diff --git a/.github/actions/setup-proteus/action.yml b/.github/actions/setup-proteus/action.yml index 44766bc80..c797877b9 100644 --- a/.github/actions/setup-proteus/action.yml +++ b/.github/actions/setup-proteus/action.yml @@ -16,7 +16,7 @@ inputs: julia-version: description: Julia version required: false - default: '1.12.6' + default: '1.13.0' install-editable-submodules: description: > Whether to clone MORS / aragog / JANUS / CALLIOPE / ZEPHYRUS / Zalmoxis diff --git a/.github/workflows/ci-nightly.yml b/.github/workflows/ci-nightly.yml index 27fa94458..d9abd8a97 100644 --- a/.github/workflows/ci-nightly.yml +++ b/.github/workflows/ci-nightly.yml @@ -262,6 +262,13 @@ jobs: os: ubuntu-latest files: >- tests/integration/test_slow_agni_aragog.py + # ---- shard: orbit-evection (pure orbit.satellite physics, + # no real submodule needed; Linux-only, ~25-40 min) ---- + - shard: orbit-evection + tier: extended + os: ubuntu-latest + files: >- + tests/integration/test_slow_orbit_evection_ctl.py # ---- shard: grid (dummy-backend parameter grid; a single # module-scoped fixture runs the whole grid, ~27 min. Linux-only # extended because it needs no macOS parity) ---- diff --git a/.gitignore b/.gitignore index 2aedede6b..d4907f7cf 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,13 @@ lovepy Lovepy LOVEPY LovePy +obliqua +Obliqua +OBLIQUA +Obliqua_data +platon +Platon +PLATON /prt petitradtrans petitRADTRANS diff --git a/docs/Explanations/code_architecture.md b/docs/Explanations/code_architecture.md index 1dded235f..a1b468d93 100644 --- a/docs/Explanations/code_architecture.md +++ b/docs/Explanations/code_architecture.md @@ -12,7 +12,7 @@ coupled planetary evolution simulation: - [`atmos_chem/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/atmos_chem): atmospheric photochemistry (VULCAN, dummy) - [`escape/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/escape): atmospheric mass loss (ZEPHYRUS, dummy) - [`outgas/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/outgas): volatile partitioning (CALLIOPE, atmodeller, dummy) -- [`orbit/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/orbit): orbital evolution and tides (Obliqua/LovePy, dummy) +- [`orbit/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/orbit): tidal response (Obliqua/LovePy, dummy) and orbital evolution (native) - [`star/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/star): stellar evolution and spectra (MORS, dummy) Most modules follow a common pattern: a `wrapper.py` defining the dispatch diff --git a/docs/Explanations/dummy_modules.md b/docs/Explanations/dummy_modules.md index 5cd90dbb9..3c2ef03ca 100644 --- a/docs/Explanations/dummy_modules.md +++ b/docs/Explanations/dummy_modules.md @@ -139,7 +139,7 @@ from Kepler's third law. A configurable tidal heating amplitude (`H_tide`) is applied to mantle layers where the melt fraction exceeds a threshold (`Phi_tide`), providing a simple parameterised heat source for testing the interior module's response to tidal power without -running the full LovePy viscoelastic solver. +running the full LovePy or Obliqua viscoelastic solvers. --- diff --git a/docs/Explanations/model.md b/docs/Explanations/model.md index 7b711dcdc..9652bdbe1 100644 --- a/docs/Explanations/model.md +++ b/docs/Explanations/model.md @@ -45,7 +45,8 @@ atmosphere), enabling hierarchical model intercomparison. | Star | [MORS](https://proteus-framework.org/MORS/), dummy | Stellar evolution and spectrum | | Escape | [ZEPHYRUS](https://github.com/FormingWorlds/ZEPHYRUS), dummy | Atmospheric escape | | Outgassing | [CALLIOPE](https://proteus-framework.org/CALLIOPE/), [atmodeller](https://github.com/djbower/atmodeller), dummy | Volatile exchange between interior and atmosphere | -| Orbit | [Obliqua](https://github.com/FormingWorlds/Obliqua), dummy | Orbital evolution and tidal heating | +| Tides | [Obliqua](https://proteus-framework.org/Obliqua), [Lovepy](https://github.com/nichollsh/LovePy), dummy | Tidal response of the planet (Love numbers, heating) | +| Orbit | PROTEUS (internal) | Orbital evolution (semi-major axis, eccentricity, spin) | | Observations | [petitRADTRANS](https://petitradtrans.readthedocs.io/), none | Synthetic transit and eclipse spectra | Each module is maintained in its own repository and can be used as a standalone package outside of PROTEUS. The following sections describe each module's physical role and how PROTEUS couples to it. @@ -189,11 +190,17 @@ Some notable consequences of step 3: `utils.coupler.assert_mass_conservation` therefore checks two things separately. `M_atm <= M_planet` is enforced under `outgas.vapourise = false`. `M_vol_atm` equals the sum of the per-species atmospheric masses; rock vapour is excluded from `M_vol_atm` by definition. -## Orbital evolution: Obliqua +## Tidal evolution: Obliqua, Lovepy -**[Obliqua](https://github.com/FormingWorlds/Obliqua)** (Julia) evolves the orbital semi-major axis and eccentricity under the influence of tidal dissipation. The tidal response of the planet is computed from its interior structure and rheology using a viscoelastic love-number solver (LovePy). Tidal heating power is distributed radially across the mantle and fed back into the interior energy equation. Obliqua also computes the spin-orbit evolution and checks for dynamical stability (Roche limit, Hill sphere). +**[Obliqua](https://proteus-framework.org/Obliqua)** (Julia) computes the multi-phase tidal response of the planet. The tidal love-numbers of the planet is computed from its interior structure and rheology using various viscoelastic rheological models. Tidal heating power is distributed radially across the mantle and fed back into the interior energy equation. The model is valid for arbitrary eccentricity and spin-orbit misalignment, as it models arbitrary tidal degrees and modes. -Config section: `[orbit]`. Reference: [Star and orbit configuration](../Reference/config/star_orbit.md). +**[Lovepy](https://github.com/nichollsh/LovePy)** (Julia) computes the solid-only tidal response of the planet using a Maxwell rheology. Tidal heating power is distributed radially across the mantle and fed back into the interior energy equation. It assumes spin-orbit synchronisation and a small eccentricity, as only the dominant degree-2 tidal modes are considered. + +## Orbital evolution: PROTEUS (internal) + +**[Orbital evolution](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/orbit)** (Python) computes the time evolution of the independent orbital parameters (semi-major axis, eccentricity, spin, apsidal precession) under the influence of tidal dissipation in both the primary and the perturbing body. The model combines spin-orbit dynamics with eccentricity evolution, based on a vectorial approach expressed in Hansen coefficients. The model allows for angular momentum draining through the evection resonance, but conserves it in all other cases. + +Config section: `[orbit]`. Reference: [Star and orbit configuration](../Reference/config/star_orbit.md). For the full set of star-planet and planet-satellite models (sp0d/sp1d/ps0d/ps1d/ps1d_evec), the evection resonance, and the satellite Love-number lookup workflow, see [Orbital dynamics and tides](orbit.md). ## Synthetic observations: petitRADTRANS @@ -240,7 +247,7 @@ architecture and for quick parameter exploration. | Star | Fixed effective temperature and luminosity; Planck-function spectrum at a user-specified $T_\mathrm{eff}$ | | Escape | Constant bulk mass loss rate (user-specified kg/s), distributed proportionally across elements | | Outgassing | Melt-fraction-dependent volatile partitioning with fixed stoichiometry, no equilibrium chemistry | -| Orbit | Fixed semi-major axis and eccentricity; configurable parameterised tidal heating | +| Orbit & Tides | Fixed semi-major axis and eccentricity; configurable parameterised tidal heating | The [Quick start tutorial](../Tutorials/quick_start_dummy.md) runs PROTEUS with all modules set to dummy. diff --git a/docs/Explanations/orbit.md b/docs/Explanations/orbit.md new file mode 100644 index 000000000..fa576e1f4 --- /dev/null +++ b/docs/Explanations/orbit.md @@ -0,0 +1,403 @@ +
+

+ + +

+
+ +# Orbital dynamics + +This page describes the orbital dynamics included within PROTEUS, how +its config options combine, and how the physics is validated. For the +config field tables themselves, see +[Star and orbit configuration](../Reference/config/star_orbit.md). For the +one-paragraph summaries of each tidal module, see +[Tidal evolution: Obliqua, Lovepy](model.md#tidal-evolution-obliqua-lovepy) +and [Orbital evolution: PROTEUS (internal)](model.md#orbital-evolution-proteus-internal). + +## Two independent evolution families + +PROTEUS evolves an orbit around exactly one body at a time: + +
+ +- ![Star-planet orbit](../assets/orbit/orbit_sp.png) + + **Star-planet** + + Evolve the planet's own orbit around its host star. + + [Star-planet models](#star-planet-models-orbitstar_planet_model){ .md-button .md-button--primary } + +- ![Planet-satellite orbit](../assets/orbit/orbit_ps.png) + + **Planet-satellite** + + Evolve a satellite's orbit around the planet. + + [Planet-satellite models](#planet-satellite-models-orbitplanet_satellite_model){ .md-button .md-button--primary } + +
+ +These are mutually exclusive (a config error is raised if both are set). +A satellite can still be *tracked* (`orbit.satellite.include_satellite`) +without its own evolution model, in which case its semi-major axis and +eccentricity stay fixed at their configured initial values while the +planet's orbit around the star evolves independently. The reverse also +works: `planet_satellite_model` can evolve the satellite while the +planet-star orbit stays fixed at its initial `semimajoraxis`/`eccentricity`. + +## Tidal response modules (`orbit.module`) + +The tidal module supplies two things every evolution model needs: a +heating profile to add to the interior's energy equation, and a Love-number +description of the dissipation (`hf_row['Imk2']` and/or the full mode +spectrum in `tides_o`). + +| Module | What it computes | Assumptions | +|---|---|---| +| `dummy` | A fixed heating rate `H_tide` applied where the local melt fraction satisfies an inequality (`Phi_tide`, e.g. `"<0.3"`); returns a fixed `Imk2`. | No physical rheology; a parameterised heat source for testing the interior's response. | +| `lovepy` | Solid-only viscoelastic (Maxwell) Love number from the topmost region above a viscosity threshold. | Degree-2 only, small eccentricity, spin-orbit synchronisation. | +| `obliqua` | Multi-phase (solid/mushy/fluid) Love-number spectrum from the full interior profile, for arbitrary tidal degree/mode (`orbit.obliqua.n`/`m`) and eccentricity. | The only module that can compute a satellite-side response (see [Satellite Love-number lookup](#satellite-love-number-lookup-obliqua-only) below); requires `orbit.perturber` set explicitly. | + +??? note "dummy in a nutshell - van Dijk et al. (2026)[^cite-vandijk2026]" + Grew out of a study of the Hadean Earth-Moon system, asking how long + tidal heating could keep a magma ocean from fully solidifying without + modelling the rheology in detail: heating is simply switched on below + a melt-fraction threshold and scaled linearly with the remaining + solid fraction. Sweeping that heating rate reveals quasi-steady + "global radiative equilibrium" epochs, where interior heating and + atmospheric cooling balance. + +??? note "lovepy in a nutshell - Nicholls et al. (2025)[^cite-nicholls2025lovepy]" + Solves for the planet's actual viscoelastic (Maxwell) response by + propagating the tidal deformation through radial layers, rather than + prescribing a heating rate. Applied to the L 98-59 system, it revealed + a self-limiting "radiation-tide-rheology" feedback: as tidal heating + softens the mantle, dissipation efficiency drops too, capping heating + at levels up to two orders of magnitude below earlier estimates - + while still being enough to sustain magma oceans for billions of + years. + +??? note "obliqua in a nutshell" + Obliqua generalises the same viscoelastic idea beyond `lovepy`'s + single solid layer and low-eccentricity limit: it resolves solid, + mushy, and fluid regions together, at arbitrary tidal degree, mode, + and eccentricity. The dummy-module study above hinted at how much + tidal heating can matter for early evolution, but only for one + fixed, simplified regime; Obliqua exists to track the tidal response + self-consistently across the much wider range of thermal and + orbital states real exoplanets occupy. + +!!! warning "Dynamic-tide resonances (Obliqua)" + Setting `orbit.obliqua.solid.inertial_terms` to `true` solves the full + finite-frequency problem instead of the quasi-static ($\omega \to 0$) approximation. + When tidal forcing matches a normal-mode frequency of the body, it produces a + physically real, bounded peak in the Love number—not a numerical grid artifact. + + * **When they occur:** Only when parts of the mantle are molten or mushy. + * **Control knob:** Use `params.dt.mushy_maximum` to tighten timesteps during solidification. + This forces PROTEUS to re-sample Obliqua frequently enough to resolve resonance crossings. + * **Safety cap:** `orbit.obliqua.cap_LN` clamps each mode's Love number to a fixed multiple + of the classical fluid limit for its degree n. This prevents extreme heating spikes while + macro-steps are too large to fully resolve the resonance timescale. + +## Star-planet models (`orbit.star_planet_model`) + +| Model | Evolves | Reference | Notes | +|---|---|---|---| +| `sp0d` | `semimajorax`, `eccentricity` | Driscoll & Barnes (2015)[^cite-driscoll2015], Eq. 15-16 | Closed-form two-ODE system in `(a, e)` only; no spin dynamics, so it is **not** angular-momentum-conserving by construction. | +| `sp1d` | `axial_period`, `semimajorax`, `eccentricity`, `plan_star_am` | Correia & Valente (2022)[^cite-correia2022] | Vectorial, Hansen-coefficient formulation restricted to planetary tides (star assumed non-dissipative). Genuinely angular-momentum-conserving; verified by dedicated tests. | + +??? note "sp0d in a nutshell - Driscoll & Barnes (2015)" + Written for rocky planets around M dwarfs, where the habitable zone + sits close enough in that tides matter. Treats the planet as a + passive, non-rotating "equilibrium tide" bulge dragged slightly + behind (or ahead of) the star: that lag drains eccentricity and + trades orbital energy for heat inside the planet. No spin, no + resonances -- just a slow circularisation clock coupled to whatever + the interior does with the heat. + +??? note "sp1d in a nutshell - Correia & Valente (2022)" + Instead of one lumped tidal bulge, the tidal potential is decomposed + into its individual Fourier harmonics (Hansen coefficients), each + oscillating at its own forcing frequency and dissipating + independently. This removes the low-eccentricity assumption baked + into classical tidal theory, and it means spin and orbit are evolved + together as one system, exchanging angular momentum internally. + +Both integrate with `scipy.solve_ivp` (`orbit.solver.*` controls method +and tolerances). + +## Planet-satellite models (`orbit.planet_satellite_model`) + +| Model | Evolves | Reference | Notes | +|---|---|---|---| +| `ps0d` | `semimajorax_sat`, `axial_period` | Korenaga (2023)[^cite-korenaga2023], Eq. 58-60 | No eccentricity evolution, no satellite-side tide. Uses the `M_sat << M_planet` limit of the orbital angular-momentum term (~1.2% error for Earth-Moon). | +| `ps1d` | `axial_period`, `axial_period_sat`, `semimajorax_sat`, `eccentricity_sat`, `plan_sat_am` | Correia & Valente (2022)[^cite-correia2022] | Same vectorial approach as `sp1d`, extended to track both planet-raised and satellite-raised tidal contributions separately. Requires satellite-side Love-numbers (see below). | +| `ps1d_evec` | Everything `ps1d` evolves, plus `evection_angle` | `ps1d` physics plus Rufu & Canup (2020)[^cite-rufu2020] evection-resonance terms | Adds a J2-driven apsidal-precession term and a resonant forcing term. See [Evection resonance](#evection-resonance-ps1d_evec) below. | + +??? note "ps0d in a nutshell - Korenaga (2023)" + Built to explain why the Moon's magma ocean stayed molten for so + long: rather than solving the tidal potential in detail, it tracks + one number, the system's total (spin + orbital) angular momentum, + and lets the planet's tidal dissipation rate spend it. As the + planet's spin winds down, the satellite's orbit must expand to keep + the ledger balanced - a bookkeeping model, not a torque model, so + it is cheap and exactly momentum-conserving, at the cost of no + eccentricity evolution. + +??? note "ps1d in a nutshell - Correia & Valente (2022)" + The same Hansen-coefficient decomposition as `sp1d`, but with two + dissipating bodies instead of one: both the planet's and the + satellite's tidal responses pull on the shared orbit, so each of + their spins, the semi-major axis, and the eccentricity all evolve + together, coupled through one exchange of angular momentum. + +??? note "ps1d_evec in a nutshell - Rufu & Canup (2020)" + As a tidally-receding moon's orbit expands, its slow apsidal + precession can fall into step with the star's apparent yearly + motion - a secular resonance. Falling into that resonance is like + pushing a swing at just the right moment: it pumps up the moon's + eccentricity long after ordinary tides alone would have damped it + flat, which is the paper's proposed route to the Moon's present-day + orbital tilt. + +!!! warning "Satellite Love-number lookup" + Both `ps1d` and `ps1d_evec` need the satellite's own Love-number spectrum as a + function of forcing frequency, which only `orbit.module='obliqua'` can + supply (via [`LN_from_lookup`](#satellite-love-number-lookup-obliqua-only)). + Using `ps1d`/`ps1d_evec` unconditionally populates the satellite's tidal + parameters in `tides_o` through Obliqua's `lookup_from_interior` at the + start of the run. + +## Compatibility between orbit models and tidal modules + +A tidal module makes up to two things available: the scalar +`hf_row['Imk2']`, and/or the full per-mode spectrum in `tides_o`. Which one +an orbit model reads is exactly what its `0d`/`1d` suffix tracks -- a `0d` +model reads the scalar path, a `1d` model reads `tides_o` directly. + +**What each tidal module provides:** + +| `orbit.module` | `Imk2` | `tides_o` | `hf_row['F_tidal']` | +|---|---|---|---| +| `dummy` | Yes | No | Yes | +| `lovepy` | Yes | Yes | yes | +| `obliqua` | Yes, only when `orbit.obliqua.n == [2]` (`0.0` otherwise) | Yes, planet always, satellite too when `orbit.perturber='satellite'` | yes | + +**What each orbit model reads:** + +| Model | Reads | Compatible `orbit.module` | +|---|---|---| +| `sp0d` | `hf_row['Imk2']` | `dummy`, `lovepy`, `obliqua` (requires `orbit.obliqua.n == [2]`) | +| `sp1d` | `tides_o`, (`primary='planet', perturber='star'`) | `lovepy`, `obliqua` | +| `ps0d` | `hf_row['F_tidal']` | `dummy`, `lovepy`, `obliqua` | +| `ps1d` | `tides_o`, (both `primary='planet', perturber='satellite'` and `primary='satellite', perturber='planet'`) | `lovepy`, `obliqua` | +| `ps1d_evec` | Same as `ps1d`, plus `evection_angle` | `lovepy`, `obliqua` (Note that `lovepy` breaks down at high eccentricities, so it is not recommended for this case) | + +!!! warning "Note on `*1d` models" + `sp1d`, `ps1d`, and `ps1d_evec` are rejected at config load when + `orbit.module` is not `'obliqua'` or `'lovepy'`. Prefer + `orbit.module='obliqua'` for any `*1d` orbit model. + +--- + +### Satellite Love-number lookup (Obliqua only) + +Unlike the planet, whose interior structure evolves and is re-queried +every coupling step, the satellite's interior is treated as static for +the lifetime of a run. `orbit.obliqua.lookup_from_interior` builds a full +frequency-spectrum Love-number table once, from a fixed satellite +interior description (`orbit.satellite.love_number_sat`, a JSON initial +condition read by a simplified 0-D solid/fluid Obliqua configuration), +and writes it to a NetCDF file (`sat_tides.nc`). Alternatively, the user +can provide their own pre-computed table (`orbit.satellite.love_number_sat`, +a NetCDF file), which will be used instead of the one generated by +`lookup_from_interior`. Every subsequent coupling step, `LN_from_lookup` +computes the satellite's own forcing frequencies from its current spin and +orbital state and interpolates the satellite's Love numbers from that fixed +table (linear in frequency, per tidal degree). + +### Evection resonance (`ps1d_evec`) + +Evection resonance happens when a moon’s elongated orbit rotates at the +exact same speed that the central planet orbits its star.; capture into +it can pump the satellite's eccentricity well above +what tides alone would produce. `ps1d_evec` detects proximity to the +resonance location `a'_res` (Rufu & Canup 2020, Eq. 12) with a debounced, +hysteretic band detector (separate entry/exit margins, +`orbit.solver.resonance_margin_enter`/`resonance_margin_exit`, avoid +chattering at the band edge) and gates only the *oscillating* resonant +forcing term on that detector. The secular apsidal-precession term and +the evection angle's own evolution are always active regardless of +band status. Setting the gate to zero decouples the resonant forcing +term, reducing `ps1d_evec` to plain `ps1d` dynamics. + +
+

+ + +

+Example evection-resonance episode. The satellite starts outside +the resonance band, evolving freely; capture into the band locks the +evection angle to the resonant condition and pumps up the eccentricity; +escape from the band later returns the system to free, non-resonant +precession. +

+
+ +While in or near the band, two additional controls apply: + +- **Rate cap** (`params.dt.evection_*`): bounds the next PROTEUS coupling + step so the fractional change in `eccentricity_sat` stays near + `evection_target_rel_de`, since Obliqua's own adaptive spectrum window + is chosen once per step from the eccentricity at that step's start. +- **Growth limiter**: caps how fast the step size can grow relative to + the previous step while inside the band, or for `evection_cooldown_iters` + iterations after leaving it, so the coupling step does not snap back to + its ordinary size the instant the band is exited. + +Both are folded into the single exported column `evection_dt_cap_yr`, +which `interior_energetics.timestep.next_step` applies as one of several +caps on the next main-loop step. + +**Reproducing capture and peak eccentricity** against Rufu & Canup (2020) +Figure 3 is validated in +[`tests/integration/test_slow_orbit_evection_ctl.py`](../../tests/integration/test_slow_orbit_evection_ctl.py) +(`@pytest.mark.slow`): the real `ps1d_evec` model, driven by a Mignard +constant-time-lag tidal spectrum, reproduces the paper's resonance-capture +timing window and peak-eccentricity location to better than 0.1% in +semi-major axis. + +### The "three clocks" + +Fine-grained diagnostics for `ps1d_evec` (`fine_evection_data.csv`) +distinguish three different notions of "step": + +1. **PROTEUS main-loop clock**: one `evolve_orbit_satellite` call per + coupling iteration, spanning `interior_o.dt` years. +2. **Solver clock**: the adaptive-substep controller's own internal + `dt_yr` steps within one main-loop call (see below), and, within a + single substep, `solve_ivp`'s own internal integration points. +3. **Storage clock**: how densely those solver-clock samples are written + to disk. Every solver-clock sample is kept while inside the evection + band; outside the band, samples are thinned to a target spacing + (`orbit.solver.fine_csv_target_rel_dt`, a fraction of the current + main-loop step) so the file does not grow unbounded over a long, + quiescent run. + +## Hansen coefficients + +The `sp1d`/`ps1d`/`ps1d_evec` vectorial tidal models expand the tide-raising +potential in Hansen coefficients `X_k^{n,m}(e)`, evaluated at every ODE +substep. A direct FFT evaluation is too slow for that (implicit solvers +probe many micro-varying eccentricities per step), and nearest-neighbor +caching would introduce discontinuities that break implicit solvers. +[`orbit.hansen`](../../src/proteus/orbit/hansen.py) instead pre-tabulates, +once per run, the eccentricity-dependent mode window `[k_min, k_max]` +(since the number of significant modes grows from ~10 near `e=0` to +several hundred above `e=0.8`) and then the coefficient values themselves +on that window, both linearly interpolated in `e` thereafter. The tables +are warmed up once by `orbit.wrapper.run_orbit` at `Time<=1`; a hot-path +call lazily builds them if warm-up was skipped. + +!!! warning "High eccentricity" + The underlying Kepler solver does not converge beyond `e~0.90`, hence + a warning is issued. + +## Adaptive substep controller + +`sp1d`, `ps0d`, `ps1d`, and `ps1d_evec` all integrate through the same +accept/reject controller, +[`orbit.common.run_adaptive_orbit_substeps`](../../src/proteus/orbit/common.py). +For each attempted internal step it stages the tentative result, checks it +for unphysical values (negative semi-major axis, eccentricity outside `[0, 1)`, +non-finite spin) and for excessive relative change in tracked quantities +(`orbit.solver.max_rel_*`), then either merges it in and grows the step, +or discards it and shrinks the step (`orbit.solver.growth`/`shrink`). + +The same call also keeps the planet's moment of inertia (`C_int`, from +[`interior_energetics.common.get_C_planet`](../../src/proteus/interior_energetics/common.py)) +consistent with the live interior structure: rather than jumping to the +freshly computed value once per call (which would put a discontinuity in +any quantity that depends on the planet's spin rate, such as `ps1d_evec`'s +oblateness-driven precession), the controller ramps `C_int` linearly +across the call's accepted substeps, rescaling `axial_period` at each one +to conserve `C_int * Omega_p` (angular momentum). + +`ps0d` additionally gets a **cumulative drift cap**: `ps0d` has no +eccentricity or spin feedback of its own, so many small, individually-legal +substeps can compound into a large silent migration within a single call. + +## Termination criteria + +Orbital and rotational state feed three physical stopping conditions +(`params.stop.*`, checked in `utils.terminate`): + +- **Disintegration** (`params.stop.disint`/`disint_sat`): the planet or + satellite orbiting within its partner's Roche limit, or spinning faster + than its breakup rate. +- **Satellite escape** (`params.stop.satellite`): the satellite's + semi-major axis exceeding `sma_max`. + +## Model selection guide + +- Want a cheap, non-physical heat source for testing the interior's + response to tides? `orbit.module = 'dummy'`, no evolution model. +- Want a self-consistent solid-body tidal response with minimal setup, + spin-orbit synchronised, low eccentricity? `orbit.module = 'lovepy'`. +- Want the planet's orbit and spin to evolve self-consistently with its + own interior structure, at arbitrary eccentricity? `orbit.module = + 'obliqua'`, `orbit.perturber = 'star'`, `orbit.star_planet_model = + 'sp1d'`. +- Want a fast, closed-form estimate of star-planet tidal circularisation + without resolving spin? `orbit.module.dummy` supplying `Imk2` plus + `orbit.star_planet_model = 'sp0d'`. +- Want a satellite's orbit (e.g. a moon) to evolve, including its own + tidal response? `orbit.module = 'obliqua'`, `orbit.perturber = + 'satellite'`, `orbit.planet_satellite_model = 'ps1d'` (or `'ps0d'` for a + cheaper, eccentricity-frozen estimate). +- Want to also study capture into, and eccentricity pumping by, the + evection resonance? `orbit.module = 'obliqua'`, `orbit.perturber = + 'satellite'`, `orbit.planet_satellite_model = 'ps1d_evec'`. + +## Testing + +- **Unit tests** (`tests/orbit/*.py`, `@pytest.mark.unit`) mock Julia + calls for `lovepy`/`obliqua` and pin closed-form results for `sp0d`, + `sp1d`, `ps0d`, `ps1d` against their source papers, each with a + discrimination guard against the nearest plausible wrong formula (wrong + exponent, wrong sign, or the wrong mass in a prefactor). Angular + momentum conservation for `sp1d`/`ps1d` is checked directly, both as a + raw total and as a targeted spin-vs-orbital exchange test sized to + catch a bug the raw total alone would miss. +- **Slow/integration tests** (`tests/integration/test_slow_orbit_evection_ctl.py`, + `@pytest.mark.slow`) drive the real `ps1d_evec` model end-to-end and + compare against the published Rufu & Canup (2020) evection trajectory. +- Per-source-file test inventories, references, and re-derivations are + tracked under + [`docs/Validation/orbit/`](../Validation/orbit/orbit.md): `orbit.py`, + `satellite.py`, `wrapper.py`, `obliqua.py`. + +--- + +**See also:** [Model description](model.md) | [Star and orbit configuration](../Reference/config/star_orbit.md) | [Execution and output configuration](../Reference/config/params.md) | [Validation: orbit](../Validation/orbit/orbit.md) + + [^cite-vandijk2026]: van Dijk, M.R., Nicholls, H. & Lichtenberg, T., *[Onset of Habitable Conditions on the Hadean Earth Set by Feedback between Tides and Greenhouse Forcing](https://doi.org/10.3847/PSJ/ae5928)*, The Planetary Science Journal, 7, 94, 2026. + + [^cite-nicholls2025lovepy]: Nicholls, H., Guimond, C.M., Hay, H.C.F.C., Chatterjee, R.D., Lichtenberg, T. & Pierrehumbert, R.T., *[Self-limited tidal heating and prolonged magma oceans in the L 98-59 system](https://doi.org/10.1093/mnras/staf1167)*, Monthly Notices of the Royal Astronomical Society, 541, 2566-2584, 2025. + + [^cite-driscoll2015]: Driscoll, P. & Barnes, R., *[Tidal Heating of Earth-like Exoplanets around M Stars: Thermal, Magnetic, and Orbital Evolutions](https://doi.org/10.1089/ast.2015.1325)*, Astrobiology, 15, 739, 2015. + + [^cite-correia2022]: Correia, A.C.M. & Valente, E.F.S., *[A simple model to study tides in moons](https://doi.org/10.1007/s10569-022-10079-3)*, Celestial Mechanics and Dynamical Astronomy, 134, 27, 2022. + + [^cite-korenaga2023]: Korenaga, J., *[Rapid tidal dissipation explains the extended lunar magma ocean](https://doi.org/10.1016/j.icarus.2023.115564)*, Icarus, 400, 115564, 2023. + + [^cite-rufu2020]: Rufu, R. & Canup, R.M., *[Evection resonance as a possible cause for lunar inclination](https://doi.org/10.1029/2019JE006312)*, Journal of Geophysical Research: Planets, 125, e2019JE006312, 2020. diff --git a/docs/How-to/manual_installation.md b/docs/How-to/manual_installation.md index 19c82deec..15f53e51d 100644 --- a/docs/How-to/manual_installation.md +++ b/docs/How-to/manual_installation.md @@ -81,8 +81,8 @@ conda activate proteus ## 3. Install Julia -Some PROTEUS modules (AGNI, LovePy) are written in Julia. Install via the -official installer: +Some PROTEUS modules (AGNI, LovePy, Obliqua) are written in Julia. Install via +the official installer: ```console curl -fsSL https://install.julialang.org | sh diff --git a/docs/How-to/optionalmodules_installation.md b/docs/How-to/optionalmodules_installation.md index 3df35c994..6cfd8e62a 100644 --- a/docs/How-to/optionalmodules_installation.md +++ b/docs/How-to/optionalmodules_installation.md @@ -34,15 +34,24 @@ bash tools/get_spider.sh encounter issues, see [Troubleshooting: PETSc on Apple Silicon](troubleshooting.md#petsc-compilation-fails-on-apple-silicon). -## Multi-phase tidal heating (LovePy) +## Solid-phase tidal heating (LovePy) -LovePy computes tidal dissipation for multi-phase planetary interiors. It is +LovePy computes tidal dissipation for solid-phase planetary interiors. It is written in Julia. ```console bash tools/get_lovepy.sh ``` +## Multi-phase tidal response (Obliqua) + +Obliqua computes the multi-phase tidal response of the planet from its +interior structure and rheology. It is written in Julia. + +```console +bash tools/get_obliqua.sh +``` + ## Synthetic observations (PetitRADTRANS) [PetitRADTRANS](https://petitradtrans.readthedocs.io/en/latest/) generates diff --git a/docs/How-to/stabilise_run.md b/docs/How-to/stabilise_run.md index 28a6a4808..97c5d2369 100644 --- a/docs/How-to/stabilise_run.md +++ b/docs/How-to/stabilise_run.md @@ -70,6 +70,37 @@ Reduce the interior grid resolution: num_levels = 50 ``` +--- + +## Tidal Love-number resonances (Obliqua) + +If Obliqua's Love-number spectrum (`plot_lovenumber.png`) shows a point +ringed red ("seismic resonance", `Re(k2) > 1.5` or `Im(k2) > 1.0`), or +the orbit/spin state jumps abruptly over one or a few iterations, the +tidal forcing frequency likely crossed one of the interior's own dynamic +normal-mode resonances. These resonances occur at the same time the +mantle is partially molten, hence to properly resolve them reduce the +mushy-regime timestep limit: + +```toml +[params.dt] + mushy_maximum = 1.0e4 # or lower; these resonances only occur while + # part of the mantle is molten/mushy, so a + # tighter mushy-regime cap gives PROTEUS more + # chances to re-sample Obliqua during a crossing + mushy_upper = 0.99 +``` + +Alternatively, PROTEUS is able to cap the Love number Obliqua +returns as a safety net. This suppresses divergences that arise at large +timesteps, by limiting the overall tidal response: + +```toml +[orbit.obliqua] + cap_LN = true # clamp each mode's Re(k2)/Im(k2) to 3x/2x the fluid Love-number limit for its degree n +``` + + --- ## General tips diff --git a/docs/How-to/update_module_pins.md b/docs/How-to/update_module_pins.md index 9a1fbdaf8..2c8c7a8d6 100644 --- a/docs/How-to/update_module_pins.md +++ b/docs/How-to/update_module_pins.md @@ -19,7 +19,7 @@ module is distributed. |----------|------------------------------------|-----------|---------| | PyPI floor | `[project] dependencies` | Minimum version bound, e.g. `fwl-aragog>=26.05.13` | fwl-janus, fwl-mors, fwl-calliope, fwl-zephyrus, fwl-aragog, fwl-zalmoxis | | PyPI floor (optional) | `[project.optional-dependencies]` | Minimum version bound on an optional backend | fwl-vulcan, atmodeller | -| Git ref | `[tool.proteus.modules.]` | Exact commit SHA, tag, or branch in a `ref` field | AGNI, SOCRATES, SPIDER, BOREAS, LovePy | +| Git ref | `[tool.proteus.modules.]` | Exact commit SHA, tag, or branch in a `ref` field | AGNI, SOCRATES, SPIDER, BOREAS, LovePy, Obliqua | A third entry, PETSc, is pinned in `[tool.proteus.modules.petsc]` by the SHA-256 of a pre-built archive rather than a git ref, because it is downloaded as a @@ -156,9 +156,9 @@ checks out the pinned SHA, the check reads `ok`. The `ref` field accepts a commit SHA, a tag, or a branch name. They trade reproducibility against convenience: -- **Commit SHA** (AGNI, SOCRATES, SPIDER): fully reproducible. The same PROTEUS - commit always builds the same external source. Use this for any module whose - exact state affects simulation results. +- **Commit SHA** (AGNI, SOCRATES, SPIDER, Obliqua): fully reproducible. The + same PROTEUS commit always builds the same external source. Use this for + any module whose exact state affects simulation results. - **Tag**: reproducible as long as the upstream tag is not moved. Convenient when a module publishes named releases. - **Branch** (LovePy tracks `main`): always pulls the latest commit on that diff --git a/docs/Reference/config/config_schema.json b/docs/Reference/config/config_schema.json index a112b09ec..d02cff41a 100644 --- a/docs/Reference/config/config_schema.json +++ b/docs/Reference/config/config_schema.json @@ -489,6 +489,132 @@ "group": null, "group_qualifier": null }, + { + "path": "params.dt.evection_maximum", + "toml_section": "params.dt", + "class": "TimeStepParams", + "type": "float or none", + "accepts_none": true, + "default": "none", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Ceiling on the time-step size [yr] while the planet-satellite system is inside, or approaching, the evection resonance band. Must be > 0 when set. Default ``'none'`` (disables the whole mechanism).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.dt.evection_target_rel_de", + "toml_section": "params.dt", + "class": "TimeStepParams", + "type": "float", + "accepts_none": false, + "default": "0.05", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Target maximum fractional change in ``eccentricity_sat`` per macro-step while inside/approaching the evection band. Obliqua's own adaptive mode-window selection is keyed on the eccentricity it is given at call time, and that window is then held fixed for the whole of the following macro-step, so a large fractional swing in ``e`` within one step risks needing modes outside that window. Default 0.05 (5%).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.dt.evection_de_floor", + "toml_section": "params.dt", + "class": "TimeStepParams", + "type": "float", + "accepts_none": false, + "default": "0.02", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Floor on the eccentricity value used in the ``evection_target_rel_de`` ratio's denominator, so a tiny eccentricity right at capture onset does not make the allowed step blow up. Default 0.02.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.dt.evection_rate_window", + "toml_section": "params.dt", + "class": "TimeStepParams", + "type": "int", + "accepts_none": false, + "default": "2", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 2 + } + ], + "description": "Number of trailing accepted macro-steps used to estimate the SECULAR ``|de/dt|`` this cap bounds against (a least-squares linear fit for windows > 2, the plain two-point difference at the default of 2). Default 2, preserves the two-point behaviour.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.dt.evection_growth_factor", + "toml_section": "params.dt", + "class": "TimeStepParams", + "type": "float or none", + "accepts_none": true, + "default": "none", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Cap on the dt growth ratio between consecutive steps while the system is inside/approaching the evection band, or within ``evection_cooldown_iters`` steps of having left it. Separate from the global ``max_growth_factor`` (which most evection runs leave disabled, since it would also throttle ordinary bulk evolution for the rest of the run). Must be > 0 when set. Default ``'none'`` (disabled).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.dt.evection_cooldown_iters", + "toml_section": "params.dt", + "class": "TimeStepParams", + "type": "int or none", + "accepts_none": true, + "default": "none", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Number of PROTEUS iterations, after the system is no longer judged in/near the evection band, during which ``evection_growth_factor`` remains active. Refreshed to this value on every iteration the zone is active, so a long stay in the band does not exhaust it before exit. Must be > 0 when set. Default ``'none'`` (no cooldown tail; growth limiting turns off the instant the zone is left).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, { "path": "params.dt.hysteresis_iters", "toml_section": "params.dt", @@ -894,6 +1020,118 @@ "group": null, "group_qualifier": null }, + { + "path": "params.stop.disint_sat.enabled", + "toml_section": "params.stop.disint_sat", + "class": "StopDisintSat", + "type": "bool", + "accepts_none": false, + "default": "false", + "choices": null, + "bounds": null, + "description": "Enable all planet disintegration criteria if True", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.stop.disint_sat.roche_enabled", + "toml_section": "params.stop.disint_sat", + "class": "StopDisintSat", + "type": "bool", + "accepts_none": false, + "default": "true", + "choices": null, + "bounds": null, + "description": "Disable Roche limit criterion", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.stop.disint_sat.offset_roche", + "toml_section": "params.stop.disint_sat", + "class": "StopDisintSat", + "type": "float", + "accepts_none": false, + "default": "0", + "choices": null, + "bounds": null, + "description": "Absolute correction (+/-) to (increase/decrease) calculated Roche limit [m].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.stop.disint_sat.spin_enabled", + "toml_section": "params.stop.disint_sat", + "class": "StopDisintSat", + "type": "bool", + "accepts_none": false, + "default": "true", + "choices": null, + "bounds": null, + "description": "Disable Breakup period criterion", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.stop.disint_sat.offset_spin", + "toml_section": "params.stop.disint_sat", + "class": "StopDisintSat", + "type": "float", + "accepts_none": false, + "default": "0", + "choices": null, + "bounds": null, + "description": "Absolute correction (+/-) to (increase/decrease) calculated Breakup period [s].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.stop.satellite.enabled", + "toml_section": "params.stop.satellite", + "class": "StopSatellite", + "type": "bool", + "accepts_none": false, + "default": "false", + "choices": null, + "bounds": null, + "description": "Enable criteria if True", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "params.stop.satellite.sma_max", + "toml_section": "params.stop.satellite", + "class": "StopSatellite", + "type": "float", + "accepts_none": false, + "default": "60", + "choices": null, + "bounds": null, + "description": "Maximum semi-major axis for the satellite [R_Earth].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, { "path": "params.stop.clock.enabled", "toml_section": "params.stop.clock", @@ -1397,7 +1635,1193 @@ "value": 0.0 } ], - "description": "Stellar age from which bol_scale is applied [Gyr]. 'None' to disable.", + "description": "Stellar age from which bol_scale is applied [Gyr]. 'None' to disable.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.semimajoraxis", + "toml_section": "orbit", + "class": "Orbit", + "type": "float", + "accepts_none": false, + "default": "1.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Initial semi-major axis of the planet's orbit [AU].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.eccentricity", + "toml_section": "orbit", + "class": "Orbit", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + }, + { + "op": "<", + "value": 1 + } + ], + "description": "Initial Eccentricity of the planet's orbit.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.instellation_method", + "toml_section": "orbit", + "class": "Orbit", + "type": "str", + "accepts_none": false, + "default": "\"distance\"", + "choices": [ + "distance", + "inst" + ], + "bounds": null, + "description": "Whether to use the semi-major axis ('distance') or instellation flux ('inst') to define the planet's initial orbit", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.instellationflux", + "toml_section": "orbit", + "class": "Orbit", + "type": "float", + "accepts_none": false, + "default": "1.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Instellation flux initially received by the planet in Earth units.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.zenith_angle", + "toml_section": "orbit", + "class": "Orbit", + "type": "float", + "accepts_none": false, + "default": "48.19", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + }, + { + "op": "<", + "value": 90 + } + ], + "description": "Characteristic angle of incoming stellar radiation, relative to the zenith [deg].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.s0_factor", + "toml_section": "orbit", + "class": "Orbit", + "type": "float", + "accepts_none": false, + "default": "0.375", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Scale factor applies to incoming stellar radiation to represent planetary rotation.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.star_planet_model", + "toml_section": "orbit", + "class": "Orbit", + "type": "str or none", + "accepts_none": true, + "default": "none", + "choices": [ + null, + "none", + "sp0d", + "sp1d" + ], + "bounds": null, + "description": "Select star-planet orbit module to use. Choices: 'none', 'sp0d', 'sp1d'.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.axial_period", + "toml_section": "orbit", + "class": "Orbit", + "type": "float or none", + "accepts_none": true, + "default": "none", + "choices": null, + "bounds": null, + "description": "Planet initial day length [hours], will use orbital period if value is None.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.include_satellite", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "bool", + "accepts_none": false, + "default": "false", + "choices": null, + "bounds": null, + "description": "Whether to model a satellite orbiting the planet.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.mass_sat", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "float", + "accepts_none": false, + "default": "0.012", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Satellite mass [M_earth].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.radius_sat", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "float", + "accepts_none": false, + "default": "0.273", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Satellite radius [R_earth].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.axial_period_sat", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "float or none", + "accepts_none": true, + "default": "none", + "choices": null, + "bounds": null, + "description": "Satellite initial day length [hours], will use orbital period if value is None.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.semimajoraxis_sat", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "float", + "accepts_none": false, + "default": "3.5", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Satellite initial semi-major axis [R_earth].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.eccentricity_sat", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Satellite initial orbital eccentricity [dimensionless].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.evection_angle", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Satellite evection angle [deg].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.c_factor_sat", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "float", + "accepts_none": false, + "default": "0.4", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + }, + { + "op": "<=", + "value": 0.4 + } + ], + "description": "Satellite tidal dissipation factor (<= 0.4) [dimensionless].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.satellite.love_number_sat", + "toml_section": "orbit.satellite", + "class": "Satellite", + "type": "str or none", + "accepts_none": true, + "default": "none", + "choices": null, + "bounds": null, + "description": "Satellite love number spectrum, provide absolute path to netCDF file containing forcing frequencies and complex Lovenumbers, and corresponding tidal degree in nmk format.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.planet_satellite_model", + "toml_section": "orbit", + "class": "Orbit", + "type": "str or none", + "accepts_none": true, + "default": "none", + "choices": [ + null, + "none", + "ps0d", + "ps1d", + "ps1d_evec" + ], + "bounds": null, + "description": "Select planet-satellite orbit module to use. Choices: 'none', 'ps0d', 'ps1d', 'ps1d_evec'.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.method", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "str", + "accepts_none": false, + "default": "\"Radau\"", + "choices": [ + "RK45", + "RK23", + "DOP853", + "Radau", + "BDF", + "LSODA" + ], + "bounds": null, + "description": "scipy.integrate.solve_ivp integration method.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.rtol", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "1e-06", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Relative tolerance passed to solve_ivp.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.atol", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "1e-09", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Absolute tolerance passed to solve_ivp.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.dt0_yr", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.0001", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Initial adaptive-substep size [yr].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.dt_max_yr", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "2000.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Maximum adaptive-substep size [yr].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.growth", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "1.15", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 1.0 + } + ], + "description": "Substep growth factor applied after an accepted step.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.shrink", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.35", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + }, + { + "op": "<", + "value": 1 + } + ], + "description": "Substep shrink factor applied after a rejected step.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.max_rel_da", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.01", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Maximum tolerated relative change in semi-major axis per substep.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.max_rel_de", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.01", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Maximum tolerated relative change in eccentricity per substep.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.max_rel_dOmega", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.02", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Maximum tolerated relative change in a spin rate per substep.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.de_floor", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.05", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Floor on the eccentricity-change-ratio denominator, so a small starting eccentricity does not make the ratio spuriously huge.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.max_substeps", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "int", + "accepts_none": false, + "default": "10000000", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Maximum number of substeps attempted per call.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.resonance_margin_enter", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.1", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Evection-band entry margin (ps1d_evec only).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.resonance_margin_exit", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.5", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Evection-band exit margin (ps1d_evec only).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.resonance_margin_approach", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.3", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Wider, purely-diagnostic margin used to set a pre-emptive signal that the system is closing in on the band before the tighter ``resonance_margin_enter`` would declare capture.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.solver.fine_csv_target_rel_dt", + "toml_section": "orbit.solver", + "class": "OrbitSolver", + "type": "float", + "accepts_none": false, + "default": "0.01", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Target storage-clock spacing for out-of-band fine samples, as a fraction of the requested call duration (ps1d_evec only).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.perturber", + "toml_section": "orbit", + "class": "Orbit", + "type": "str or none", + "accepts_none": true, + "default": "none", + "choices": [ + null, + "none", + "star", + "satellite" + ], + "bounds": null, + "description": "Select perturber to induce tides on the planet. Options: 'none', 'star', 'satellite'.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.module", + "toml_section": "orbit", + "class": "Orbit", + "type": "str or none", + "accepts_none": true, + "default": "none", + "choices": [ + null, + "dummy", + "lovepy", + "obliqua" + ], + "bounds": null, + "description": "Select tides module to use. Choices: 'none', 'dummy', 'lovepy', 'obliqua'.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.dummy.H_tide", + "toml_section": "orbit.dummy", + "class": "Dummy", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0.0 + } + ], + "description": "Fixed global heating rate from tides [W kg-1].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.dummy.Phi_tide", + "toml_section": "orbit.dummy", + "class": "Dummy", + "type": "str", + "accepts_none": false, + "default": "\"<0.3\"", + "choices": null, + "bounds": null, + "description": "Inequality which, if locally true, determines in which regions tides are applied.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.dummy.Imk2", + "toml_section": "orbit.dummy", + "class": "Dummy", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": "<=", + "value": 0.0 + } + ], + "description": "Imaginary part of k2 Love number, which is usually negative.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.lovepy.visc_thresh", + "toml_section": "orbit.lovepy", + "class": "Lovepy", + "type": "float", + "accepts_none": false, + "default": "1000000000.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Minimum viscosity required for heating [Pa s].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.lovepy.ncalc", + "toml_section": "orbit.lovepy", + "class": "Lovepy", + "type": "int", + "accepts_none": false, + "default": "1000", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 100 + } + ], + "description": "Number of interpoltaed interior levels to use for solving tidal heating rates.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.store_3D", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "bool", + "accepts_none": false, + "default": "false", + "choices": null, + "bounds": null, + "description": "Whether to store 3D information for solid tides.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.enforce_ec", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "bool", + "accepts_none": false, + "default": "true", + "choices": null, + "bounds": null, + "description": "Whether to enforce energy conservation between Lovenumbers and heating profile.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.optimize_scales", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "bool", + "accepts_none": false, + "default": "false", + "choices": null, + "bounds": null, + "description": "Whether to optimize the non-dimensional scaling parameters for solid tides.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.solid_shell", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "bool", + "accepts_none": false, + "default": "true", + "choices": null, + "bounds": null, + "description": "Whether to insert an infinitesimal solid shell around the core.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.cap_LN", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "bool", + "accepts_none": false, + "default": "false", + "choices": null, + "bounds": null, + "description": "Whether to clamp each mode's Love number to a fixed multiple of the classical fluid limit for its degree.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.min_frac", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "float", + "accepts_none": false, + "default": "0.02", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Minimal segment radius fraction before smoothing.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.visc_lus", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "float", + "accepts_none": false, + "default": "500000.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Liquidus viscosity [Pa s].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.visc_sus", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "float", + "accepts_none": false, + "default": "500000.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Solidus viscosity [Pa s].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.n", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "list", + "accepts_none": false, + "default": "[2]", + "choices": null, + "bounds": null, + "description": "Power of the radial factor (r/a)^n.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.m", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "list", + "accepts_none": false, + "default": "[0, 2]", + "choices": null, + "bounds": null, + "description": "Tidal harmonic (m=2 semidiurnal, m=1 diurnal).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.k_min", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "int | Literal", + "accepts_none": false, + "default": "\"none\"", + "choices": null, + "bounds": null, + "description": "Minimum Fourier index in mean anomaly (adaptive spectrum).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.k_max", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "int | Literal", + "accepts_none": false, + "default": "\"none\"", + "choices": null, + "bounds": null, + "description": "Maximum Fourier index in mean anomaly (adaptive spectrum).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.evection_padding_factor", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "float", + "accepts_none": false, + "default": "2.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Safety multiplier on the linear look-ahead eccentricity padding applied to Obliqua's own adaptive k-range selection.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.material_mu", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "str", + "accepts_none": false, + "default": "\"andrade\"", + "choices": [ + "andrade", + "maxwell", + "elastic" + ], + "bounds": null, + "description": "Rheology model for complex shear modulus (\"andrade\" or \"maxwell\").", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.material_k", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "str", + "accepts_none": false, + "default": "\"andrade\"", + "choices": [ + "andrade", + "maxwell", + "elastic" + ], + "bounds": null, + "description": "Rheology model for complex bulk modulus (\"andrade\" or \"maxwell\").", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.alpha", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "float", + "accepts_none": false, + "default": "0.3", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Andrade power-law exponent.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.verbosity", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "int", + "accepts_none": false, + "default": "1", + "choices": [ + 0, + 1, + 2 + ], + "bounds": null, + "description": "Logging verbosity level (0=silent, 1=info, 2=debug).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.module_solid", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "str", + "accepts_none": false, + "default": "\"solid0d\"", + "choices": [ + "none", + "solid0d", + "solid1d", + "solid1d-relax", + "solid1d-mush", + "solid1d-mush-relax", + "solid1d-equil-relax" + ], + "bounds": null, + "description": "Solid-tide module to use (\"none\", \"solid0d\", \"solid1d\", \"solid1d-relax\", \"solid1d-mush\", \"solid1d-mush-relax\", \"solid1d-equil-relax\").", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1405,19 +2829,18 @@ "group_qualifier": null }, { - "path": "orbit.module", - "toml_section": "orbit", - "class": "Orbit", - "type": "str or none", - "accepts_none": true, - "default": "none", + "path": "orbit.obliqua.module_mushy", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "str", + "accepts_none": false, + "default": "\"none\"", "choices": [ - null, - "dummy", - "lovepy" + "none", + "interp" ], "bounds": null, - "description": "Select orbit module to use. Choices: 'none', 'dummy', 'lovepy'.", + "description": "Mushy-tide module to use (\"none\", \"interp\").", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1425,20 +2848,19 @@ "group_qualifier": null }, { - "path": "orbit.semimajoraxis", - "toml_section": "orbit", - "class": "Orbit", - "type": "float", + "path": "orbit.obliqua.module_fluid", + "toml_section": "orbit.obliqua", + "class": "Obliqua", + "type": "str", "accepts_none": false, - "default": "1.0", - "choices": null, - "bounds": [ - { - "op": ">", - "value": 0 - } + "default": "\"fluid0d\"", + "choices": [ + "none", + "fluid0d", + "fluid1d" ], - "description": "Initial semi-major axis of the planet's orbit [AU].", + "bounds": null, + "description": "Fluid-tide module to use (\"none\", \"fluid0d\", \"fluid1d\").", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1446,24 +2868,20 @@ "group_qualifier": null }, { - "path": "orbit.eccentricity", - "toml_section": "orbit", - "class": "Orbit", - "type": "float", + "path": "orbit.obliqua.solid.ncalc", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", + "type": "int", "accepts_none": false, - "default": "0.0", + "default": "1000", "choices": null, "bounds": [ { - "op": ">=", - "value": 0 - }, - { - "op": "<", - "value": 1 + "op": ">", + "value": 100 } ], - "description": "Initial Eccentricity of the planet's orbit.", + "description": "Number of interpolated interior levels to use for solving tidal heating rates (shooting method).", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1471,24 +2889,20 @@ "group_qualifier": null }, { - "path": "orbit.zenith_angle", - "toml_section": "orbit", - "class": "Orbit", - "type": "float", + "path": "orbit.obliqua.solid.dr_min", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", + "type": "int", "accepts_none": false, - "default": "48.19", + "default": "300", "choices": null, "bounds": [ { - "op": ">=", + "op": ">", "value": 0 - }, - { - "op": "<", - "value": 90 } ], - "description": "Characteristic angle of incoming stellar radiation, relative to the zenith [deg].", + "description": "Minimum radial grid spacing [m] (Henyey/relaxation method).", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1496,12 +2910,12 @@ "group_qualifier": null }, { - "path": "orbit.s0_factor", - "toml_section": "orbit", - "class": "Orbit", - "type": "float", + "path": "orbit.obliqua.solid.dr_max", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", + "type": "int", "accepts_none": false, - "default": "0.375", + "default": "3000", "choices": null, "bounds": [ { @@ -1509,7 +2923,7 @@ "value": 0 } ], - "description": "Scale factor applies to incoming stellar radiation to represent planetary rotation.", + "description": "Maximum radial grid spacing [m] (Henyey/relaxation method).", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1517,15 +2931,20 @@ "group_qualifier": null }, { - "path": "orbit.evolve", - "toml_section": "orbit", - "class": "Orbit", - "type": "bool", + "path": "orbit.obliqua.solid.core", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", + "type": "str", "accepts_none": false, - "default": "false", - "choices": null, + "default": "\"liquid\"", + "choices": [ + "liquid", + "solid", + "inertial-liquid", + "inertial" + ], "bounds": null, - "description": "Allow the planet's orbit to evolve based on eccentricity tides?", + "description": "Core solution vector (\"liquid\", \"solid\", \"inertial-liquid\", or \"inertial\").", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1533,15 +2952,18 @@ "group_qualifier": null }, { - "path": "orbit.axial_period", - "toml_section": "orbit", - "class": "Orbit", - "type": "float or none", - "accepts_none": true, - "default": "none", - "choices": null, + "path": "orbit.obliqua.solid.core_props", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", + "type": "str", + "accepts_none": false, + "default": "\"core\"", + "choices": [ + "core", + "mantle" + ], "bounds": null, - "description": "Planet initial day length [hours], will use orbital period if value is None.", + "description": "Core properties to use (\"core\" or \"mantle\").", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1549,15 +2971,15 @@ "group_qualifier": null }, { - "path": "orbit.satellite", - "toml_section": "orbit", - "class": "Orbit", + "path": "orbit.obliqua.solid.inertial_terms", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", "type": "bool", "accepts_none": false, - "default": "false", + "default": "true", "choices": null, "bounds": null, - "description": "Model a satellite (moon) orbiting the planet and solve for its orbit?", + "description": "Whether to include inertial terms in the solid-tide solution.", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1565,12 +2987,12 @@ "group_qualifier": null }, { - "path": "orbit.mass_sat", - "toml_section": "orbit", - "class": "Orbit", + "path": "orbit.obliqua.solid.bulk_l", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", "type": "float", "accepts_none": false, - "default": "7.347e+22", + "default": "1000000000.0", "choices": null, "bounds": [ { @@ -1578,7 +3000,7 @@ "value": 0 } ], - "description": "Satellite mass [kg]; the default is the lunar mass.", + "description": "Bulk modulus of the liquid phase [Pa].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1586,12 +3008,12 @@ "group_qualifier": null }, { - "path": "orbit.semimajoraxis_sat", - "toml_section": "orbit", - "class": "Orbit", + "path": "orbit.obliqua.solid.porosity_thresh", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", "type": "float", "accepts_none": false, - "default": "300000000.0", + "default": "0.03", "choices": null, "bounds": [ { @@ -1599,7 +3021,7 @@ "value": 0 } ], - "description": "Satellite initial semi-major axis [m]", + "description": "Porosity threshold, hard cutoff below which melt fraction is set to zero [dimensionless].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1607,20 +3029,20 @@ "group_qualifier": null }, { - "path": "orbit.dummy.H_tide", - "toml_section": "orbit.dummy", - "class": "OrbitDummy", + "path": "orbit.obliqua.solid.dbulk_power", + "toml_section": "orbit.obliqua.solid", + "class": "ObliquaSolid", "type": "float", "accepts_none": false, - "default": "0.0", + "default": "0.5", "choices": null, "bounds": [ { - "op": ">=", - "value": 0.0 + "op": ">", + "value": 0 } ], - "description": "Fixed global heating rate from tides [W kg-1].", + "description": "Drained bulk modulus powerlaw scaling exponent [dimensionless].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1628,15 +3050,20 @@ "group_qualifier": null }, { - "path": "orbit.dummy.Phi_tide", - "toml_section": "orbit.dummy", - "class": "OrbitDummy", - "type": "str", + "path": "orbit.obliqua.mushy.b_width", + "toml_section": "orbit.obliqua.mushy", + "class": "ObliquaMushy", + "type": "float", "accepts_none": false, - "default": "\"<0.3\"", + "default": "0.5", "choices": null, - "bounds": null, - "description": "Inequality which, if locally true, determines in which regions tides are applied.", + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Scale width of the bottom heating decay profile [dimensionless].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1644,20 +3071,20 @@ "group_qualifier": null }, { - "path": "orbit.dummy.Imk2", - "toml_section": "orbit.dummy", - "class": "OrbitDummy", + "path": "orbit.obliqua.mushy.t_width", + "toml_section": "orbit.obliqua.mushy", + "class": "ObliquaMushy", "type": "float", "accepts_none": false, - "default": "0.0", + "default": "0.03", "choices": null, "bounds": [ { - "op": "<=", - "value": 0.0 + "op": ">", + "value": 0 } ], - "description": "Imaginary part of k2 Love number, which is usually negative.", + "description": "Scale width of the top heating decay profile [dimensionless].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1665,12 +3092,12 @@ "group_qualifier": null }, { - "path": "orbit.lovepy.visc_thresh", - "toml_section": "orbit.lovepy", - "class": "Lovepy", + "path": "orbit.obliqua.fluid.sigma_R", + "toml_section": "orbit.obliqua.fluid", + "class": "ObliquaFluid", "type": "float", "accepts_none": false, - "default": "1000000000.0", + "default": "0.001", "choices": null, "bounds": [ { @@ -1678,7 +3105,7 @@ "value": 0 } ], - "description": "Minimum viscosity required for heating [Pa s].", + "description": "Rayleigh drag in the fluid-mush/solid boundary layers [1/s].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1686,20 +3113,20 @@ "group_qualifier": null }, { - "path": "orbit.lovepy.ncalc", - "toml_section": "orbit.lovepy", - "class": "Lovepy", - "type": "int", + "path": "orbit.obliqua.fluid.sigma_R_factor", + "toml_section": "orbit.obliqua.fluid", + "class": "ObliquaFluid", + "type": "float", "accepts_none": false, - "default": "1000", + "default": "0.5", "choices": null, "bounds": [ { "op": ">", - "value": 100 + "value": 0 } ], - "description": "Number of interpoltaed interior levels to use for solving tidal heating rates.", + "description": "Rayleigh drag in the pure fluid as a fraction of the interface [dimensionless].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1707,18 +3134,22 @@ "group_qualifier": null }, { - "path": "orbit.instellation_method", - "toml_section": "orbit", - "class": "Orbit", + "path": "orbit.obliqua.fluid.sigma_R_prf", + "toml_section": "orbit.obliqua.fluid", + "class": "ObliquaFluid", "type": "str", "accepts_none": false, - "default": "\"distance\"", + "default": "\"exp\"", "choices": [ - "distance", - "inst" + "uniform", + "exp", + "linear", + "quadratic", + "dynamic", + "dynamic_interp" ], "bounds": null, - "description": "Whether to use the semi-major axis ('distance') or instellation flux ('inst') to define the planet's initial orbit", + "description": "Radial heating distribution profile [dimensionless].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -1726,12 +3157,12 @@ "group_qualifier": null }, { - "path": "orbit.instellationflux", - "toml_section": "orbit", - "class": "Orbit", + "path": "orbit.obliqua.fluid.H_R", + "toml_section": "orbit.obliqua.fluid", + "class": "ObliquaFluid", "type": "float", "accepts_none": false, - "default": "1.0", + "default": "10000.0", "choices": null, "bounds": [ { @@ -1739,7 +3170,28 @@ "value": 0 } ], - "description": "Instellation flux initially received by the planet in Earth units.", + "description": "Scale height to be used by heating profile [m].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "orbit.obliqua.fluid.efficiency", + "toml_section": "orbit.obliqua.fluid", + "class": "ObliquaFluid", + "type": "float", + "accepts_none": false, + "default": "0.3", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Rayleigh drag efficiency at core interface [dimensionless].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -4287,6 +5739,48 @@ "group": null, "group_qualifier": null }, + { + "path": "interior_energetics.boundary.core_shear", + "toml_section": "interior_energetics.boundary", + "class": "InteriorBoundary", + "type": "float", + "accepts_none": false, + "default": "0.1", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Core shear modulus [Pa].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "interior_energetics.boundary.core_bulk", + "toml_section": "interior_energetics.boundary", + "class": "InteriorBoundary", + "type": "float", + "accepts_none": false, + "default": "500000000000.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Core bulk modulus [Pa].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, { "path": "interior_energetics.boundary.atm_heat_capacity_const", "toml_section": "interior_energetics.boundary", @@ -4823,6 +6317,27 @@ "group": "Rheology and convection", "group_qualifier": null }, + { + "path": "interior_energetics.tmagma_tides_step", + "toml_section": "interior_energetics", + "class": "Interior", + "type": "float", + "accepts_none": false, + "default": "10.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Maximum change in T_magma allowed when tides are active [K].", + "doc_source": "attributes", + "group_order": 4, + "group_position": 2, + "group": "Coupling limits", + "group_qualifier": null + }, { "path": "interior_energetics.kappah_floor", "toml_section": "interior_energetics", @@ -8655,8 +10170,8 @@ "orbit" ], "touches": [ - "orbit.evolve", - "orbit.instellation_method" + "orbit.instellation_method", + "orbit.star_planet_model" ], "doc": "Orbital evolution cannot be combined with instellation method 'inst'." }, @@ -8673,6 +10188,18 @@ ], "doc": "ZEPHYRUS escape with JANUS requires the escape stop criterion to be enabled." }, + { + "validator": "obliqua_requires_perturber", + "module": "_config.py", + "attached_to": [ + "orbit" + ], + "touches": [ + "orbit.module", + "orbit.perturber" + ], + "doc": "The Obliqua tidal-response module requires an explicit perturber." + }, { "validator": "observe_resolved_atmosphere", "module": "_config.py", @@ -8685,6 +10212,19 @@ ], "doc": "Synthetic observations require a spatially resolved atmosphere (not dummy)." }, + { + "validator": "orbit_requires_tides", + "module": "_config.py", + "attached_to": [ + "orbit" + ], + "touches": [ + "orbit.module", + "orbit.planet_satellite_model", + "orbit.star_planet_model" + ], + "doc": "sp1d, ps1d, and ps1d_evec require at least Lovepy, but ideally the Obliqua tidal-response module: all three read the full per-mode spectrum in ``tides_o``, which ``dummy`` never populates. ``sp0d``/``ps0d`` read the scalar ``Imk2`` instead (which ``dummy`` does provide), so they are unrestricted here; see \"Compatibility between orbit models and tidal modules\" in docs/Explanations/orbit.md." + }, { "validator": "planet_fO2_source_compat", "module": "_config.py", @@ -8717,10 +10257,23 @@ "orbit" ], "touches": [ - "orbit.evolve", - "orbit.satellite" + "orbit.planet_satellite_model", + "orbit.star_planet_model" + ], + "doc": "Star-planet orbital evolution and the planet-satellite model are mutually exclusive." + }, + { + "validator": "sp0d_obliqua_degree_mismatch", + "module": "_config.py", + "attached_to": [ + "orbit" + ], + "touches": [ + "orbit.module", + "orbit.obliqua.n", + "orbit.star_planet_model" ], - "doc": "Planetary orbital evolution and the satellite model are mutually exclusive." + "doc": "sp0d's closed-form is by definition the n=2 Love number. Obliqua can compute arbitrary tidal degree(s), block the mismatch." }, { "validator": "spada_zephyrus", @@ -8745,7 +10298,7 @@ "interior_energetics.heat_tidal", "orbit.module" ], - "doc": "Interior tidal heating requires an orbit module to be enabled." + "doc": "Interior tidal heating requires an tides module to be enabled." }, { "validator": "valid_escapedummy", diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index cf364f4a0..508892ddd 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -212,6 +212,7 @@ controlled parity tests. |---|---|---|---| | `tmagma_atol` | float | `20.0` | Maximum absolute change in T_magma per PROTEUS step \[K\]. Must be >= 0. | | `tmagma_rtol` | float | `0.02` | Maximum relative change in T_magma per PROTEUS step. Must be >= 0. | +| `tmagma_tides_step` | float | `10.0` | Maximum change in T_magma allowed when tides are active \[K\]. Must be >= 0. | **Ultra-thin boundary layer** @@ -338,6 +339,8 @@ with prescribed solidus and liquidus and parameterised convective heat transport | `nusselt_exponent` | float | `0.33` | Nusselt-Rayleigh scaling exponent \[-\]. Must be > 0. | | `silicate_heat_capacity` | float | `1200.0` | Silicate heat capacity \[J/kg/K\]. Must be > 0. | | `core_density` | float | `10738.0` | Core density \[kg/m^3\]. Must be > 0. | +| `core_shear` | float | `0.1` | Core shear modulus \[Pa\]. Must be > 0. | +| `core_bulk` | float | `500000000000.0` | Core bulk modulus \[Pa\]. Must be > 0. | | `atm_heat_capacity_const` | bool | `true` | Always use fallback atmosphere heat capacity?. | | `atm_heat_capacity` | float | `17000.0` | Used as fallback for atmosphere heat capacity when layer-specific value is not available \[J/kg/K\]. Must be > 0. | | `silicate_density` | float | `4103.0` | Silicate density \[kg/m^3\]. Default taken from Fei et. al. 2021 (https://ui.adsabs.harvard.edu/abs/2021NatCo..12..876F). Must be > 0. | @@ -360,7 +363,7 @@ with prescribed solidus and liquidus and parameterised convective heat transport Cross-field constraints enforced when the config file loads: - Boundary backend assumes a fixed surface state coupling. -- Interior tidal heating requires an orbit module to be enabled. +- Interior tidal heating requires an tides module to be enabled. - Aragog requires at least one energy transport term to be enabled. - Validate Boundary backend's solidus/liquidus ordering. - Dummy interior requires the liquidus to sit above the solidus. diff --git a/docs/Reference/config/params.md b/docs/Reference/config/params.md index 7fae49a45..008176f17 100644 --- a/docs/Reference/config/params.md +++ b/docs/Reference/config/params.md @@ -74,6 +74,12 @@ solidification transition (melt fraction between `phi_crit` and | `initial` | float | `30.0` | Initial time-step size \[yr\]. Must be > 0. | | `mushy_maximum` | float | `0.0` | Maximum time-step size \[yr\] during the mushy-zone transition (``phi_crit < Phi_global < mushy_upper``). Tighter than ``maximum`` because the interior solver hits stiffness cliffs in this regime (phase-boundary Jgrav + rheology contrast). Set to 0 (default) to disable the mushy-regime cap, in which case ``maximum`` applies throughout. A typical value for Aragog at 1 M_E is ~4e3 yr; see ``input/tutorials/tutorial_earth.toml``. Must be >= 0. | | `mushy_upper` | float | `0.99` | Upper bound of the mushy regime \[dimensionless melt fraction\]. When ``Phi_global < mushy_upper`` AND ``Phi_global > stop.solid.phi_crit``, ``mushy_maximum`` takes over from ``maximum``. Default 0.99 so the cap kicks in as soon as the first cell crystallises. Must be > 0 and < 1. | +| `evection_maximum` | float or none | `none` | Ceiling on the time-step size \[yr\] while the planet-satellite system is inside, or approaching, the evection resonance band. Must be > 0 when set. Default ``'none'`` (disables the whole mechanism). Must be > 0. | +| `evection_target_rel_de` | float | `0.05` | Target maximum fractional change in ``eccentricity_sat`` per macro-step while inside/approaching the evection band. Obliqua's own adaptive mode-window selection is keyed on the eccentricity it is given at call time, and that window is then held fixed for the whole of the following macro-step, so a large fractional swing in ``e`` within one step risks needing modes outside that window. Default 0.05 (5%). Must be > 0. | +| `evection_de_floor` | float | `0.02` | Floor on the eccentricity value used in the ``evection_target_rel_de`` ratio's denominator, so a tiny eccentricity right at capture onset does not make the allowed step blow up. Default 0.02. Must be > 0. | +| `evection_rate_window` | int | `2` | Number of trailing accepted macro-steps used to estimate the SECULAR ``|de/dt|`` this cap bounds against (a least-squares linear fit for windows > 2, the plain two-point difference at the default of 2). Default 2, preserves the two-point behaviour. Must be >= 2. | +| `evection_growth_factor` | float or none | `none` | Cap on the dt growth ratio between consecutive steps while the system is inside/approaching the evection band, or within ``evection_cooldown_iters`` steps of having left it. Separate from the global ``max_growth_factor`` (which most evection runs leave disabled, since it would also throttle ordinary bulk evolution for the rest of the run). Must be > 0 when set. Default ``'none'`` (disabled). Must be > 0. | +| `evection_cooldown_iters` | int or none | `none` | Number of PROTEUS iterations, after the system is no longer judged in/near the evection band, during which ``evection_growth_factor`` remains active. Refreshed to this value on every iteration the zone is active, so a long stay in the band does not exhaust it before exit. Must be > 0 when set. Default ``'none'`` (no cooldown tail; growth limiting turns off the instant the zone is left). Must be > 0. | | `hysteresis_iters` | int | `0` | Number of PROTEUS iterations after an adaptive "slow down" decision during which the speed-up factor is suppressed. Prevents the controller from ramping dt straight back into the same stiffness cliff it just escaped from. Default 3; set to 0 to disable. Must be >= 0. | | `hysteresis_sfinc` | float | `1.1` | Replacement speed-up factor applied while the hysteresis counter is active. Must be ``>= 1.0`` and ``<= SFINC`` (1.6). Default 1.1 (gentle ramp-up). Must be >= 1.0. | | `max_growth_factor` | float | `0.0` | Cap on the dt growth ratio between consecutive steps \[dimensionless\]. Bounds dtswitch / dtprev, preventing large jumps that can wedge the interior solver; 0 (default) disables the cap. Must be >= 0. | @@ -159,6 +165,29 @@ to be satisfied for two consecutive iterations before terminating. | `offset_spin` | float | `0` | Absolute correction (+/-) to (increase/decrease) calculated Breakup period \[s\]. | +### Satellite disintegration `[params.stop.disint_sat]` + + + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `enabled` | bool | `false` | Enable all planet disintegration criteria if True. | +| `roche_enabled` | bool | `true` | Disable Roche limit criterion. | +| `offset_roche` | float | `0` | Absolute correction (+/-) to (increase/decrease) calculated Roche limit \[m\]. | +| `spin_enabled` | bool | `true` | Disable Breakup period criterion. | +| `offset_spin` | float | `0` | Absolute correction (+/-) to (increase/decrease) calculated Breakup period \[s\]. | + + +### Satellite escape `[params.stop.satellite]` + + + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `enabled` | bool | `false` | Enable criteria if True. | +| `sma_max` | float | `60` | Maximum semi-major axis for the satellite \[R_Earth\]. | + + ### Wall-clock limit `[params.stop.clock]` diff --git a/docs/Reference/config/star_orbit.md b/docs/Reference/config/star_orbit.md index 88ed8bbaa..84b01f1a4 100644 --- a/docs/Reference/config/star_orbit.md +++ b/docs/Reference/config/star_orbit.md @@ -73,20 +73,64 @@ parameter studies where stellar evolution is not relevant. | Parameter | Type | Default | Description | |---|---|---|---| -| `module` | str or none | `none` | Select orbit module to use. Choices: `none`, `"dummy"`, `"lovepy"`. | | `semimajoraxis` | float | `1.0` | Initial semi-major axis of the planet's orbit \[AU\]. Must be > 0. | | `eccentricity` | float | `0.0` | Initial Eccentricity of the planet's orbit. Must be >= 0 and < 1. | +| `instellation_method` | str | `"distance"` | Whether to use the semi-major axis ('distance') or instellation flux ('inst') to define the planet's initial orbit. Choices: `"distance"`, `"inst"`. | +| `instellationflux` | float | `1.0` | Instellation flux initially received by the planet in Earth units. Must be > 0. | | `zenith_angle` | float | `48.19` | Characteristic angle of incoming stellar radiation, relative to the zenith \[deg\]. Must be >= 0 and < 90. | | `s0_factor` | float | `0.375` | Scale factor applies to incoming stellar radiation to represent planetary rotation. Must be > 0. | -| `evolve` | bool | `false` | Allow the planet's orbit to evolve based on eccentricity tides?. | +| `star_planet_model` | str or none | `none` | Select star-planet orbit module to use. Choices: `none`, `"none"`, `"sp0d"`, `"sp1d"`. | | `axial_period` | float or none | `none` | Planet initial day length \[hours\], will use orbital period if value is None. | -| `satellite` | bool | `false` | Model a satellite (moon) orbiting the planet and solve for its orbit?. | -| `mass_sat` | float | `7.347e+22` | Satellite mass \[kg\]; the default is the lunar mass. Must be > 0. | -| `semimajoraxis_sat` | float | `300000000.0` | Satellite initial semi-major axis \[m\]. Must be > 0. | -| `instellation_method` | str | `"distance"` | Whether to use the semi-major axis ('distance') or instellation flux ('inst') to define the planet's initial orbit. Choices: `"distance"`, `"inst"`. | -| `instellationflux` | float | `1.0` | Instellation flux initially received by the planet in Earth units. Must be > 0. | +| `planet_satellite_model` | str or none | `none` | Select planet-satellite orbit module to use. Choices: `none`, `"none"`, `"ps0d"`, `"ps1d"`, `"ps1d_evec"`. | +| `perturber` | str or none | `none` | Select perturber to induce tides on the planet. Options: 'none', 'star', 'satellite'. Choices: `none`, `"none"`, `"star"`, `"satellite"`. | +| `module` | str or none | `none` | Select tides module to use. Choices: `none`, `"dummy"`, `"lovepy"`, `"obliqua"`. | +### Satellite `[orbit.satellite]` + + + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `include_satellite` | bool | `false` | Whether to model a satellite orbiting the planet. | +| `mass_sat` | float | `0.012` | Satellite mass \[M_earth\]. Must be > 0. | +| `radius_sat` | float | `0.273` | Satellite radius \[R_earth\]. Must be > 0. | +| `axial_period_sat` | float or none | `none` | Satellite initial day length \[hours\], will use orbital period if value is None. | +| `semimajoraxis_sat` | float | `3.5` | Satellite initial semi-major axis \[R_earth\]. Must be > 0. | +| `eccentricity_sat` | float | `0.0` | Satellite initial orbital eccentricity \[dimensionless\]. Must be >= 0. | +| `evection_angle` | float | `0.0` | Satellite evection angle \[deg\]. Must be >= 0. | +| `c_factor_sat` | float | `0.4` | Satellite tidal dissipation factor (<= 0.4) \[dimensionless\]. Must be > 0 and <= 0.4. | +| `love_number_sat` | str or none | `none` | Satellite love number spectrum, provide absolute path to netCDF file containing forcing frequencies and complex Lovenumbers, and corresponding tidal degree in nmk format. | + + +### Solver `[orbit.solver]` + +Shared numerical-solver settings for the orbital-evolution ODE models, +used by both the star-planet models (sp0d, sp1d) and the planet- +satellite models (ps0d, ps1d, ps1d_evec). + + + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `method` | str | `"Radau"` | scipy.integrate.solve_ivp integration method. Choices: `"RK45"`, `"RK23"`, `"DOP853"`, `"Radau"`, `"BDF"`, `"LSODA"`. | +| `rtol` | float | `1e-06` | Relative tolerance passed to solve_ivp. Must be > 0. | +| `atol` | float | `1e-09` | Absolute tolerance passed to solve_ivp. Must be > 0. | +| `dt0_yr` | float | `0.0001` | Initial adaptive-substep size \[yr\]. Must be > 0. | +| `dt_max_yr` | float | `2000.0` | Maximum adaptive-substep size \[yr\]. Must be > 0. | +| `growth` | float | `1.15` | Substep growth factor applied after an accepted step. Must be > 1.0. | +| `shrink` | float | `0.35` | Substep shrink factor applied after a rejected step. Must be > 0 and < 1. | +| `max_rel_da` | float | `0.01` | Maximum tolerated relative change in semi-major axis per substep. Must be > 0. | +| `max_rel_de` | float | `0.01` | Maximum tolerated relative change in eccentricity per substep. Must be > 0. | +| `max_rel_dOmega` | float | `0.02` | Maximum tolerated relative change in a spin rate per substep. Must be > 0. | +| `de_floor` | float | `0.05` | Floor on the eccentricity-change-ratio denominator, so a small starting eccentricity does not make the ratio spuriously huge. Must be > 0. | +| `max_substeps` | int | `10000000` | Maximum number of substeps attempted per call. Must be > 0. | +| `resonance_margin_enter` | float | `0.1` | Evection-band entry margin (ps1d_evec only). Must be > 0. | +| `resonance_margin_exit` | float | `0.5` | Evection-band exit margin (ps1d_evec only). Must be > 0. | +| `resonance_margin_approach` | float | `0.3` | Wider, purely-diagnostic margin used to set a pre-emptive signal that the system is closing in on the band before the tighter ``resonance_margin_enter`` would declare capture. Must be > 0. | +| `fine_csv_target_rel_dt` | float | `0.01` | Target storage-clock spacing for out-of-band fine samples, as a fraction of the requested call duration (ps1d_evec only). Must be > 0. | + + ### Dummy tides `[orbit.dummy]` Fixed tidal heating rates, useful for parameter studies. @@ -113,6 +157,78 @@ the interior rheological profile. | `ncalc` | int | `1000` | Number of interpoltaed interior levels to use for solving tidal heating rates. Must be > 100. | +### Obliqua tides `[orbit.obliqua]` + +Self-consistent, multi-phase tidal response computed from the interior's +solid, mushy, and fluid layers, each handled by a dedicated sub-model. Valid +for arbitrary eccentricity and tidal mode. + + + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `store_3D` | bool | `false` | Whether to store 3D information for solid tides. | +| `enforce_ec` | bool | `true` | Whether to enforce energy conservation between Lovenumbers and heating profile. | +| `optimize_scales` | bool | `false` | Whether to optimize the non-dimensional scaling parameters for solid tides. | +| `solid_shell` | bool | `true` | Whether to insert an infinitesimal solid shell around the core. | +| `cap_LN` | bool | `false` | Whether to clamp each mode's Love number to a fixed multiple of the classical fluid limit for its degree. | +| `min_frac` | float | `0.02` | Minimal segment radius fraction before smoothing. Must be > 0. | +| `visc_lus` | float | `500000.0` | Liquidus viscosity \[Pa s\]. Must be > 0. | +| `visc_sus` | float | `500000.0` | Solidus viscosity \[Pa s\]. Must be > 0. | +| `n` | list | `[2]` | Power of the radial factor (r/a)^n. | +| `m` | list | `[0, 2]` | Tidal harmonic (m=2 semidiurnal, m=1 diurnal). | +| `k_min` | int | Literal | `"none"` | Minimum Fourier index in mean anomaly (adaptive spectrum). | +| `k_max` | int | Literal | `"none"` | Maximum Fourier index in mean anomaly (adaptive spectrum). | +| `evection_padding_factor` | float | `2.0` | Safety multiplier on the linear look-ahead eccentricity padding applied to Obliqua's own adaptive k-range selection. Must be >= 0. | +| `material_mu` | str | `"andrade"` | Rheology model for complex shear modulus ("andrade" or "maxwell"). Choices: `"andrade"`, `"maxwell"`, `"elastic"`. | +| `material_k` | str | `"andrade"` | Rheology model for complex bulk modulus ("andrade" or "maxwell"). Choices: `"andrade"`, `"maxwell"`, `"elastic"`. | +| `alpha` | float | `0.3` | Andrade power-law exponent. Must be > 0. | +| `verbosity` | int | `1` | Logging verbosity level (0=silent, 1=info, 2=debug). Choices: `0`, `1`, `2`. | +| `module_solid` | str | `"solid0d"` | Solid-tide module to use ("none", "solid0d", "solid1d", "solid1d-relax", "solid1d-mush", "solid1d-mush-relax", "solid1d-equil-relax"). Choices: `"none"`, `"solid0d"`, `"solid1d"`, `"solid1d-relax"`, `"solid1d-mush"`, `"solid1d-mush-relax"`, `"solid1d-equil-relax"`. | +| `module_mushy` | str | `"none"` | Mushy-tide module to use ("none", "interp"). Choices: `"none"`, `"interp"`. | +| `module_fluid` | str | `"fluid0d"` | Fluid-tide module to use ("none", "fluid0d", "fluid1d"). Choices: `"none"`, `"fluid0d"`, `"fluid1d"`. | + + +#### Solid tides `[orbit.obliqua.solid]` + + + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `ncalc` | int | `1000` | Number of interpolated interior levels to use for solving tidal heating rates (shooting method). Must be > 100. | +| `dr_min` | int | `300` | Minimum radial grid spacing \[m\] (Henyey/relaxation method). Must be > 0. | +| `dr_max` | int | `3000` | Maximum radial grid spacing \[m\] (Henyey/relaxation method). Must be > 0. | +| `core` | str | `"liquid"` | Core solution vector ("liquid", "solid", "inertial-liquid", or "inertial"). Choices: `"liquid"`, `"solid"`, `"inertial-liquid"`, `"inertial"`. | +| `core_props` | str | `"core"` | Core properties to use ("core" or "mantle"). Choices: `"core"`, `"mantle"`. | +| `inertial_terms` | bool | `true` | Whether to include inertial terms in the solid-tide solution. | +| `bulk_l` | float | `1000000000.0` | Bulk modulus of the liquid phase \[Pa\]. Must be > 0. | +| `porosity_thresh` | float | `0.03` | Porosity threshold, hard cutoff below which melt fraction is set to zero \[dimensionless\]. Must be > 0. | +| `dbulk_power` | float | `0.5` | Drained bulk modulus powerlaw scaling exponent \[dimensionless\]. Must be > 0. | + + +#### Mushy tides `[orbit.obliqua.mushy]` + + + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `b_width` | float | `0.5` | Scale width of the bottom heating decay profile \[dimensionless\]. Must be > 0. | +| `t_width` | float | `0.03` | Scale width of the top heating decay profile \[dimensionless\]. Must be > 0. | + + +#### Fluid tides `[orbit.obliqua.fluid]` + + + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `sigma_R` | float | `0.001` | Rayleigh drag in the fluid-mush/solid boundary layers \[1/s\]. Must be > 0. | +| `sigma_R_factor` | float | `0.5` | Rayleigh drag in the pure fluid as a fraction of the interface \[dimensionless\]. Must be > 0. | +| `sigma_R_prf` | str | `"exp"` | Radial heating distribution profile \[dimensionless\]. Choices: `"uniform"`, `"exp"`, `"linear"`, `"quadratic"`, `"dynamic"`, `"dynamic_interp"`. | +| `H_R` | float | `10000.0` | Scale height to be used by heating profile \[m\]. Must be > 0. | +| `efficiency` | float | `0.3` | Rayleigh drag efficiency at core interface \[dimensionless\]. Must be > 0. | + + ## Constraints @@ -121,7 +237,10 @@ Cross-field constraints enforced when the config file loads: - Instellation method 'inst' is only available with the dummy star module. - Orbital evolution cannot be combined with instellation method 'inst'. -- Planetary orbital evolution and the satellite model are mutually exclusive. +- The Obliqua tidal-response module requires an explicit perturber. +- sp1d, ps1d, and ps1d_evec require at least Lovepy, but ideally the Obliqua tidal-response module: all three read the full per-mode spectrum in ``tides_o``, which ``dummy`` never populates. ``sp0d``/``ps0d`` read the scalar ``Imk2`` instead (which ``dummy`` does provide), so they are unrestricted here; see "Compatibility between orbit models and tidal modules" in docs/Explanations/orbit.md. +- Star-planet orbital evolution and the planet-satellite model are mutually exclusive. +- sp0d's closed-form is by definition the n=2 Love number. Obliqua can compute arbitrary tidal degree(s), block the mismatch. - A bolometric scaling other than 1 requires bol_scale_start and a positive duration. - Validate MORS settings: positive age, spectrum-source requirements, and rotation set by exactly one of percentile or period. - Dummy star requires a consistent radius specification and a valid Teff. @@ -129,7 +248,7 @@ Cross-field constraints enforced when the config file loads: --- -**See also:** [Stellar module](../../Explanations/model.md#stellar-evolution-mors) | [Orbit module](../../Explanations/model.md#orbital-evolution-obliqua) +**See also:** [Stellar module](../../Explanations/model.md#stellar-evolution-mors) | [Tidal evolution](../../Explanations/model.md#tidal-evolution-obliqua-lovepy) | [Orbital evolution](../../Explanations/model.md#orbital-evolution-proteus-internal) | [Orbital dynamics and tides (full model overview)](../../Explanations/orbit.md) [^cite-spada2013]: Spada, F., Demarque, P., Kim, Y.C. & Sills, A., *[The radius discrepancy in low-mass stars: single versus binaries](https://doi.org/10.1088/0004-637X/776/2/87)*, The Astrophysical Journal, 776, 87, 2013. [SciX](https://scixplorer.org/abs/2013ApJ...776...87S/abstract). diff --git a/docs/Reference/module_map.json b/docs/Reference/module_map.json index 0ccc9b51f..92553dfd0 100644 --- a/docs/Reference/module_map.json +++ b/docs/Reference/module_map.json @@ -143,13 +143,18 @@ "options": [ { "option": "dummy", - "entry": "src/proteus/orbit/dummy.py:run_dummy_orbit", + "entry": "src/proteus/orbit/dummy.py:run_dummy_tides", "role": "Fixed Im(k2) tides" }, { "option": "lovepy", "entry": "src/proteus/orbit/lovepy.py:run_lovepy", - "role": "Multi-phase tidal heating (Julia)" + "role": "Solid-phase tidal heating (Julia)" + }, + { + "option": "obliqua", + "entry": "src/proteus/orbit/obliqua.py:run_obliqua", + "role": "Multi-phase tidal response (Julia)" }, { "option": null, @@ -159,14 +164,14 @@ ], "sub_dispatches": [ { - "switch": "`orbit.evolve = true`", - "entry": "src/proteus/orbit/orbit.py:evolve_orbital", - "doc": "evolves semi-major axis and eccentricity each iteration" + "switch": "`orbit.star_planet_model != none`", + "entry": "src/proteus/orbit/orbit.py:evolve_orbit_star", + "doc": "evolves the star-planet orbit (sp0d/sp1d) each iteration" }, { - "switch": "`orbit.satellite = true`", - "entry": "src/proteus/orbit/satellite.py:update_satellite", - "doc": "evolves the satellite orbit instead of the planetary one" + "switch": "`orbit.planet_satellite_model != none`", + "entry": "src/proteus/orbit/satellite.py:evolve_orbit_satellite", + "doc": "evolves the planet-satellite orbit (ps0d/ps1d/ps1d_evec) each iteration" } ] }, diff --git a/docs/Reference/module_map.md b/docs/Reference/module_map.md index 031927f29..309816417 100644 --- a/docs/Reference/module_map.md +++ b/docs/Reference/module_map.md @@ -60,12 +60,13 @@ listed below its table. | Option | Entry point | Role | |---|---|---| -| `"dummy"` | `orbit/dummy.py:run_dummy_orbit` | Fixed Im(k2) tides | -| `"lovepy"` | `orbit/lovepy.py:run_lovepy` | Multi-phase tidal heating (Julia) | +| `"dummy"` | `orbit/dummy.py:run_dummy_tides` | Fixed Im(k2) tides | +| `"lovepy"` | `orbit/lovepy.py:run_lovepy` | Solid-phase tidal heating (Julia) | +| `"obliqua"` | `orbit/obliqua.py:run_obliqua` | Multi-phase tidal response (Julia) | | `none` | not applicable | Tides disabled; Im(k2) set to zero | -- `orbit.evolve = true`: evolves semi-major axis and eccentricity each iteration (`orbit/orbit.py:evolve_orbital`). -- `orbit.satellite = true`: evolves the satellite orbit instead of the planetary one (`orbit/satellite.py:update_satellite`). +- `orbit.star_planet_model != none`: evolves the star-planet orbit (sp0d/sp1d) each iteration (`orbit/orbit.py:evolve_orbit_star`). +- `orbit.planet_satellite_model != none`: evolves the planet-satellite orbit (ps0d/ps1d/ps1d_evec) each iteration (`orbit/satellite.py:evolve_orbit_satellite`). ## Outgassing (`outgas.module`) diff --git a/docs/Reference/module_versions.md b/docs/Reference/module_versions.md index a9a608218..9ca505113 100644 --- a/docs/Reference/module_versions.md +++ b/docs/Reference/module_versions.md @@ -35,9 +35,10 @@ pinned commit. | Module | Role | Pin | Docs | |--------|------|-----|------| -| AGNI | Radiative-convective atmosphere (Julia) | [![AGNI](https://img.shields.io/badge/AGNI-8a494d7c-green)](https://github.com/nichollsh/AGNI/commit/8a494d7c74db78163eba7354d78d1389e6f63072){target="_blank" rel="noopener"} | [Docs](https://www.h-nicholls.space/AGNI/) | +| AGNI | Radiative-convective atmosphere (Julia) | [![AGNI](https://img.shields.io/badge/AGNI-1c4baad0-green)](https://github.com/nichollsh/AGNI/commit/1c4baad046c52ec2205508062fbcb962bce15375){target="_blank" rel="noopener"} | [Docs](https://www.h-nicholls.space/AGNI/) | | SOCRATES | Spectral radiative transfer (Fortran) | [![SOCRATES](https://img.shields.io/badge/SOCRATES-c3296586-green)](https://github.com/FormingWorlds/SOCRATES/commit/c32965865c2c6bb1ba27b94e958a36982d74b0bf){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/SOCRATES/) | | SPIDER | Interior evolution (C, requires PETSc) | [![SPIDER](https://img.shields.io/badge/SPIDER-c9a3fd43-green)](https://github.com/FormingWorlds/SPIDER/commit/c9a3fd4301c7008291d4f4921506d36b6288f8ca){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/SPIDER/) | +| Obliqua | Multi-phase tidal response (Julia) | [![Obliqua](https://img.shields.io/badge/Obliqua-ee1ef40d-green)](https://github.com/FormingWorlds/Obliqua/commit/ee1ef40deaac47e1ba9e6d344a8541f500bb9e94){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/Obliqua/) | ### Optional modules @@ -45,10 +46,9 @@ pinned commit. | Module | Role | Pin | Docs | |--------|------|-----|------| -| LovePy | Multi-phase tidal heating (Julia) | [![LovePy](https://img.shields.io/badge/LovePy-main-lightgrey)](https://github.com/nichollsh/LovePy){target="_blank" rel="noopener"} | [GitHub](https://github.com/nichollsh/LovePy) | +| LovePy | Solid-phase tidal heating (Julia) | [![LovePy](https://img.shields.io/badge/LovePy-main-lightgrey)](https://github.com/nichollsh/LovePy){target="_blank" rel="noopener"} | [GitHub](https://github.com/nichollsh/LovePy) | | atmodeller | Alternative outgassing backend (GPL-3.0) | [![atmodeller](https://img.shields.io/badge/atmodeller-%3E%3D1.0.2-blue)](https://pypi.org/project/atmodeller/1.0.2/){target="_blank" rel="noopener"} | [GitHub](https://github.com/djbower/atmodeller) | | VULCAN | Atmospheric chemistry (GPL-3.0) | [![VULCAN](https://img.shields.io/badge/VULCAN-%3E%3D26.04.22-blue)](https://pypi.org/project/fwl-vulcan/26.04.22/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/VULCAN) | -| Obliqua | Orbital evolution and tides (Julia) | n/a | [GitHub](https://github.com/FormingWorlds/Obliqua) | --- diff --git a/docs/Reference/output.md b/docs/Reference/output.md index 5fddb6fe1..1e5b6001b 100644 --- a/docs/Reference/output.md +++ b/docs/Reference/output.md @@ -81,12 +81,15 @@ Each iteration carries the previous row forward and overwrites only the columns | Column | Unit | Description | Producer | Written when | Read by | |---|---|---|---|---|---| | `semimajorax` | `m` | semi-major axis | `orbit/orbit.py`
`orbit/wrapper.py` | always; orbit.evolve = true | escape, orbit, plot | +| `sma_dot_planet` | `m s-1` | semi-major axis derivative | `orbit/orbit.py`
`orbit/satellite.py` | orbit.evolve = true; orbit.satellite = true | | | `separation` | `m` | time-averaged separation | `orbit/wrapper.py` | always | atmos_chem, atmos_clim, main loop, observe, orbit, plot, star, utils | | `perihelion` | `m` | lowest point in orbit | `orbit/wrapper.py` | always | orbit | -| `orbital_period` | `s` | orbital duration | `orbit/wrapper.py` | always | orbit | +| `orbital_period` | `s` | orbital duration | `orbit/wrapper.py` | always | orbit, plot | | `eccentricity` | `1` | orbital eccentricity | `orbit/orbit.py`
`orbit/wrapper.py` | always; orbit.evolve = true | escape, orbit, plot | +| `ecc_dot_planet` | `1 s-1` | eccentricity derivative | `orbit/orbit.py`
`orbit/satellite.py` | orbit.evolve = true; orbit.satellite = true | | +| `plan_star_am` | `kg m2 s-1` | angular momentum of star+planet | `orbit/orbit.py` | orbit.evolve = true | | +| `axial_period` | `s` | day length of planet around its axis | `orbit/orbit.py`
`orbit/satellite.py`
`orbit/wrapper.py` | always; orbit.evolve = true; orbit.satellite = true | atmos_clim, orbit, plot, utils | | `Imk2` | `1` | Imaginary part of k2 Love Number | `orbit/wrapper.py` | always | orbit | -| `axial_period` | `s` | day length of planet around its axis | `orbit/satellite.py`
`orbit/wrapper.py` | always; orbit.satellite = true | atmos_clim, orbit, plot, utils | | `longitude` | `deg` | column longitude relative to substellar point | `atmos_clim/agni.py`
`orbit/wrapper.py` | always; atmos_clim.module = "agni" | atmos_clim | | `latitude` | `deg` | column latitude relative to substellar point | `atmos_clim/agni.py`
`orbit/wrapper.py` | always; atmos_clim.module = "agni" | atmos_clim | @@ -94,10 +97,20 @@ Each iteration carries the previous row forward and overwrites only the columns | Column | Unit | Description | Producer | Written when | Read by | |---|---|---|---|---|---| -| `perigee` | `m` | lowest point in orbit | `orbit/wrapper.py` | always | | -| `semimajorax_sat` | `m` | semi-major axis | `orbit/satellite.py`
`orbit/wrapper.py` | always; orbit.satellite = true | orbit, plot | -| `M_sat` | `kg` | mass of satellite | `orbit/satellite.py` | orbit.satellite = true | orbit | -| `plan_sat_am` | `kg m2 s-1` | angular momentum of sat+pla | `orbit/satellite.py` | orbit.satellite = true | orbit | +| `semimajorax_sat` | `m` | semi-major axis | `orbit/satellite.py`
`orbit/wrapper.py` | always; orbit.satellite = true | orbit, plot, utils | +| `sma_dot_sat` | `m s-1` | semi-major axis derivative | `orbit/satellite.py` | orbit.satellite = true | | +| `separation_sat` | `m` | time-averaged separation | `orbit/wrapper.py` | always | utils | +| `perigee` | `m` | lowest point in orbit | `orbit/wrapper.py` | always | orbit | +| `orbital_period_sat` | `s` | orbital duration | `orbit/wrapper.py` | always | orbit, plot | +| `eccentricity_sat` | `1` | orbital eccentricity of satellite | `orbit/satellite.py`
`orbit/wrapper.py` | always; orbit.satellite = true | orbit, plot | +| `ecc_dot_sat` | `1 s-1` | eccentricity derivative | `orbit/satellite.py` | orbit.satellite = true | | +| `plan_sat_am` | `kg m2 s-1` | angular momentum of satellite+planet | `orbit/satellite.py` | orbit.satellite = true | orbit, plot | +| `axial_period_sat` | `s` | day length of satellite around its axis | `orbit/satellite.py`
`orbit/wrapper.py` | always; orbit.satellite = true | orbit, plot, utils | +| `R_sat` | `m` | radius of satellite | `orbit/wrapper.py` | always | orbit | +| `M_sat` | `kg` | mass of satellite | `orbit/wrapper.py` | always | orbit | +| `C_sat` | `kg m2` | principal moment of inertia of satellite | `orbit/wrapper.py` | always | orbit | +| `evection_angle` | `rad` | evection angle | `orbit/satellite.py`
`orbit/wrapper.py` | always; orbit.satellite = true | orbit, plot | +| `evection_dt_cap_yr` | `yr` | next macro-step dt cap, rate + growth limiter folded in | `orbit/satellite.py` | orbit.satellite = true | interior_energetics | ### Planet structure @@ -108,12 +121,13 @@ Each iteration carries the previous row forward and overwrites only the columns | `M_planet` | `kg` | total planet wet+dry mass | `interior_energetics/wrapper.py` | always | atmos_clim, escape, interior_energetics, orbit, outgas, plot, utils | | `M_vaps` | `kg` | vapourised rock mass, including the vapourised oxygen | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, utils | | `R_core` | `m` | core radius | `interior_energetics/wrapper.py`
`interior_struct/dummy.py`
`interior_struct/zalmoxis.py` | always; interior_struct.module = "dummy"; interior_struct.module = "zalmoxis" | interior_energetics | +| `C_int` | `kg m2` | principal moment of inertia of planet | `interior_energetics/common.py`
`orbit/common.py` | always | orbit | | `R_solvus` | `m` | solvus radius for global_miscibility mode | `interior_struct/zalmoxis.py` | interior_struct.module = "zalmoxis" | interior_energetics, interior_struct, main loop | | `P_solvus` | `Pa` | solvus pressure for global_miscibility mode | `interior_struct/zalmoxis.py` | interior_struct.module = "zalmoxis" | interior_struct, main loop | | `T_solvus` | `K` | solvus temperature for global_miscibility mode | `interior_struct/zalmoxis.py` | interior_struct.module = "zalmoxis" | interior_struct, main loop | | `P_center` | `Pa` | central pressure from Zalmoxis structure ; 0 for SPIDER, which models the mantle only | `interior_struct/zalmoxis.py` | interior_struct.module = "zalmoxis" | interior_struct | | `P_cmb` | `Pa` | core-mantle boundary pressure, from Zalmoxis structure or SPIDER's basic-node pressure profile | `interior_energetics/spider.py`
`interior_struct/zalmoxis.py` | interior_energetics.module = "spider"; interior_struct.module = "zalmoxis" | interior_energetics, interior_struct | -| `core_density` | `kg m-3` | core density from structure solver | `interior_energetics/aragog.py`
`interior_energetics/wrapper.py`
`interior_struct/dummy.py`
`interior_struct/zalmoxis.py` | always; interior_energetics.module = "aragog"; interior_struct.module = "dummy"; interior_struct.module = "zalmoxis" | interior_energetics | +| `core_density` | `kg m-3` | core density from structure solver | `interior_energetics/aragog.py`
`interior_energetics/wrapper.py`
`interior_struct/dummy.py`
`interior_struct/zalmoxis.py` | always; interior_energetics.module = "aragog"; interior_struct.module = "dummy"; interior_struct.module = "zalmoxis" | interior_energetics, orbit | | `core_heatcap` | `J kg-1 K-1` | core heat capacity | `interior_energetics/wrapper.py`
`interior_struct/dummy.py`
`interior_struct/zalmoxis.py` | always; interior_struct.module = "dummy"; interior_struct.module = "zalmoxis" | interior_energetics | | `X_H2_int` | `1` | H2 mass fraction in interior (sub-Neptune mode) | `interior_struct/zalmoxis.py` | interior_struct.module = "zalmoxis" | interior_struct | | `struct_mass_desync_frac` | `1` | \|trapezoid - ODE accumulator\| / accumulator structure mass self-consistency | `interior_struct/zalmoxis.py` | interior_struct.module = "zalmoxis" | interior_struct | @@ -202,7 +216,7 @@ Each iteration carries the previous row forward and overwrites only the columns | Column | Unit | Description | Producer | Written when | Read by | |---|---|---|---|---|---| | `M_star` | `kg` | mass of star | `star/wrapper.py` | always | orbit | -| `R_star` | `m` | photospheric radius | `star/wrapper.py` | always | atmos_chem, atmos_clim, observe, plot, star | +| `R_star` | `m` | photospheric radius | `star/wrapper.py` | always | atmos_chem, atmos_clim, observe, orbit, plot, star | | `age_star` | `yr` | age relative to deuterium fusion 'stellar birthline' | `proteus.py` | always | main loop, star | | `T_star` | `K` | photospheric temperature | `star/wrapper.py` | always | observe, star | @@ -923,6 +937,8 @@ Each iteration carries the previous row forward and overwrites only the columns | `roche_limit` | `m` | Roche limit, orbital distance | `orbit/wrapper.py` | always | orbit, utils | | `breakup_period` | `s` | Critical day length | `orbit/wrapper.py` | always | orbit, utils | | `hill_radius` | `m` | Hill radius, radial distance | `orbit/wrapper.py` | always | atmos_clim, orbit | +| `roche_limit_sat` | `m` | Roche limit, orbital distance for the satellite | `orbit/wrapper.py` | always | orbit, utils | +| `breakup_period_sat` | `s` | Critical day length for satellite | `orbit/wrapper.py` | always | orbit, utils | ### Simulation's computational variables diff --git a/docs/Reference/output_schema.json b/docs/Reference/output_schema.json index 3914e0813..4eff1da0f 100644 --- a/docs/Reference/output_schema.json +++ b/docs/Reference/output_schema.json @@ -47,6 +47,24 @@ "plot" ] }, + { + "name": "sma_dot_planet", + "unit": "m s-1", + "description": "semi-major axis derivative", + "group": "Orbital and spin parameters of planet", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/orbit.py", + "condition": "orbit.evolve = true" + }, + { + "file": "src/proteus/orbit/satellite.py", + "condition": "orbit.satellite = true" + } + ], + "consumers": [] + }, { "name": "separation", "unit": "m", @@ -99,7 +117,8 @@ } ], "consumers": [ - "orbit" + "orbit", + "plot" ] }, { @@ -125,20 +144,36 @@ ] }, { - "name": "Imk2", - "unit": "1", - "description": "Imaginary part of k2 Love Number", + "name": "ecc_dot_planet", + "unit": "1 s-1", + "description": "eccentricity derivative", "group": "Orbital and spin parameters of planet", "origin": "literal", "producers": [ { - "file": "src/proteus/orbit/wrapper.py", - "condition": "always" + "file": "src/proteus/orbit/orbit.py", + "condition": "orbit.evolve = true" + }, + { + "file": "src/proteus/orbit/satellite.py", + "condition": "orbit.satellite = true" } ], - "consumers": [ - "orbit" - ] + "consumers": [] + }, + { + "name": "plan_star_am", + "unit": "kg m2 s-1", + "description": "angular momentum of star+planet", + "group": "Orbital and spin parameters of planet", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/orbit.py", + "condition": "orbit.evolve = true" + } + ], + "consumers": [] }, { "name": "axial_period", @@ -147,6 +182,10 @@ "group": "Orbital and spin parameters of planet", "origin": "literal", "producers": [ + { + "file": "src/proteus/orbit/orbit.py", + "condition": "orbit.evolve = true" + }, { "file": "src/proteus/orbit/satellite.py", "condition": "orbit.satellite = true" @@ -163,6 +202,22 @@ "utils" ] }, + { + "name": "Imk2", + "unit": "1", + "description": "Imaginary part of k2 Love Number", + "group": "Orbital and spin parameters of planet", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit" + ] + }, { "name": "longitude", "unit": "deg", @@ -204,23 +259,94 @@ ] }, { - "name": "perigee", + "name": "semimajorax_sat", "unit": "m", - "description": "lowest point in orbit", + "description": "semi-major axis", "group": "Satellite system", "origin": "literal", "producers": [ + { + "file": "src/proteus/orbit/satellite.py", + "condition": "orbit.satellite = true" + }, { "file": "src/proteus/orbit/wrapper.py", "condition": "always" } ], + "consumers": [ + "orbit", + "plot", + "utils" + ] + }, + { + "name": "sma_dot_sat", + "unit": "m s-1", + "description": "semi-major axis derivative", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/satellite.py", + "condition": "orbit.satellite = true" + } + ], "consumers": [] }, { - "name": "semimajorax_sat", + "name": "separation_sat", "unit": "m", - "description": "semi-major axis", + "description": "time-averaged separation", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "utils" + ] + }, + { + "name": "perigee", + "unit": "m", + "description": "lowest point in orbit", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit" + ] + }, + { + "name": "orbital_period_sat", + "unit": "s", + "description": "orbital duration", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit", + "plot" + ] + }, + { + "name": "eccentricity_sat", + "unit": "1", + "description": "orbital eccentricity of satellite", "group": "Satellite system", "origin": "literal", "producers": [ @@ -238,26 +364,132 @@ "plot" ] }, + { + "name": "ecc_dot_sat", + "unit": "1 s-1", + "description": "eccentricity derivative", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/satellite.py", + "condition": "orbit.satellite = true" + } + ], + "consumers": [] + }, + { + "name": "plan_sat_am", + "unit": "kg m2 s-1", + "description": "angular momentum of satellite+planet", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/satellite.py", + "condition": "orbit.satellite = true" + } + ], + "consumers": [ + "orbit", + "plot" + ] + }, + { + "name": "axial_period_sat", + "unit": "s", + "description": "day length of satellite around its axis", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/satellite.py", + "condition": "orbit.satellite = true" + }, + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit", + "plot", + "utils" + ] + }, + { + "name": "R_sat", + "unit": "m", + "description": "radius of satellite", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit" + ] + }, { "name": "M_sat", "unit": "kg", "description": "mass of satellite", "group": "Satellite system", "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit" + ] + }, + { + "name": "C_sat", + "unit": "kg m2", + "description": "principal moment of inertia of satellite", + "group": "Satellite system", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit" + ] + }, + { + "name": "evection_angle", + "unit": "rad", + "description": "evection angle", + "group": "Satellite system", + "origin": "literal", "producers": [ { "file": "src/proteus/orbit/satellite.py", "condition": "orbit.satellite = true" + }, + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" } ], "consumers": [ - "orbit" + "orbit", + "plot" ] }, { - "name": "plan_sat_am", - "unit": "kg m2 s-1", - "description": "angular momentum of sat+pla", + "name": "evection_dt_cap_yr", + "unit": "yr", + "description": "next macro-step dt cap, rate + growth limiter folded in", "group": "Satellite system", "origin": "literal", "producers": [ @@ -267,7 +499,7 @@ } ], "consumers": [ - "orbit" + "interior_energetics" ] }, { @@ -410,6 +642,26 @@ "interior_energetics" ] }, + { + "name": "C_int", + "unit": "kg m2", + "description": "principal moment of inertia of planet", + "group": "Planet structure", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/interior_energetics/common.py", + "condition": "always" + }, + { + "file": "src/proteus/orbit/common.py", + "condition": "always" + } + ], + "consumers": [ + "orbit" + ] + }, { "name": "R_solvus", "unit": "m", @@ -524,7 +776,8 @@ } ], "consumers": [ - "interior_energetics" + "interior_energetics", + "orbit" ] }, { @@ -1881,6 +2134,7 @@ "atmos_chem", "atmos_clim", "observe", + "orbit", "plot", "star" ] @@ -20571,6 +20825,40 @@ "orbit" ] }, + { + "name": "roche_limit_sat", + "unit": "m", + "description": "Roche limit, orbital distance for the satellite", + "group": "Diagnostic variables", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit", + "utils" + ] + }, + { + "name": "breakup_period_sat", + "unit": "s", + "description": "Critical day length for satellite", + "group": "Diagnostic variables", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/orbit/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "orbit", + "utils" + ] + }, { "name": "runtime", "unit": "s", diff --git a/docs/Tutorials/orbit_tides.md b/docs/Tutorials/orbit_tides.md new file mode 100644 index 000000000..dd4bdd47e --- /dev/null +++ b/docs/Tutorials/orbit_tides.md @@ -0,0 +1,191 @@ +# Orbit and tides + +This tutorial demonstrates how to use PROTEUS to model the orbital and +tidal evolution of a planet with a satellite. It covers the setup of the +planet-satellite system, the configuration of tidal parameters, and the +execution of simulations to study the effects of tides on planetary orbits. + +## Prerequisites + +- Full PROTEUS installation with AGNI, SOCRATES, and Obliqua compiled +- `FWL_DATA` and `RAD_DIR` environment variables set +- Spectral files downloaded (`proteus get spectral -n Dayspring -b 48`) +- Solar spectrum downloaded (`proteus get stellar`) +- Interior data downloaded, including the PALEOS EOS tables for the + structure solver + (`proteus get interiordata --config-path input/tutorials/tutorial_earth.toml`) + +Reference data is also fetched automatically when `proteus start` runs +without the `--offline` flag, so the download commands above are only +required for offline use. + +## Physical setup + +The physical setup for the orbit and tides tutorial involves defining the +properties of the planet and its satellite. For the satellite a data file called +`tutorial_earth_moon.json` is included in the `input/tutorials/` directory relative +to the PROTEUS root directory. The data file must be a JSON file with the following +structure: + +```json +{ + "omega": 1.0e-06, // Orbital frequency in rad/s + "axial": 1.0e-06, // Axial rotation frequency in rad/s + "ecc": 0.1, // Orbital eccentricity + "sma": 1.82e7, // Semi-major axis in meters + "S_mass": 6e24, // Mass of the central body in kg + "density": [ // Radial density profile in kg/m^3 + 7822.0, // Iron core density + 3500.0, // Solid mantle density + 3500.0 // Molten crust density + ], + "radius": [ // Radial radius profile in meters + 480000.0, // Radius of the iron core in meters + 995613.0, // Radius of the solid mantle in meters + 1650000.0 // Radius of the molten crust in meters (i.e. the surface) + ], + "visc": [ // Viscosity profile in Pa.s + 1e22, // Viscosity of the solid mantle in Pa.s + 1e2 // Viscosity of the molten crust in Pa.s + ], + "shear": [ // Shear modulus profile in Pa + 65857968278.256905, // Shear modulus of the solid mantle in Pa + 10.0 // Shear modulus of the molten crust in Pa + ], + "bulk": [ // Bulk modulus profile in Pa + 147739735943.7933, // Bulk modulus of the solid mantle in Pa + 1000000000.0 // Bulk modulus of the molten crust in Pa + ], + "phi": [ // Porosity profile (dimensionless) + 0.0, // Porosity of the solid mantle (dimensionless) + 1.0 // Porosity of the molten crust (dimensionless + ] +} +``` + +The specific values provided here reflect a partially molten Moon, with a fluid iron core, +a solid mantle, and a partially molten crust. Although we do not require the orbital +parameters for this test case, we still need to provide them in the data file. The +values provided here are arbitrary and do not affect the tidal response calculations. + +## Running the simulation + +```bash +conda activate proteus +mkdir -p output/tutorial_earth_moon +proteus start -c input/tutorials/tutorial_earth_moon.toml +``` + +Add `--offline` to skip the reference-data check on later runs; the first +run must be able to download any missing data (or download it beforehand, +see the prerequisites above). + +Monitor progress with `tail -f output/tutorial_earth_moon/proteus_00.log` +(the log appears once PROTEUS has initialized). + +!!! info "Runtime" + This run takes roughly 20 minutes depending on hardware. A fine excuse to go + read up on the physics while SPIDER, AGNI, and Obliqua sort out the + Earth-Moon system on your behalf: the [orbital dynamics](../Explanations/orbit.md) + page covers PROTEUS's own orbital models, or head over to the + [Obliqua documentation](https://proteus-framework.org/Obliqua) for the + multi-phase tidal-response theory driving this tutorial, whose + [usage guide](https://proteus-framework.org/Obliqua/dev/how-to-guides/usage/) + includes a tidal response evolution animation for an Earth-like planet. Not in + the mood to wait at all? The [Results](#results) section below already + has the pregenerated plots from a reference run. + +## Configuration + +The config at `input/tutorials/tutorial_earth_moon.toml` sets: + +- **Star**: Sun on Spada [^cite-spada2013] tracks starting at 50 Myr. The solar + spectrum is used for radiative transfer. Stellar luminosity, radius, and + XUV flux evolve with age. +- **Interior**: SPIDER solves the mantle energy equation on an 80-node radial + grid. SPIDER also computes the hydrostatic structure using the + `MgSiO3_Wolf_Bower_2018_1TPa` EOS tables. +- **Outgassing**: CALLIOPE partitions H$_2$O, CO$_2$, H$_2$, CH$_4$, and CO + between atmosphere and melt at the fO$_2$ = IW+2 buffer. +- **Atmosphere**: AGNI solves the radiative-convective equilibrium with + Dayspring 48-band correlated-k opacities and real-gas corrections. +- **Escape**: ZEPHYRUS computes energy-limited mass loss at 20% efficiency, + distributing the bulk escape rate across elements proportionally. + +## Results + +After the run completes, generate plots: + +```bash +proteus plot -c input/tutorials/tutorial_earth_moon.toml all +``` + +The reference run below terminates at t $\approx$ 7.9 × 105 yr once +the net atmosphere-interior flux drops below the convergence threshold +(status: *Completed, net flux is small*), with the mantle still around 20% +molten ($\Phi \approx 0.20$) rather than fully solidified. Overall, the +time steps are small enough to resolve the tidal evolution, but finer +time resolution will substantially reduce the jumping behavior in the tidal heating. +Ultimately, this is beyond the scope of this tutorial, as it would take substantially +longer to run. + +
+ ![Orbital evolution](../assets/orbit/orbit_tides_orbit.avif#only-light){ width="100%" } + ![Orbital evolution](../assets/orbit/orbit_tides_orbit_dark.avif#only-dark){ width="100%" } +
Planet-star and satellite-planet orbital evolution. + The planet's heliocentric semi-major axis and eccentricity stay pinned at + 1.00 AU and 0 (Obliqua evolves the planet-satellite pair; the star-planet + orbit is not perturbed by it here), so the planet's orbital period holds at + 365.2563 days while its axial (spin) period lengthens from 4.00 h to + 7.52 h as the satellite despins it. The satellite's semi-major axis climbs + from ~3.5 to ~23.4 Earth radii over the run, with its orbital and axial spin + periods rising together from ~9.1 h to ~158.3 h (i.e. it stays + tidally locked), and its eccentricity varying between 0 and 0.05.
+
+ +
+ ![Global flux budget](../assets/orbit/orbit_tides_fluxes_global.avif#only-light){ width="100%" } + ![Global flux budget](../assets/orbit/orbit_tides_fluxes_global_dark.avif#only-dark){ width="100%" } +
Global flux budget. + Tidal heating (gold) starts at ~5.0 × 105 W m-2, + comparable to the net interior/atmosphere flux (orange/grey) at that time, + then decreases as the satellite moves away from the planet, first dropping + below the runaway-greenhouse (S–N) limit at t ≈ + 8.8 × 104 yr. From there it no longer decays smoothly: it + reaches an absolute minimum of ~0.2 W m-2 near + 2.8 × 105 yr, then fluctuates through a long series of + resonance-driven bursts as the dominant tidal mode repeatedly sweeps in and out of the + mantle's normal-mode resonances (see the Love-number figure below), peaking + at ~1.7 × 103 W m-2 near + 7.4 × 105 yr. The mantle is still ~20% molten when the run + ends at t ≈ 7.9 × 105 yr, with tidal heating + (~2.6 × 102 W m-2) comparable to the net + interior/atmosphere flux rather than having settled into a smoothly decaying + solid-tide regime.
+
+ +
+ ![Love number spectrum evolution](../assets/orbit/orbit_tides_lovenumber.avif#only-light){ width="100%" } + ![Love number spectrum evolution](../assets/orbit/orbit_tides_lovenumber_dark.avif#only-dark){ width="100%" } +
Degree-2 Love number spectrum evolution (Obliqua). + The dominant, almost always populated mode is the semidiurnal (n=2, m=2, k=2) tide; + its forcing frequency |σ| decreases from + ~6.5 × 10-4 to ~4.4 × 10-4 rad + s-1 as the satellite recedes and despins the planet. + Re(k22) starts near 0.03 and jumps by roughly two orders of + magnitude to ~1–1.3 once the forcing frequency first sweeps past a + normal-mode (seismic) resonance around t ≈ 9.6 × 104 yr, + then fluctuates in that resonance-affected regime for most of the run. It + briefly exceeds the seismic-resonance threshold (Re > 1.5, ringed in red) + four times between t ≈ 6.5 × 105 and + 7.3 × 105 yr, peaking at ~4.1 at t ≈ + 7.3 × 105 yr (where Im(k22) also drops to its + smallest value, 10-3.0), before settling to small, occasionally + negative values (~10-1.0) by the end of the run.
+
+ +--- + +**See also:** [Model description](../Explanations/model.md) | [Orbital dynamics](../Explanations/orbit.md) |[Coupling loop](../Explanations/coupling_loop.md) | [Configuration reference](../Reference/config/params.md) | [Output format](../Reference/output.md) + + [^cite-spada2013]: Spada, F., Demarque, P., Kim, Y.C. & Sills, A., *[The radius discrepancy in low-mass stars: single versus binaries](https://doi.org/10.1088/0004-637X/776/2/87)*, The Astrophysical Journal, 776, 87, 2013. [SciX](https://scixplorer.org/abs/2013ApJ...776...87S/abstract). diff --git a/docs/Validation/orbit/obliqua.md b/docs/Validation/orbit/obliqua.md new file mode 100644 index 000000000..2ffd930bf --- /dev/null +++ b/docs/Validation/orbit/obliqua.md @@ -0,0 +1,27 @@ +# Validation: `src/proteus/orbit/obliqua.py` + +This page tracks the `@pytest.mark.reference_pinned` tests that anchor the +behaviour of `proteus.orbit.obliqua` against a published source or analytical +limit. The marker is registered in `pyproject.toml`. + +| Test id | Reference | Source page | Scope | +|---|---|---|---| +| `tests/orbit/test_obliqua.py::test_ln_from_lookup_enforces_love_number_reality_symmetry` | Reality condition for the frequency response of a real-valued (causal) linear physical system: $k(-\sigma) = k^*(\sigma)$ | n/a (analytical limit) | Pins that `LN_from_lookup` returns the complex conjugate of a lookup-table node's Love number when queried at the negative of that node's forcing frequency, rather than the same value or its negation. | + +## Re-derivation note + +A tidal Love number $k(\sigma)$ is the transfer function of a real-valued +input (the tide-raising potential) to a real-valued output (the body's +deformation), evaluated at forcing frequency $\sigma$. For any causal, +real-valued linear system, the transfer function obeys the Hermitian +symmetry $k(-\sigma) = k^*(\sigma)$ -- the same condition that underlies the +Kramers-Kronig relations. `LN_from_lookup` looks up Love numbers on a +one-sided ($\sigma \geq 0$) table and must apply this symmetry itself when a +negative-frequency mode is requested. + +The test seeds a lookup-table node at $\sigma = 2\times 10^{-6}$ with +$k = 0.02 - 0.03i$ and queries the mode at $\sigma = -2\times 10^{-6}$ +(`m=-2, k=0`). The expected result is $\mathrm{conj}(0.02 - 0.03i) = +0.02 + 0.03i$. A regression that instead returned the un-conjugated node +value, or one that only flipped the real part, would fail both the +`pytest.approx` pin and the sign discrimination guard (`imag > 0`). diff --git a/docs/Validation/orbit/orbit.md b/docs/Validation/orbit/orbit.md index dab5c4349..3ee4a3f70 100644 --- a/docs/Validation/orbit/orbit.md +++ b/docs/Validation/orbit/orbit.md @@ -6,14 +6,15 @@ limit. The marker is registered in `pyproject.toml`. | Test id | Reference | Source page | Scope | |---|---|---|---| -| `tests/orbit/test_orbit_evolve.py::test_de_dt_matches_driscoll_barnes_2015_eq16` | Driscoll and Barnes (2015), Astrobiology 15, 739 (DOI 10.1089/ast.2015.1325; arXiv:1509.07452), Eq. 16 | n/a (closed form) | Pins the prefactor `21/2`, the `a^-6.5` exponent, the `R_pl^5` scaling, and the linear-in-`e` dependence of the tidal eccentricity-damping rate at unit-scale parameters. Also asserts sign and order-of-magnitude, with discrimination guards against `a^5` and `a^7` neighbouring exponents. | -| `tests/orbit/test_orbit_evolve.py::test_evolve_orbital_first_call_seeds_from_config_with_au_conversion` | AU constant from `scipy.constants` (IAU 2015 Resolution B2 nominal value, 1 AU = 1.495978707e11 m) | n/a (closed form) | Pins the AU-to-metres conversion that the orchestrator applies to `semimajoraxis` on the first call. A regression that dropped the AU factor would leave the semi-major axis at the config value in AU (e.g. 0.5) instead of the SI value (~7.48e10 m); the lower-bound `> 1e10 m` scale guard discriminates this. | +| `tests/orbit/test_orbit.py::test_sp0d_de_dt_matches_driscoll_barnes_2015_eq16` | Driscoll and Barnes (2015), Astrobiology 15, 739 (DOI 10.1089/ast.2015.1325; arXiv:1509.07452), Eq. 16 | n/a (closed form) | Pins the prefactor `21/2`, the `a^-6.5` exponent, the `R_pl^5` scaling, and the linear-in-`e` dependence of `sp0d`'s tidal eccentricity-damping rate at unit-scale parameters. Also asserts sign and order-of-magnitude, with discrimination guards against `a^5` and `a^7` neighbouring exponents. | +| `tests/orbit/test_orbit.py::test_sp1d_conserves_total_angular_momentum` | Angular-momentum conservation for an isolated two-body system with only internal tidal torques (analytical limit, not paper-specific) | n/a (closed form) | For `sp1d` (which, unlike `sp0d`, tracks the planet's spin), pins that planet-spin + orbital angular momentum is conserved to `rel=1e-6` across a real, non-trivial step (`e`: 0.3 to below 0.1). | +| `tests/orbit/test_orbit.py::test_sp1d_spin_am_gain_matches_orbital_am_loss` | Same conservation law as above, more targeted | n/a (closed form) | The planet's spin AM is ~1e-6 of the system total, so the raw-sum check above is nearly blind to a bug confined to `domega_dt`. This test instead pins `Delta(spin AM) == -Delta(orbital AM)` directly, at a tolerance where the two comparable-magnitude quantities discriminate a coefficient bug the raw-sum check misses. | ## Sign convention note The paper uses `Im(k2) < 0` for tidal dissipation (Eq. 4 expresses `-Im(k2)` as the positive dissipation efficiency). The PROTEUS source -takes positive `Imk2` from callers (`run_dummy_orbit`, `run_lovepy`), +takes positive `Imk2` from callers (`run_dummy_tides`, `run_lovepy`), so the formula evaluated with positive `Imk2` returns positive `de/dt` and expands the orbit instead of circularizing it. This is documented in the source docstring as a known science item; the test pins the diff --git a/docs/Validation/orbit/satellite.md b/docs/Validation/orbit/satellite.md index c1c07b1a9..ece4ab9e1 100644 --- a/docs/Validation/orbit/satellite.md +++ b/docs/Validation/orbit/satellite.md @@ -6,6 +6,8 @@ behaviour of `proteus.orbit.satellite` against a published source. | Test id | Reference | Source page | Scope | |---|---|---|---| | `tests/orbit/test_satellite.py::test_update_satellite_angular_momentum_matches_korenaga_2023_eq60` | Korenaga (2023) Icarus 400, 115564, Eq. 60 (orbital component cross-checked against Touma and Wisdom 1994) | n/a | Pins the spin-plus-orbital decomposition on the present-day Earth-Moon configuration. Asserts sign and the 1e34-1e35 kg m^2 / s order of magnitude expected from Korenaga's Eq. 60. | +| `tests/integration/test_slow_orbit_evection_ctl.py::test_resonance_capture_occurs_within_expected_time_window` | Rufu and Canup (2020) Figure 3 evection-resonance capture case, as reproduced in `src/proteus/orbit/evection_notebook.ipynb` / `plot_evection.png` (capture at t~2.4e4 yr) | n/a | Drives the real `evolve_orbit_satellite(model='ps1d_evec')` under a Mignard constant-time-lag tidal spectrum from the paper's Figure-3-calibrated initial conditions; asserts eccentricity crosses 0.1 within t between `2e4` and `4e4` yr. Compared against a real run reaching t=2.58e4 yr, e=0.111. | +| `tests/integration/test_slow_orbit_evection_ctl.py::test_peak_eccentricity_matches_reference_location` | Same source as above (peak e~0.72-0.724 at a'~11.89 R_earth, t~5.2e4 yr) | n/a | Same driver; asserts the eccentricity peak reaches >= 0.6 at a' between `10` and `13` R_earth. Compared against a real run's observed peak of e=0.755 at a'=11.88 R_earth, t=5.12e4 yr -- the peak a' matches the reference to <0.1%. The post-peak contraction phase (a' declining to ~10.2 R_earth by t=1e5 yr in the reference) was not reached in that run and is not asserted here. | ## Re-derivation note @@ -37,6 +39,29 @@ reference-pinned test brackets the total in `[1e34, 1e35]` kg m^2 / s; a regression that swaps `M_sat` for `M_planet` in the orbital prefactor would land at ~2.4e36 kg m^2 / s, well outside the bracket. +## Eq. 58-59: rotation and semi-major-axis rate equations + +Korenaga (2023) Eq. 58 gives the planet's spin-down rate, + +``` +dOmega/dt = -E_tide_dot / (I*Omega + G*M_pl*M_sat*I / (a*(L - I*Omega))) +``` + +where `E_tide_dot` is the tidal power dissipated in the planet (positive, +W) and `L` is the conserved total angular momentum from Eq. 60. The minus +sign ensures the spin slows whenever tidal energy is dissipated, matching +the expectation that dissipation transfers angular momentum from the +planet's spin to the satellite's orbit. Eq. 59 for the semi-major axis, + +``` +da/dt = -2*I*a / (L - I*Omega) * dOmega/dt +``` + +follows directly from differentiating the Eq. 60 closure at constant `L` +and solving for `da/dt`: whenever the planet's spin slows, the orbit +expands, provided `L > I*Omega` (the prograde-satellite regime PROTEUS +targets). + ## Correctness of the orbital prefactor The orbital prefactor in Eq. 60 is the satellite mass `M_M`, not the diff --git a/docs/assets/orbit/evection_animation.webm b/docs/assets/orbit/evection_animation.webm new file mode 100644 index 000000000..7ff7cdad0 Binary files /dev/null and b/docs/assets/orbit/evection_animation.webm differ diff --git a/docs/assets/orbit/orbit_ps.png b/docs/assets/orbit/orbit_ps.png new file mode 100644 index 000000000..ef3f35d4f Binary files /dev/null and b/docs/assets/orbit/orbit_ps.png differ diff --git a/docs/assets/orbit/orbit_sp.png b/docs/assets/orbit/orbit_sp.png new file mode 100644 index 000000000..fd996da9f Binary files /dev/null and b/docs/assets/orbit/orbit_sp.png differ diff --git a/docs/assets/orbit/orbit_system.webm b/docs/assets/orbit/orbit_system.webm new file mode 100644 index 000000000..633efd4de Binary files /dev/null and b/docs/assets/orbit/orbit_system.webm differ diff --git a/docs/assets/orbit/orbit_tides_fluxes_global.avif b/docs/assets/orbit/orbit_tides_fluxes_global.avif new file mode 100644 index 000000000..187fec88e Binary files /dev/null and b/docs/assets/orbit/orbit_tides_fluxes_global.avif differ diff --git a/docs/assets/orbit/orbit_tides_fluxes_global_dark.avif b/docs/assets/orbit/orbit_tides_fluxes_global_dark.avif new file mode 100644 index 000000000..b0532085d Binary files /dev/null and b/docs/assets/orbit/orbit_tides_fluxes_global_dark.avif differ diff --git a/docs/assets/orbit/orbit_tides_lovenumber.avif b/docs/assets/orbit/orbit_tides_lovenumber.avif new file mode 100644 index 000000000..8d76adc43 Binary files /dev/null and b/docs/assets/orbit/orbit_tides_lovenumber.avif differ diff --git a/docs/assets/orbit/orbit_tides_lovenumber_dark.avif b/docs/assets/orbit/orbit_tides_lovenumber_dark.avif new file mode 100644 index 000000000..c2f9ac29c Binary files /dev/null and b/docs/assets/orbit/orbit_tides_lovenumber_dark.avif differ diff --git a/docs/assets/orbit/orbit_tides_orbit.avif b/docs/assets/orbit/orbit_tides_orbit.avif new file mode 100644 index 000000000..40bf9e2a0 Binary files /dev/null and b/docs/assets/orbit/orbit_tides_orbit.avif differ diff --git a/docs/assets/orbit/orbit_tides_orbit_dark.avif b/docs/assets/orbit/orbit_tides_orbit_dark.avif new file mode 100644 index 000000000..74d43b55f Binary files /dev/null and b/docs/assets/orbit/orbit_tides_orbit_dark.avif differ diff --git a/input/all_options.toml b/input/all_options.toml index 030c00295..ab2e37bbe 100644 --- a/input/all_options.toml +++ b/input/all_options.toml @@ -61,6 +61,12 @@ config_version = "3.0" max_growth_factor = 0.0 # cap on dt growth ratio between consecutive steps; 0 = disabled mushy_maximum = 1.0e6 # max dt [yr] while stop.solid.phi_crit < Phi < mushy_upper (0 = disabled); caps steps through the mushy band so the integrator resolves the solidification front rather than overstepping it mushy_upper = 0.99 # mushy regime upper bound (melt fraction) + evection_maximum = 'none' # ceiling dt [yr] while hf_row['in_evection_band']/['near_evection_band'] ('none' = disabled, must be >0 otherwise); used directly until rate history exists, then only as an upper bound on the rate-based cap below + evection_target_rel_de = 0.05 # target max fractional change in eccentricity_sat per step while in/near the evection band + evection_de_floor = 0.02 # floor on eccentricity_sat in the evection_target_rel_de ratio's denominator + evection_rate_window = 2 # trailing macro-steps used to fit the secular |de/dt| (least-squares for >2, two-point diff at the default of 2); widen (e.g. 10-20) if eccentricity_sat's own evection-angle-driven oscillation aliases into a stuck-small dt + evection_growth_factor = 'none' # cap on dt growth ratio while in/near the evection band or within evection_cooldown_iters of leaving it; 'none' = disabled, must be >0 otherwise + evection_cooldown_iters = 'none' # iterations after leaving the evection band during which evection_growth_factor stays active; 'none' = disabled, must be >0 otherwise hysteresis_iters = 0 # suppress speed-up for N iters after a slow-down; 0 = disabled hysteresis_sfinc = 1.1 # gentler speed-up factor while hysteresis active @@ -99,6 +105,17 @@ config_version = "3.0" spin_enabled = true # check rotational breakup offset_spin = 0 # correction to breakup period [s] + [params.stop.disint_sat] # satellite disintegration criterion + enabled = false + roche_enabled = true # check Roche limit (around the planet) + offset_roche = 0 # correction to the satellite's Roche limit [m] + spin_enabled = true # check rotational breakup + offset_spin = 0 # correction to the satellite's breakup period [s] + + [params.stop.satellite] # satellite escape criterion + enabled = false + sma_max = 60 # terminate when the satellite's semi-major axis exceeds this value [R_earth] + [params.stop.clock] # wall-clock runtime criterion enabled = false maximum = 8.64e4 # terminate after this wall-clock runtime [s] @@ -212,25 +229,64 @@ config_version = "3.0" # Planetary orbit & tides [orbit] - module = "none" # tidal heating module: none | dummy | lovepy - - # Orbit geometry and instellation + # Orbit instellation instellation_method = "distance" # "distance" (semi-major axis) | "inst" (flux) - semimajoraxis = 1.0 # semi-major axis [AU] (if method = "distance") instellationflux = 1.0 # instellation [S_Earth] (if method = "inst") + + # Orbit geometry + semimajoraxis = 1.0 # semi-major axis [AU] (if method = "distance") eccentricity = 0.0 # orbital eccentricity zenith_angle = 48.19 # characteristic zenith angle [degrees] s0_factor = 0.375 # instellation geometric scale factor - - # Tidal evolution and rotation - evolve = false # evolve semi-major axis and eccentricity axial_period = "none" # day length [hours]; none = tidally locked - # Satellite (moon) - satellite = false # include satellite - mass_sat = 7.347e+22 # satellite mass [kg] - semimajoraxis_sat = 3e8 # satellite orbit semi-major axis [m] - + # Tidal evolution and rotation + star_planet_model = "none" # evolve star-planet orbital parameters (semi-major axis and eccentricity); options = "none" | "sp0d" | "sp1d" + planet_satellite_model = "none" # evolve planet-satellite orbital parameters (semi-major axis and eccentricity); options = "none" | "ps1d" | "ps1d_evec" + + perturber = "star" # perturbing object ("star", "satellite") to induce tides on the primairy (planet) + + # Tidal heating module + module = "none" # tidal heating module: none | dummy | lovepy | obliqua + + # Satellite + [orbit.satellite] + include_satellite = false # include satellite + mass_sat = 0.012 # satellite mass [M_earth] + radius_sat = 0.273 # satellite radius [R_earth] + axial_period_sat = "none" # day length [hours]; none = tidally locked + semimajoraxis_sat = 3.5 # satellite orbit semi-major axis [R_earth] + eccentricity_sat = 0.0 # satellite initial orbital eccentricity [dimensionless] + evection_angle = 0.0 # initial evection angle [degrees] + c_factor_sat = 0.4 # satellite normalized moment of inertia factor [dimensionless] + love_number_sat = "none" # satellite love number spectrum or interior structure, provide absolute path to file. + + # Shared ODE-solver and adaptive-substep-controller settings, used by + # both the star-planet models (sp0d, sp1d) and the planet-satellite + # models (ps0d, ps1d, ps1d_evec) + [orbit.solver] + method = "Radau" # scipy.integrate.solve_ivp integration method + rtol = 1e-6 # relative tolerance passed to solve_ivp + atol = 1e-9 # absolute tolerance passed to solve_ivp + + dt0_yr = 1e-4 # initial adaptive-substep size [yr] + dt_max_yr = 2000.0 # maximum adaptive-substep size [yr] + growth = 1.15 # substep growth factor applied after an accepted step + shrink = 0.35 # substep shrink factor applied after a rejected step + + max_rel_da = 0.01 # max tolerated relative change in semi-major axis per substep + max_rel_de = 0.01 # max tolerated relative change in eccentricity per substep + max_rel_dOmega = 0.02 # max tolerated relative change in a spin rate per substep + de_floor = 0.05 # floor on the eccentricity-change-ratio denominator + max_substeps = 10_000_000 # maximum number of substeps attempted per call + + # ps1d_evec only + resonance_margin_enter = 0.10 # evection-band entry margin ((a-a_res)/a < margin) + resonance_margin_exit = 0.50 # evection-band exit margin ((a-a_res)/a > margin) + resonance_margin_approach = 0.30 # wider pre-emptive margin for event detection + fine_csv_target_rel_dt = 0.01 # target storage-clock spacing for out-of-band fine samples, stores with 1% of current time, time resolution + + # Tidal heating [orbit.dummy] H_tide = 1e-7 # fixed tidal power density [W kg-1] Phi_tide = "<0.3" # apply heating where melt fraction satisfies inequality @@ -240,6 +296,55 @@ config_version = "3.0" visc_thresh = 1e9 # minimum viscosity for tidal heating [Pa s] ncalc = 1000 # grid points for tidal calculation + [orbit.obliqua] + store_3D = false # Store 3D tidal response for each layer. Note: this will generate large output files. If false, radial profiles of tidal responses will be stored instead. + enforce_ec = true # Enforce energy conservation in tidal response calculations, improves stability in fluid-mush cases at low forcing frequencies (< 1e-7 Hz). This does not affect the Lovenumbers. + optimize_scales = false # Optimize non-dimensionalization scales for the relaxation method. This can improve convergence and stability, especially for low forcing frequencies (< 1e-7 Hz). However, do not use with Bigfloat precision. + solid_shell = true # Insert an infinitesimal solid shell around the core. This patches an issue where y2 and y4 become decoupled and cause the solution to diverge in fluid layers. Only use with solid1d_relax or solid1d_mush_relax. + cap_LN = false # Clamp each mode's Re(k2)/Im(k2) to 3x/2x the fluid Love-number limit for its degree n, heating is rescaled to match via enforce_ec. + + min_frac = 0.02 # minimum segment radius fraction before smoothing [dimensionless] + visc_lus = 5e5 # liquid-mush handoff viscosity [Pa s] + visc_sus = 5e5 # solid-mush handoff viscosity [Pa s] + n = [2] # power of the radial factor (goes with (r/a)^{n}, since r< tuple[float, float]: return Ra_max, ratio -def sync_log_files(outdir: str) -> list[str]: - """Move AGNI logfile content into the PROTEUS logfile and clear it. - - Returns the list of lines that were copied, so that callers can scan - them for failure-mode markers (see `_extract_agni_failure_reason`). - Returns an empty list if the AGNI logfile cannot be read. - """ - # Logfile paths - agni_logpath = os.path.join(outdir, AGNI_LOGFILE_NAME) - logpath = GetLogfilePath(outdir, GetCurrentLogfileIndex(outdir)) - - # Copy logfile content - try: - with open(agni_logpath, 'r') as infile: - inlines = infile.readlines() - except OSError: - return [] - - with open(logpath, 'a') as outfile: - for i, line in enumerate(inlines): - # First line of agni logfile has NULL chars at the start, for some reason - if i == 0 and '[' in line: - line = '[' + line.split('[', 1)[1] - # copy the line - outfile.write(line) - - # Remove logfile content - with open(agni_logpath, 'w') as hdl: - hdl.write('') - - return inlines +# Bound to AGNI's own recent-run logfile name -- see make_log_syncer's +# docstring; obliqua.py binds the same factory to its own Obliqua_LOGFILE_NAME. +# Callers can scan the returned lines for failure-mode markers (see +# `_extract_agni_failure_reason`). +sync_log_files = make_log_syncer(AGNI_LOGFILE_NAME) # AGNI failure-mode markers emitted by AGNI/src/solver.jl lines 967-993. diff --git a/src/proteus/config/__init__.py b/src/proteus/config/__init__.py index 288e0788e..6a2a99aa5 100644 --- a/src/proteus/config/__init__.py +++ b/src/proteus/config/__init__.py @@ -3,6 +3,7 @@ import logging import tomllib from pathlib import Path +from typing import Literal, Union import cattrs @@ -13,6 +14,18 @@ log = logging.getLogger('fwl.' + __name__) +def structure_k_val(val, _cls): + if isinstance(val, bool) or not isinstance(val, (int, str)): + raise ValueError(f'Expected int or "none", got {val!r}') + if val == 'none': + return 'none' + return int(val) + + +# Register this for the specific Union type +cattrs.register_structure_hook(Union[int, Literal['none']], structure_k_val) + + def _is_explicit_zero(value: object) -> bool: """True for a TOML int or float value of zero, false for a bool or 0.0.""" return isinstance(value, (int, float)) and not isinstance(value, bool) and value == 0.0 diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index d51b577ee..9efe1cd84 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -38,24 +38,77 @@ def instmethod_dummy(instance, attribute, value): def instmethod_evolve(instance, attribute, value): """Orbital evolution cannot be combined with instellation method 'inst'.""" - if (instance.orbit.instellation_method == 'inst') and instance.orbit.evolve: + if (instance.orbit.instellation_method == 'inst') and ( + instance.orbit.star_planet_model is not None + ): raise ValueError( "Planet orbital evolution not supported for `instellation_method='inst'`" ) def satellite_evolve(instance, attribute, value): - """Planetary orbital evolution and the satellite model are mutually exclusive.""" - if instance.orbit.satellite and instance.orbit.evolve: + """Star-planet orbital evolution and the planet-satellite model are mutually exclusive.""" + if ( + instance.orbit.star_planet_model is not None + and instance.orbit.planet_satellite_model is not None + ): raise ValueError( 'Planet orbital evolution cannot be used simultaneously with a satellite' ) def tides_enabled_orbit(instance, attribute, value): - """Interior tidal heating requires an orbit module to be enabled.""" + """Interior tidal heating requires an tides module to be enabled.""" if (instance.interior_energetics.heat_tidal) and (instance.orbit.module is None): - raise ValueError('Interior tidal heating requires an orbit module to be enabled') + raise ValueError('Interior tidal heating requires a tides module to be enabled') + + +def obliqua_requires_perturber(instance, attribute, value): + """The Obliqua tidal-response module requires an explicit perturber.""" + if instance.orbit.module == 'obliqua' and instance.orbit.perturber is None: + raise ValueError( + "orbit.module = 'obliqua' requires orbit.perturber to be explicitly set to " + "'star' or 'satellite' (it has no default tidal-forcing body to fall back on)" + ) + + +def sp0d_obliqua_degree_mismatch(instance, attribute, value): + """sp0d's closed-form is by definition the n=2 Love number. Obliqua can compute + arbitrary tidal degree(s), block the mismatch. + """ + if instance.orbit.module == 'obliqua' and instance.orbit.star_planet_model == 'sp0d': + if instance.orbit.obliqua.n != [2]: + raise ValueError( + "orbit.star_planet_model = 'sp0d' requires orbit.obliqua.n == [2]: " + 'set orbit.obliqua.n = [2] to use sp0d with Obliqua, or use' + "orbit.star_planet_model = 'sp1d' instead." + ) + log.warning( + "orbit.star_planet_model = 'sp0d' with orbit.module = 'obliqua': Imk2 is " + "the mean of Obliqua's per-mode Im(k2) spectrum collapsed to a single " + 'scalar, which discards the eccentricity-dependent mode weighting sp1d ' + 'uses directly. This is an approximation, least accurate at high or ' + "rapidly-changing eccentricity. Prefer orbit.star_planet_model = 'sp1d'" + ' when using Obliqua.' + ) + + +def orbit_requires_tides(instance, attribute, value): + """sp1d, ps1d, and ps1d_evec require at least Lovepy, but ideally the Obliqua + tidal-response module: all three read the full per-mode spectrum in + ``tides_o``, which ``dummy`` never populates. ``sp0d``/``ps0d`` read the + scalar ``Imk2`` instead (which ``dummy`` does provide), so they are + unrestricted here; see "Compatibility between orbit models and tidal + modules" in docs/Explanations/orbit.md. + """ + needs_full_spectrum = instance.orbit.star_planet_model == 'sp1d' or ( + instance.orbit.planet_satellite_model in ('ps1d', 'ps1d_evec') + ) + if needs_full_spectrum and instance.orbit.module not in ('obliqua', 'lovepy'): + raise ValueError( + "orbit.star_planet_model = 'sp1d' or orbit.planet_satellite_model = " + "'ps1d'/'ps1d_evec' requires orbit.module = 'obliqua' or 'lovepy'" + ) CURRENT_CONFIG_VERSION = '3.0' @@ -316,7 +369,15 @@ class Config: params: Params = field(factory=Params) star: Star = field(factory=Star) orbit: Orbit = field( - factory=Orbit, validator=(instmethod_dummy, instmethod_evolve, satellite_evolve) + factory=Orbit, + validator=( + instmethod_dummy, + instmethod_evolve, + satellite_evolve, + obliqua_requires_perturber, + sp0d_obliqua_degree_mismatch, + orbit_requires_tides, + ), ) planet: Planet = field( factory=Planet, diff --git a/src/proteus/config/_interior.py b/src/proteus/config/_interior.py index c431d30a8..bf2c73016 100644 --- a/src/proteus/config/_interior.py +++ b/src/proteus/config/_interior.py @@ -340,6 +340,10 @@ class InteriorBoundary: Silicate density [kg/m^3]. Default taken from Fei et. al. 2021 (https://ui.adsabs.harvard.edu/abs/2021NatCo..12..876F). core_density: float Core density [kg/m^3]. + core_shear: float + Core shear modulus [Pa]. + core_bulk: float + Core bulk modulus [Pa]. thermal_conductivity: float Thermal conductivity [W/m/K]. thermal_diffusivity: float @@ -369,6 +373,8 @@ class InteriorBoundary: nusselt_exponent: float = field(default=0.33, validator=gt(0)) # - silicate_heat_capacity: float = field(default=1.2e3, validator=gt(0)) # J/kg/K core_density: float = field(default=10738.0, validator=gt(0)) # kg/m^3 + core_shear: float = field(default=1.0e-1, validator=gt(0)) # Pa + core_bulk: float = field(default=5e11, validator=gt(0)) # Pa atm_heat_capacity_const: bool = field(default=True) atm_heat_capacity: float = field(default=1.7e4, validator=gt(0)) # J/kg/K silicate_density: float = field(default=4103.0, validator=gt(0)) # kg/m^3 @@ -477,6 +483,8 @@ class Interior: Maximum absolute change in T_magma per PROTEUS step [K]. tmagma_rtol: float Maximum relative change in T_magma per PROTEUS step. + tmagma_tides_step: float + Maximum change in T_magma allowed when tides are active [K]. param_utbl: bool Enable the ultra-thin boundary layer parameterisation. param_utbl_const: float @@ -566,6 +574,7 @@ class Interior: rfront_loc: float = field(default=0.5, validator=(gt(0), lt(1))) rfront_wid: float = field(default=0.2, validator=(gt(0), lt(1))) + tmagma_tides_step: float = field(default=10.0, validator=ge(0)) # Phase-dependent eddy diffusivity floor [m^2/s]. Default 0 = standard MLT. # When > 0, applies max(kh_MLT, floor * f(phi)) where f transitions from @@ -853,6 +862,7 @@ def __attrs_post_init__(self): ( 'tmagma_atol', 'tmagma_rtol', + 'tmagma_tides_step', ), ), ( diff --git a/src/proteus/config/_orbit.py b/src/proteus/config/_orbit.py index 4f7483fa2..3abeff0b8 100644 --- a/src/proteus/config/_orbit.py +++ b/src/proteus/config/_orbit.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Literal, Union + from attrs import define, field from attrs.validators import ge, gt, in_, le, lt @@ -21,8 +23,8 @@ def phi_tide_validator(instance, attribute, value): @define -class OrbitDummy: - """Dummy orbit/tidal heating module. +class Dummy: + """Dummy tidal heating module. Uses a fixed tidal heating power density and love number. @@ -57,6 +59,196 @@ class Lovepy: ncalc: int = field(default=1000, validator=gt(100)) +@define +class ObliquaSolid: + """Solid-tide configuration for Obliqua tides. + + Attributes + ---------- + ncalc: int + Number of interpolated interior levels to use for solving tidal heating rates (shooting method). + dr_min: float + Minimum radial grid spacing [m] (Henyey/relaxation method). + dr_max: float + Maximum radial grid spacing [m] (Henyey/relaxation method). + core: str + Core solution vector ("liquid", "solid", "inertial-liquid", or "inertial"). + core_props: str + Core properties to use ("core" or "mantle"). + inertial_terms: bool + Whether to include inertial terms in the solid-tide solution. + bulk_l: float + Bulk modulus of the liquid phase [Pa]. + porosity_thresh: float + Porosity threshold, hard cutoff below which melt fraction is set to zero [dimensionless]. + dbulk_power: float + Drained bulk modulus powerlaw scaling exponent [dimensionless]. + """ + + ncalc: int = field(default=1000, validator=gt(100)) + dr_min: int = field(default=300, validator=gt(0)) + dr_max: int = field(default=3000, validator=gt(0)) + core: str = field( + default='liquid', validator=in_(('liquid', 'solid', 'inertial-liquid', 'inertial')) + ) + core_props: str = field(default='core', validator=in_(('core', 'mantle'))) + inertial_terms: bool = field(default=True) + bulk_l: float = field(default=1e9, validator=gt(0)) + porosity_thresh: float = field(default=3e-2, validator=gt(0)) + dbulk_power: float = field(default=0.5, validator=gt(0)) + + +@define +class ObliquaMushy: + """Mushy-tide configuration for Obliqua tides. + + Attributes + ---------- + b_width: float + Scale width of the bottom heating decay profile [dimensionless]. + t_width: float + Scale width of the top heating decay profile [dimensionless]. + """ + + b_width: float = field(default=5e-1, validator=gt(0)) + t_width: float = field(default=3e-2, validator=gt(0)) + + +@define +class ObliquaFluid: + """Fluid-tide configuration for Obliqua tides. + + Attributes + ---------- + sigma_R: float + Rayleigh drag in the fluid-mush/solid boundary layers [1/s]. + sigma_R_factor: float + Rayleigh drag in the pure fluid as a fraction of the interface [dimensionless]. + sigma_R_prf: str + Radial heating distribution profile [dimensionless]. + H_R: float + Scale height to be used by heating profile [m]. + efficiency: float + Rayleigh drag efficiency at core interface [dimensionless]. + """ + + sigma_R: float = field(default=1e-3, validator=gt(0)) + sigma_R_factor: float = field(default=0.5, validator=gt(0)) + sigma_R_prf: str = field( + default='exp', + validator=in_(('uniform', 'exp', 'linear', 'quadratic', 'dynamic', 'dynamic_interp')), + ) + H_R: float = field(default=1e4, validator=gt(0)) + efficiency: float = field(default=0.3, validator=gt(0)) + + +@define +class Obliqua: + """Obliqua tides module. + + Attributes + ---------- + store_3D : bool + Whether to store 3D information for solid tides. + enforce_ec : bool + Whether to enforce energy conservation between Lovenumbers and heating profile. + optimize_scales : bool + Whether to optimize the non-dimensional scaling parameters for solid tides. + solid_shell : bool + Whether to insert an infinitesimal solid shell around the core. + cap_LN : bool + Whether to clamp each mode's Love number to a fixed multiple of the + classical fluid limit for its degree. + min_frac : float + Minimal segment radius fraction before smoothing. + visc_lus : float + Liquidus viscosity [Pa s]. + visc_sus : float + Solidus viscosity [Pa s]. + n : int + Power of the radial factor (r/a)^n. + m : int + Tidal harmonic (m=2 semidiurnal, m=1 diurnal). + k_min : int + Minimum Fourier index in mean anomaly (adaptive spectrum). + k_max : int + Maximum Fourier index in mean anomaly (adaptive spectrum). + evection_padding_factor : float + Safety multiplier on the linear look-ahead eccentricity padding + applied to Obliqua's own adaptive k-range selection. + material_mu : str + Rheology model for complex shear modulus ("andrade" or "maxwell"). + material_k : str + Rheology model for complex bulk modulus ("andrade" or "maxwell"). + alpha : float + Andrade power-law exponent. + verbosity : int + Logging verbosity level (0=silent, 1=info, 2=debug). + module_solid : str + Solid-tide module to use ("none", "solid0d", "solid1d", "solid1d-relax", "solid1d-mush", "solid1d-mush-relax", "solid1d-equil-relax"). + module_mushy : str + Mushy-tide module to use ("none", "interp"). + module_fluid : str + Fluid-tide module to use ("none", "fluid0d", "fluid1d"). + solid : ObliquaSolid + Solid-tide configuration. + mushy : ObliquaMushy + Mushy-tide configuration. + fluid : ObliquaFluid + Fluid-tide configuration. + """ + + # global configuration + store_3D: bool = field(default=False) + enforce_ec: bool = field(default=True) + optimize_scales: bool = field(default=False) + solid_shell: bool = field(default=True) + cap_LN: bool = field(default=False) + + min_frac: float = field(default=0.02, validator=gt(0)) + + visc_lus: float = field(default=5e5, validator=gt(0)) + visc_sus: float = field(default=5e5, validator=gt(0)) + + n: list = field(default=[2]) + m: list = field(default=[0, 2]) + + k_min: Union[int, Literal['none']] = field(default='none') + k_max: Union[int, Literal['none']] = field(default='none') + evection_padding_factor: float = field(default=2.0, validator=ge(0)) + + material_mu: str = field( + default='andrade', validator=in_(('andrade', 'maxwell', 'elastic')) + ) + material_k: str = field(default='andrade', validator=in_(('andrade', 'maxwell', 'elastic'))) + alpha: float = field(default=0.3, validator=gt(0)) + + verbosity: int = field(default=1, validator=in_((0, 1, 2))) + + # module selection + module_solid: str = field( + default='solid0d', + validator=in_( + ( + 'none', + 'solid0d', + 'solid1d', + 'solid1d-relax', + 'solid1d-mush', + 'solid1d-mush-relax', + 'solid1d-equil-relax', + ) + ), + ) + module_mushy: str = field(default='none', validator=in_(('none', 'interp'))) + module_fluid: str = field(default='fluid0d', validator=in_(('none', 'fluid0d', 'fluid1d'))) + + # submodules + solid: ObliquaSolid = field(factory=ObliquaSolid) + mushy: ObliquaMushy = field(factory=ObliquaMushy) + fluid: ObliquaFluid = field(factory=ObliquaFluid) + + def ax_valid(instance, attribute, value): if value is None: return @@ -65,6 +257,126 @@ def ax_valid(instance, attribute, value): raise ValueError(f'Initial axial period must be >0 hours, got {value}') +@define +class Satellite: + """Satellite orbit configuration for planet-satellite systems. + + Attributes + ---------- + include_satellite: bool + Whether to model a satellite orbiting the planet. + mass_sat: float + Satellite mass [M_earth]. + radius_sat: float + Satellite radius [R_earth]. + axial_period_sat: float | None + Satellite initial day length [hours], will use orbital period if value is None. + semimajoraxis_sat: float + Satellite initial semi-major axis [R_earth]. + eccentricity_sat: float + Satellite initial orbital eccentricity [dimensionless]. + evection_angle: float + Satellite evection angle [deg]. + c_factor_sat: float + Satellite tidal dissipation factor (<= 0.4) [dimensionless]. + love_number_sat: str | None + Satellite love number spectrum, provide absolute path to netCDF file containing forcing + frequencies and complex Lovenumbers, and corresponding tidal degree in nmk format. + """ + + # Satellite orbit + include_satellite: bool = field(default=False) + mass_sat: float = field(default=0.012, validator=gt(0)) + radius_sat: float = field(default=0.273, validator=gt(0)) + axial_period_sat = field(default=None, validator=ax_valid, converter=none_if_none) + semimajoraxis_sat: float = field(default=3.5, validator=gt(0)) + eccentricity_sat: float = field(default=0.0, validator=ge(0)) + evection_angle: float = field(default=0.0, validator=ge(0)) + c_factor_sat: float = field( + default=0.4, + validator=( + gt(0), + le(0.4), + ), + ) + love_number_sat: str | None = field(default=None, converter=none_if_none) + + +@define +class OrbitSolver: + """Shared numerical-solver settings for the orbital-evolution ODE models. + + Used by both the star-planet models (sp0d, sp1d) and the + planet-satellite models (ps0d, ps1d, ps1d_evec): a single set of + tolerances and adaptive-substep-controller knobs, since only one of + the two model families is ever active in a given run (star-planet + evolution and a satellite are mutually exclusive; see + `satellite_evolve`). + + Attributes + ---------- + method: str + scipy.integrate.solve_ivp integration method. + rtol: float + Relative tolerance passed to solve_ivp. + atol: float + Absolute tolerance passed to solve_ivp. + dt0_yr: float + Initial adaptive-substep size [yr]. + dt_max_yr: float + Maximum adaptive-substep size [yr]. + growth: float + Substep growth factor applied after an accepted step. + shrink: float + Substep shrink factor applied after a rejected step. + max_rel_da: float + Maximum tolerated relative change in semi-major axis per substep. + max_rel_de: float + Maximum tolerated relative change in eccentricity per substep. + max_rel_dOmega: float + Maximum tolerated relative change in a spin rate per substep. + de_floor: float + Floor on the eccentricity-change-ratio denominator, so a small + starting eccentricity does not make the ratio spuriously huge. + max_substeps: int + Maximum number of substeps attempted per call. + resonance_margin_enter: float + Evection-band entry margin (ps1d_evec only). + resonance_margin_exit: float + Evection-band exit margin (ps1d_evec only). + resonance_margin_approach: float + Wider, purely-diagnostic margin used to set a pre-emptive signal + that the system is closing in on the band before the tighter + ``resonance_margin_enter`` would declare capture. + fine_csv_target_rel_dt: float + Target storage-clock spacing for out-of-band fine samples, as a + fraction of the requested call duration (ps1d_evec only). + """ + + method: str = field( + default='Radau', + validator=in_(('RK45', 'RK23', 'DOP853', 'Radau', 'BDF', 'LSODA')), + ) + rtol: float = field(default=1e-6, validator=gt(0)) + atol: float = field(default=1e-9, validator=gt(0)) + + dt0_yr: float = field(default=1e-4, validator=gt(0)) + dt_max_yr: float = field(default=2000.0, validator=gt(0)) + growth: float = field(default=1.15, validator=gt(1.0)) + shrink: float = field(default=0.35, validator=(gt(0), lt(1))) + + max_rel_da: float = field(default=0.01, validator=gt(0)) + max_rel_de: float = field(default=0.01, validator=gt(0)) + max_rel_dOmega: float = field(default=0.02, validator=gt(0)) + de_floor: float = field(default=0.05, validator=gt(0)) + max_substeps: int = field(default=10_000_000, validator=gt(0)) + + resonance_margin_enter: float = field(default=0.10, validator=gt(0)) + resonance_margin_exit: float = field(default=0.50, validator=gt(0)) + resonance_margin_approach: float = field(default=0.30, validator=gt(0)) + fine_csv_target_rel_dt: float = field(default=0.01, validator=gt(0)) + + @define class Orbit: """Planetary and satellite orbital parameters. @@ -87,27 +399,35 @@ class Orbit: s0_factor: float Scale factor applies to incoming stellar radiation to represent planetary rotation. - evolve: bool - Allow the planet's orbit to evolve based on eccentricity tides? + star_planet_model: str | None + Select star-planet orbit module to use. Choices: 'none', 'sp0d', 'sp1d'. axial_period: float | None Planet initial day length [hours], will use orbital period if value is None. - satellite: bool - Model a satellite (moon) orbiting the planet and solve for its orbit? - mass_sat: float - Satellite mass [kg]; the default is the lunar mass. - semimajoraxis_sat: float - Satellite initial semi-major axis [m] + satellite: Satellite + Satellite and orbit configuration for planet-satellite systems. + + planet_satellite_model: str | None + Select planet-satellite orbit module to use. Choices: 'none', 'ps0d', 'ps1d', 'ps1d_evec'. + + solver: OrbitSolver + Shared ODE-solver and adaptive-substep-controller settings for the + star-planet and planet-satellite orbital-evolution models. + + perturber: str | None + Select perturber to induce tides on the planet. Options: 'none', 'star', 'satellite'. module: str | None - Select orbit module to use. Choices: 'none', 'dummy', 'lovepy'. + Select tides module to use. Choices: 'none', 'dummy', 'lovepy', 'obliqua'. + + dummy: Dummy + Dummy tidal heating module configuration. + lovepy: Lovepy + Lovepy tidal heating module configuration. + obliqua: Obliqua + Obliqua tidal heating module configuration. """ - # Tidal heating modules - module: str | None = field( - default='none', validator=in_((None, 'dummy', 'lovepy')), converter=none_if_none - ) - # Planet initial orbital parameter semimajoraxis: float = field(default=1.0, validator=gt(0)) eccentricity: float = field( @@ -117,6 +437,8 @@ class Orbit: lt(1), ), ) + instellation_method: str = field(default='distance', validator=in_(('distance', 'inst'))) + instellationflux: float = field(default=1.0, validator=gt(0)) # Climate parameters set by rotation of planet zenith_angle: float = field( @@ -128,20 +450,39 @@ class Orbit: ) s0_factor: float = field(default=0.375, validator=gt(0)) - # Allow the planet's orbit to evolve based on eccentricity tides? - evolve: bool = field(default=False) - + # Orbital model to use for star-planet orbit evolution based on tides + star_planet_model: str | None = field( + default='none', validator=in_((None, 'none', 'sp0d', 'sp1d')), converter=none_if_none + ) # Initial day length for planet [hours] - # If none, assume 1:1 spin orbit resonance + # If none, assume 1:1 spin orbit synchronization and use orbital period as day length axial_period = field(default=None, validator=ax_valid, converter=none_if_none) - # Satellite orbit - satellite: bool = field(default=False) - mass_sat: float = field(default=7.347e22, validator=gt(0)) - semimajoraxis_sat: float = field(default=3e8, validator=gt(0)) + # Satellite orbit configuration + satellite: Satellite = field(factory=Satellite) - dummy: OrbitDummy = field(factory=OrbitDummy) - lovepy: Lovepy = field(factory=Lovepy) + # Orbital model to use for planet-satellite orbit evolution based on tides + planet_satellite_model: str | None = field( + default='none', + validator=in_((None, 'none', 'ps0d', 'ps1d', 'ps1d_evec')), + converter=none_if_none, + ) - instellation_method: str = field(default='distance', validator=in_(('distance', 'inst'))) - instellationflux: float = field(default=1.0, validator=gt(0)) + # Shared ODE-solver and adaptive-substep-controller settings + solver: OrbitSolver = field(factory=OrbitSolver) + + # Perturber to induce tides on the planet. Options: 'none', 'star', 'satellite'. + perturber: str | None = field( + default=None, validator=in_((None, 'none', 'star', 'satellite')), converter=none_if_none + ) + + # Tidal heating modules + module: str | None = field( + default='none', + validator=in_((None, 'dummy', 'lovepy', 'obliqua')), + converter=none_if_none, + ) + + dummy: Dummy = field(factory=Dummy) + lovepy: Lovepy = field(factory=Lovepy) + obliqua: Obliqua = field(factory=Obliqua) diff --git a/src/proteus/config/_params.py b/src/proteus/config/_params.py index aa66617ba..97f08d7a3 100644 --- a/src/proteus/config/_params.py +++ b/src/proteus/config/_params.py @@ -4,7 +4,7 @@ from __future__ import annotations from attrs import define, field -from attrs.validators import ge, gt, in_, lt +from attrs.validators import ge, gt, in_, lt, optional from ._converters import none_if_none @@ -136,6 +136,44 @@ class TimeStepParams: ``Phi_global > stop.solid.phi_crit``, ``mushy_maximum`` takes over from ``maximum``. Default 0.99 so the cap kicks in as soon as the first cell crystallises. + evection_maximum: float | str + Ceiling on the time-step size [yr] while the planet-satellite + system is inside, or approaching, the evection resonance band. + Must be > 0 when set. Default ``'none'`` (disables the whole + mechanism). + evection_target_rel_de: float + Target maximum fractional change in ``eccentricity_sat`` per + macro-step while inside/approaching the evection band. Obliqua's + own adaptive mode-window selection is keyed on the eccentricity + it is given at call time, and that window is then held fixed for + the whole of the following macro-step, so a large fractional swing + in ``e`` within one step risks needing modes outside that window. + Default 0.05 (5%). + evection_de_floor: float + Floor on the eccentricity value used in the + ``evection_target_rel_de`` ratio's denominator, so a tiny + eccentricity right at capture onset does not make the allowed + step blow up. Default 0.02. + evection_rate_window: int + Number of trailing accepted macro-steps used to estimate the + SECULAR ``|de/dt|`` this cap bounds against (a least-squares + linear fit for windows > 2, the plain two-point difference at + the default of 2). Default 2, preserves the two-point behaviour. + evection_growth_factor: float | str + Cap on the dt growth ratio between consecutive steps while the + system is inside/approaching the evection band, or within + ``evection_cooldown_iters`` steps of having left it. Separate + from the global ``max_growth_factor`` (which most evection runs + leave disabled, since it would also throttle ordinary bulk + evolution for the rest of the run). Must be > 0 when set. Default + ``'none'`` (disabled). + evection_cooldown_iters: int | str + Number of PROTEUS iterations, after the system is no longer judged + in/near the evection band, during which ``evection_growth_factor`` + remains active. Refreshed to this value on every iteration the + zone is active, so a long stay in the band does not exhaust it + before exit. Must be > 0 when set. Default ``'none'`` (no cooldown + tail; growth limiting turns off the instant the zone is left). hysteresis_iters: int Number of PROTEUS iterations after an adaptive "slow down" decision during which the speed-up factor is suppressed. @@ -170,9 +208,26 @@ class TimeStepParams: # Stiffness-aware adaptive time-stepping extensions. # Defaults OFF (mushy_maximum=0, hysteresis_iters=0); enable via - # positive config values. + # positive config values. The evection_* trio below use 'none' rather + # than 0 as their opt-out sentinel (see each field's own docstring + # above): 0 is not a meaningful value for any of the three (a zero + # ceiling/growth-factor/cooldown is behaviourally identical to + # disabled, so collapsing that ambiguity into an explicit 'none' + # avoids a silently-degenerate positive-looking config value). mushy_maximum: float = field(default=0.0, validator=ge(0)) mushy_upper: float = field(default=0.99, validator=(gt(0), lt(1))) + evection_maximum: float | str = field( + default=None, validator=optional(gt(0)), converter=none_if_none + ) + evection_target_rel_de: float = field(default=0.05, validator=gt(0)) + evection_de_floor: float = field(default=0.02, validator=gt(0)) + evection_rate_window: int = field(default=2, validator=ge(2)) + evection_growth_factor: float | str = field( + default=None, validator=optional(gt(0)), converter=none_if_none + ) + evection_cooldown_iters: int | str = field( + default=None, validator=optional(gt(0)), converter=none_if_none + ) hysteresis_iters: int = field(default=0, validator=ge(0)) hysteresis_sfinc: float = field(default=1.1, validator=ge(1.0)) @@ -309,6 +364,49 @@ class StopDisint: offset_spin: float = field(default=0) +@define +class StopDisintSat: + """Parameters for satellite disintegration stopping criteria. + + Attributes + ---------- + enabled: bool + Enable all planet disintegration criteria if True + roche_enabled: bool + Disable Roche limit criterion + offset_roche: float + Absolute correction (+/-) to (increase/decrease) calculated Roche limit [m]. + spin_enabled: bool + Disable Breakup period criterion + offset_spin: float + Absolute correction (+/-) to (increase/decrease) calculated Breakup period [s]. + """ + + enabled: bool = field(default=False) + + roche_enabled: bool = field(default=True) + offset_roche: float = field(default=0) + + spin_enabled: bool = field(default=True) + offset_spin: float = field(default=0) + + +@define +class StopSatellite: + """Parameters for satellite escape stopping criteria. + + Attributes + ---------- + enabled: bool + Enable criteria if True + sma_max: float + Maximum semi-major axis for the satellite [R_Earth]. + """ + + enabled: bool = field(default=False) + sma_max: float = field(default=60) + + @define class StopClock: """Parameters for maximum clock runtime stopping criteria. @@ -366,6 +464,8 @@ class StopParams: Parameters for escape criteria. disint: StopDisint Parameters for planet disintegration criteria. + disint_sat: StopDisintSat + Parameters for satellite disintegration criteria. clock: StopClock Parameters for maximum clock runtime criteria. stall: StopStall @@ -378,6 +478,8 @@ class StopParams: radeqm: StopRadeqm = field(factory=StopRadeqm) escape: StopEscape = field(factory=StopEscape) disint: StopDisint = field(factory=StopDisint) + disint_sat: StopDisintSat = field(factory=StopDisintSat) + satellite: StopSatellite = field(factory=StopSatellite) clock: StopClock = field(factory=StopClock) stall: StopStall = field(factory=StopStall) diff --git a/src/proteus/doctor.py b/src/proteus/doctor.py index 5b9fbc30f..899f8357d 100644 --- a/src/proteus/doctor.py +++ b/src/proteus/doctor.py @@ -33,6 +33,7 @@ from proteus.utils.coupler import ( _get_agni_version, + _get_obliqua_version, _get_socrates_version, get_proteus_directories, ) @@ -483,8 +484,21 @@ def check_python_package(name: str, spec: Requirement | None) -> CheckResult: ) -def check_git_module(name: str, dirs: dict) -> CheckResult: - """Check a git-pinned module (AGNI, SOCRATES) against pyproject.toml ref.""" +def check_git_module(name: str, dirs: dict, required: bool = True) -> CheckResult | None: + """Check a git-pinned module (AGNI, SOCRATES, Obliqua) against pyproject.toml ref. + + Parameters + ---------- + name : str + Module name, matching a `[tool.proteus.modules.]` + pyproject.toml table and a `dirs[name.lower()]` entry. + dirs : dict + Directory mapping from `get_proteus_directories()`. + required : bool + AGNI/SOCRATES are mandatory for a standard install, so a missing + checkout is a FAIL. Optional tidal-heating backends (Obliqua) are + not needed unless the user opts into them. + """ pins = _module_pins() pin = pins.get(name.lower(), {}) pinned_ref = pin.get('ref') @@ -497,6 +511,8 @@ def check_git_module(name: str, dirs: dict) -> CheckResult: path = dirs.get(dir_key, '') if not path or not os.path.isdir(path): + if not required: + return None # Unlike the off-pin case below, this fix is not chained to an AGNI # rebuild: a not-installed SOCRATES means RAD_DIR is unset, so the AGNI # step would have nowhere to find SOCRATES. The install script prints @@ -516,6 +532,8 @@ def check_git_module(name: str, dirs: dict) -> CheckResult: ver = _get_agni_version(dirs) elif name == 'SOCRATES': ver = _get_socrates_version() + elif name == 'Obliqua': + ver = _get_obliqua_version(dirs) else: ver = '?' except Exception: @@ -591,6 +609,7 @@ def check_git_module(name: str, dirs: dict) -> CheckResult: ] GIT_MODULES = ['AGNI', 'SOCRATES'] +OPTIONAL_GIT_MODULES = ['Obliqua'] def run_all_checks() -> list[CheckResult]: @@ -684,6 +703,22 @@ def run_all_checks() -> list[CheckResult]: ) ) + # Optional git-pinned modules: only reported when actually installed. + for mod in OPTIONAL_GIT_MODULES: + try: + result = check_git_module(mod, dirs, required=False) + if result is not None: + results.append(result) + except Exception as exc: + results.append( + CheckResult( + name=mod, + category='versions', + status=FAIL, + message=f'check error: {exc}', + ) + ) + return results diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 00091a3af..a4ee37714 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -783,3 +783,59 @@ def update_rheology(self, visc: bool = False): self.bulk[i] = eval_rheoparam(p, 'bulk') if visc: self.visc[i] = eval_rheoparam(p, 'visc') + + +def get_C_planet(hf_row: dict, config: Config, interior_o: Interior_t): + """Compute the planet's principal moment of inertia (C_int) based on the interior structure. + + Parameters + ---------- + hf_row : dict + Dictionary of current runtime variables + config : Config + Model configuration. + interior_o : Interior_t + Interior object containing interior arrays + """ + # Calculate the planet's principal moment of inertia (C_planet) + # Assuming a spherically symmetric mass distribution, we can use the formula: + # C = (8/3) * pi * integral_0^R (rho(r) * r^4 dr) + # where rho(r) is the density profile and R is the radius of the planet. + + # Get the radial grid and density profile from the interior object + arr_keys = ('density', 'radius') + lov = {k: np.array(getattr(interior_o, k), copy=True, dtype=float) for k in arr_keys} + core_density = hf_row.get('core_density', None) + if core_density is None: + core_density = config.interior_struct.core_density + # Warn user + log.warning( + 'core_density not found in hf_row; using config value: %.3e kg/m^3', + core_density, + ) + + # Reverse arrays if using SPIDER + # Such that i=0 is at the CMB + if config.interior_energetics.module == 'spider': + for k in arr_keys: + lov[k] = lov[k][::-1] + + # Include the core density as the innermost layer + r_edges = np.concatenate(([0.0], lov['radius'])) + rho = np.concatenate(([core_density], lov['density'])) + + r0 = r_edges[:-1] + r1 = r_edges[1:] + + integral = np.sum(rho * (r1**5 - r0**5) / 5.0) + + C_planet = (8 * np.pi / 3.0) * integral + + # Store C_planet in the helpfile row for later use + hf_row['C_int'] = C_planet + + # Check if C_planet is physically reasonable + C_factor_planet = C_planet / (hf_row['M_int'] * hf_row['R_int'] ** 2) + log.info( + f'Computed C_planet: {C_planet:.3e} kg.m^2, C_factor_planet: {C_factor_planet:.3f}' + ) diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index 836eab38c..640d37eab 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -620,7 +620,6 @@ def _try_spider( hf_row: dict, step_sf: float, atol_sf: float, - dT_max: float, timeout: float = 60 * 30, mesh_file: str | None = None, interior_o=None, @@ -771,7 +770,15 @@ def _try_spider( ) else: dT_poststep = float(config.interior_energetics.tmagma_atol) - call_sequence.extend(['-tsurf_poststep_change', str(min(dT_max, dT_poststep))]) + # When tides active... + if ( + config.interior_energetics.heat_tidal + and interior_o is not None + and (np.amax(interior_o.tides) > 1e-10) + ): + dT_poststep = min(dT_poststep, config.interior_energetics.tmagma_tides_step) + log.info('Tidal heating active; limiting dT_magma to %.2f K' % dT_poststep) + call_sequence.extend(['-tsurf_poststep_change', str(dT_poststep)]) # set surface and core entropy (-1 is a flag to ignore) call_sequence.extend(['-ic_surface_entropy', '-1']) @@ -1188,12 +1195,6 @@ def RunSPIDER( spider_success = False # success? attempts = 0 # number of attempts so far - # Maximum dT - dT_max = 1e99 - if config.interior_energetics.heat_tidal and (np.amax(interior_o.tides) > 1e-10): - dT_max = 4.0 - log.info('Tidal heating active; limiting dT_magma to %.2f K' % dT_max) - # make attempts while not spider_success: attempts += 1 @@ -1208,7 +1209,6 @@ def RunSPIDER( hf_row, step_sf, atol_sf, - dT_max, mesh_file=mesh_file, interior_o=interior_o, ) diff --git a/src/proteus/interior_energetics/timestep.py b/src/proteus/interior_energetics/timestep.py index 7913eec77..009155b93 100644 --- a/src/proteus/interior_energetics/timestep.py +++ b/src/proteus/interior_energetics/timestep.py @@ -199,9 +199,10 @@ def next_step( Scale factor to apply to step size interior_o : Interior_t, optional Interior object used to persist stiffness-aware adaptive - state (hysteresis counter) across calls. When ``None``, - the hysteresis and stiffness logging features are - disabled and the controller runs without hysteresis. Pass + state (hysteresis counter, evection growth-limiter cooldown + counter) across calls. When ``None``, the hysteresis, + stiffness logging, and evection growth-limiter features are + disabled and the controller runs without them. Pass ``interior_o`` when available. Returns @@ -418,6 +419,36 @@ def next_step( ) dtswitch = mushy_max + # Evection-resonance dt cap: mirrors the mushy-regime cap above, for a + # different stiffness source. Computed and exported by + # proteus.orbit.satellite.evolve_orbit_satellite -- see + # _estimate_evection_dt_cap_yr's own docstring there for the physical + # reasoning (tidal-mode-window coverage, not just orbital-state + # smoothness) and why the whole mechanism (rate cap AND the evection- + # scoped growth limiter that used to sit here, with its own cooldown + # counter) is computed in orbit now: this is the ONLY evection-related + # read left in next_step, a single precomputed value folded into + # dtswitch like any other cap. + # + # `evection_dt_cap_yr` is a registered helpfile column, so + # ZeroHelpfileRow() has already initialised it to 0.0 in hf_row before + # orbit ever runs (e.g. no planet_satellite_model configured, or the + # very first iteration) -- NOT np.inf, unlike the in-memory default + # `_estimate_evection_dt_cap_yr` itself returns. A genuine computed + # cap can never be <= 0.0 (every bound it folds together is strictly + # positive whenever finite), so <= 0.0 unambiguously means "not yet + # computed", treated as no cap. + evection_cap = float(hf_row.get('evection_dt_cap_yr', np.inf)) + if evection_cap <= 0.0: + evection_cap = np.inf + if np.isfinite(evection_cap) and dtswitch > evection_cap: + log.info( + 'Time-stepping: evection cap active, capping dt at %.2e yr (was %.2e yr)', + evection_cap, + dtswitch, + ) + dtswitch = evection_cap + # On retries (step_sf < 1) in the static/initial branches we # deliberately allow dt to fall below dt.minimum; the whole point of # a retry is to shrink the step below what would otherwise be allowed. diff --git a/src/proteus/orbit/common.py b/src/proteus/orbit/common.py new file mode 100644 index 000000000..17bda34c0 --- /dev/null +++ b/src/proteus/orbit/common.py @@ -0,0 +1,455 @@ +# Common tides model functions +from __future__ import annotations + +import logging +from collections import ChainMap +from dataclasses import dataclass, field +from typing import Any, List, Optional + +import netCDF4 as nc +import numpy as np +from numpy.typing import NDArray + +from proteus.config import Config +from proteus.interior_energetics.common import Interior_t, get_C_planet +from proteus.utils.helper import UpdateStatusfile + +log = logging.getLogger('fwl.' + __name__) + + +@dataclass +class TidalInteraction: + """Tides interaction between a primary and a perturber. + This class stores the tidal mode information for a specific interaction + between a primary and a perturber. It contains the tidal modes (n, m, k), + the forcing frequencies (sigma), and the complex Love numbers (LNk). + """ + + primary: Any + perturber: Any + + nmk: Optional[NDArray[np.int_]] = None + sigma: Optional[NDArray[np.floating]] = None + LNk: Optional[NDArray[np.complexfloating]] = None + + +@dataclass +class Tides_t: + """Registry of tidal interactions (mode, forcing frequency, Love number) + keyed by (primary, perturber), plus controller-only bookkeeping with no + physical meaning of its own (adaptive step size, evection-band + hysteresis state, fine-CSV write cursors). See "Adaptive substep + controller" and "The three clocks" in docs/Explanations/orbit.md for what + each of these fields feeds into. + """ + + interactions: List[TidalInteraction] = field(default_factory=list) + dt_yr: Optional[float] = None + fine_csv_last_t_yr: Optional[float] = None + fine_csv_next_target_yr: Optional[float] = None + resonance_state: dict = field(default_factory=dict) + evection_ecc_history: List[tuple] = field(default_factory=list) + evection_zone_active: bool = False + evection_cooldown_remaining: int = 0 + + def add(self, primary, perturber): + """Add a new tidal interaction between the primary and the perturber. + If an interaction already exists, it will be returned instead of creating a new one. + """ + try: + return self.get(primary, perturber) + except KeyError: + interaction = TidalInteraction(primary, perturber) + self.interactions.append(interaction) + return interaction + + def get(self, primary, perturber): + """Get the tidal interaction between the primary and the perturber. + If no such interaction exists, a KeyError is raised. + """ + for interaction in self.interactions: + if interaction.primary == primary and interaction.perturber == perturber: + return interaction + raise KeyError(f'No tidal interaction: {primary} <- {perturber}') + + def add_from_file(self, primary, perturber, file_path: str): + """Add a new tidal interaction between the primary and the perturber, + loading the tidal mode information from a NetCDF file. + """ + interaction = self.add(primary, perturber) + + with nc.Dataset(file_path, 'r') as ds: + n = ds.variables['n'][:] + m = ds.variables['m'][:] + k = ds.variables['k'][:] + + interaction.nmk = np.column_stack([n, m, k]).astype(int) + interaction.sigma = ds.variables['sigma'][:] + interaction.LNk = ds.variables['LNk_real'][:] + 1j * ds.variables['LNk_imag'][:] + + return interaction + + +def kmin_kmax_for_m0_mirror(nmk: NDArray[np.int_]) -> tuple[int, int]: + """(kmin, kmax) spanning the raw supplied k range PLUS the negative-k + mirror the m=0 branch needs. + """ + kmin = int(np.min(nmk[:, 2])) + kmax = int(np.max(nmk[:, 2])) + m0_k = nmk[nmk[:, 1] == 0, 2] + if len(m0_k) > 0: + kmin = min(kmin, -int(np.max(m0_k))) + return kmin, kmax + + +def run_adaptive_orbit_substeps( + hf_row: dict, + config: Config, + dirs: dict, + tides_o: Tides_t, + interior_o: Interior_t, + model: str, + step_fn, + state_is_valid_fn, + rel_change_fn, + rel_change_limits: dict, + needs_c_planet: bool, + on_accept_fn=None, + log_label: str = 'run_adaptive_orbit_substeps', +): + """Advance an orbital-evolution model by `interior_o.dt` yr using an + adaptive accept/reject substep controller, shared by sp1d/ps0d/ps1d/ + ps1d_evec. Each call site supplies the model-specific ODE step, state-validity + check, and relative-change metrics as callables; this function owns the + substep loop, the angular-momentum-conserving `C_int` ramp, and the + persistence of the step-size state across calls. See "Adaptive substep + controller" in docs/Explanations/orbit.md for the physical and + numerical rationale (why C_int is ramped rather than jumped, and why + attempts are staged in a `ChainMap` overlay rather than mutating + `hf_row` directly). + + Parameters + ---------- + hf_row : dict + Runtime state; mutated only once a substep is accepted. + config : Config + Reads `config.orbit.solver` for every tolerance/controller knob. + dirs : dict + Directory paths, used only for logging and error messages. + tides_o : Tides_t + `tides_o.dt_yr` carries the controller's step-size state across + calls (one call per PROTEUS main-loop iteration). + interior_o : Interior_t + `interior_o.dt` is the total elapsed time to advance by, in years. + model : str + Name of the active model, used only for log messages. + step_fn : callable(attempt, dt_yr, t_elapsed_yr) -> Any + Advances `attempt` in place by `dt_yr`. May return arbitrary + "extra" data, forwarded to `on_accept_fn` only if the substep is + accepted. + state_is_valid_fn : callable(attempt) -> bool + Whether the state `step_fn` produced is physical; False (or an + exception from `step_fn`) rejects and shrinks the substep. + rel_change_fn : callable(attempt, hf_row) -> dict[str, float] + Named relative-change metrics; keys must match `rel_change_limits`. + rel_change_limits : dict[str, float] + Maximum tolerated value per key; exceeding any rejects the substep. + Growth only occurs once every value is below 30% of its limit. + needs_c_planet : bool + Whether this model reads `hf_row['C_int']`; if True, `C_int` is + ramped from its call-start value to a freshly computed target + across the substep loop, with `axial_period` rescaled at each + accepted substep to conserve `C_int * Omega_p`. + on_accept_fn : callable(hf_row, extra) -> None, optional + Called once a substep is confirmed accepted, for side effects that + must not run on a rejected trial (e.g. ps1d_evec's fine-CSV write). + log_label : str, optional + Prefix for log messages, to distinguish the two call sites. + """ + solver = config.orbit.solver + + # Specify the initial timestep size + dt_yr = tides_o.dt_yr if tides_o.dt_yr is not None else solver.dt0_yr + + # Setup the solver clock timescales + t_total_yr = interior_o.dt + dt_max = solver.dt_max_yr if solver.dt_max_yr is not None else t_total_yr + dt_yr = min(dt_yr, t_total_yr) if t_total_yr > 0 else dt_yr + + # Accumulators + t_elapsed = 0.0 + n_steps = 0 + n_rejected = 0 + + # Cumulative drift cap: only ps0d needs it. + enable_cumulative_cap = model == 'ps0d' + call_start_snapshot = dict(hf_row) if enable_cumulative_cap else None + + log.debug( + '%s: ENTER model=%s Time=%.6e yr t_total_yr=%.6e dt_yr_start=%.3e', + log_label, + model, + float(hf_row['Time']), + t_total_yr, + dt_yr, + ) + + # Refresh the planet's moment-of-inertia coefficient from the current + # interior state. The resulting jump (if any) is smoothed across the + # substep loop below rather than applied here -- see "Smoothing the + # structural C_planet update" in this function's own docstring. + C_p_call_start = None + C_p_call_target = None + ramp_c_planet = False + + def _rescale_c_planet_to(row, target_c_p): + """Move row['C_int'] to target_c_p, rescaling axial_period to + conserve C_int*Omega_p (a no-op on the rescale if there is no + valid prior C_int/axial_period to conserve against).""" + c_p_before = row.get('C_int') + if ( + c_p_before is not None + and np.isfinite(c_p_before) + and c_p_before > 0 + and np.isfinite(target_c_p) + and target_c_p != 0 + ): + omega_p_before = 2 * np.pi / float(row['axial_period']) + row['axial_period'] = 2 * np.pi / (omega_p_before * c_p_before / target_c_p) + row['C_int'] = target_c_p + + if needs_c_planet: + C_p_call_start = hf_row.get('C_int') + + try: + get_C_planet(hf_row, config, interior_o) + C_p_call_target = hf_row['C_int'] + + if not np.isfinite(C_p_call_target) or C_p_call_target == 0: + log.error( + '%s: get_C_planet produced C_p_new=%r (C_p_old=%r) at ' + 'Time=%.6e yr -- structural update will be skipped', + log_label, + C_p_call_target, + C_p_call_start, + float(hf_row['Time']), + ) + + ramp_c_planet = ( + C_p_call_start is not None + and np.isfinite(C_p_call_start) + and C_p_call_start > 0 + and np.isfinite(C_p_call_target) + and C_p_call_target != 0 + and t_total_yr > 0 + ) + if not ramp_c_planet: + # No prior value to ramp from (bootstrap), a degenerate + # get_C_planet result (logged above), or a zero-length + # call (no substeps will run to do the ramping): apply + # the full jump immediately, same as the un-smoothed + # behaviour in all three cases. + _rescale_c_planet_to(hf_row, C_p_call_target) + else: + # Restore the call-start value so the substep loop below + # ramps toward the target instead of landing on it in + # one jump. + hf_row['C_int'] = C_p_call_start + except Exception as err: + log.error( + '%s: C_planet update RAISED at Time=%.6e yr (C_p_old=%r, model=%s); re-raising', + log_label, + float(hf_row['Time']), + C_p_call_start, + model, + exc_info=True, + ) + UpdateStatusfile(dirs, 26) + raise RuntimeError( + f'C_planet update failed for {log_label} at Time={float(hf_row["Time"]):.6e} yr ' + f'(model={model}, C_p_old={C_p_call_start!r})' + ) from err + + # Initialize exit_reason to None. + exit_reason = None + + # Loop until the requested total time has been advanced, or until the + # maximum number of substeps has been reached. + while t_elapsed < t_total_yr and n_steps < solver.max_substeps: + dt_yr = min(dt_yr, t_total_yr - t_elapsed) + + # Stage this substep's tentative changes in an overlay rather than + # mutating hf_row directly, so a rejected attempt costs nothing to + # discard (docs/Explanations/orbit.md, "Adaptive substep controller"). + attempt = ChainMap({}, hf_row) + + try: + with np.errstate(all='ignore'): + # If ramping C_planet, compute the linearly-interpolated target for + # this substep and rescale the planet's spin to conserve AM. + if ramp_c_planet: + frac = min(1.0, (t_elapsed + dt_yr) / t_total_yr) + target_this_substep = ( + C_p_call_start + (C_p_call_target - C_p_call_start) * frac + ) + _rescale_c_planet_to(attempt, target_this_substep) + + # Run the model's ODE step for this substep, and check whether the + # resulting state is physically valid. Extra data returned by the step + # function is only forwarded to on_accept_fn if the substep is accepted. + extra = step_fn(attempt, dt_yr, t_elapsed) + + # Check whether the resulting state is physically valid. If not, discard + # this attempt (hf_row was never touched) and shrink the timestep. + ok = state_is_valid_fn(attempt) + if not ok: + bad_fields = { + k: v + for k, v in attempt.items() + if isinstance(v, (int, float)) and not np.isfinite(v) + } + log.warning( + '%s: state_is_valid_fn rejected the state at ' + 't_elapsed=%.6e/%.6e yr, dt_yr=%.3e; non-finite fields=%r', + log_label, + t_elapsed, + t_total_yr, + dt_yr, + bad_fields, + ) + except Exception: + log.warning( + '%s: substep raised (model=%s, dt_yr=%.3e, t_elapsed=%.6e/%.6e yr)', + log_label, + model, + dt_yr, + t_elapsed, + t_total_yr, + exc_info=True, + ) + ok = False + extra = None + + # Check the tracked quantities' relative change against their limits. + rel_changes = {} + if ok: + rel_changes = rel_change_fn(attempt, hf_row) + tripped = [ + f'{key}={value:.4g}>{rel_change_limits[key]:.4g}' + for key, value in rel_changes.items() + if value > rel_change_limits[key] + ] + if tripped: + ok = False + log.debug( + '%s: reject at t_elapsed=%.6e yr, dt_yr=%.3e -- tripped: %s', + log_label, + t_elapsed, + dt_yr, + ', '.join(tripped), + ) + + # If current step gets rejected, discard the attempt and retry with a + # smaller dt_yr; hf_row was never mutated, so there is nothing to restore. + if not ok: + dt_yr *= solver.shrink + n_rejected += 1 + + if dt_yr < 1e-10: + exit_reason = 'internal step size collapsed to zero' + log.warning( + '%s: internal step size collapsed to zero at t=%.3e yr of a ' + '%.3e yr requested call; stopping early (n_steps=%d, ' + 'n_rejected=%d, last rel_changes=%r)', + log_label, + t_elapsed, + t_total_yr, + n_steps, + n_rejected, + rel_changes, + ) + break + continue + + # This substep is now confirmed ACCEPTED. Merge only the keys this + # attempt actually wrote into the real hf_row, then run any side + # effect gated on genuine acceptance. + hf_row.update(attempt.maps[0]) + + if on_accept_fn is not None: + on_accept_fn(hf_row, extra) + + # Update the elapsed time and step count, and log progress every 5000 accepted steps. + t_elapsed += dt_yr + n_steps += 1 + + if n_steps % 5000 == 0: + log.debug( + '%s: progress t_elapsed=%.6e/%.6e yr n_steps=%d n_rejected=%d dt_yr=%.3e', + log_label, + t_elapsed, + t_total_yr, + n_steps, + n_rejected, + dt_yr, + ) + + # Cumulative drift cap. + if enable_cumulative_cap: + cumulative_changes = rel_change_fn(hf_row, call_start_snapshot) + cumulative_tripped = [ + f'{key}={value:.4g}>{rel_change_limits[key]:.4g}' + for key, value in cumulative_changes.items() + if value > rel_change_limits[key] + ] + if cumulative_tripped: + exit_reason = 'cumulative drift cap reached' + log.debug( + '%s: cumulative drift cap reached at t_elapsed=%.6e/%.6e yr ' + '(n_steps=%d) -- tripped: %s. Stopping so the tidal forcing ' + 'can be refreshed before advancing further.', + log_label, + t_elapsed, + t_total_yr, + n_steps, + ', '.join(cumulative_tripped), + ) + break + + # Adaptively grow the timestep once every tracked quantity is + # comfortably inside its limit. + if all(value < 0.3 * rel_change_limits[key] for key, value in rel_changes.items()): + dt_yr = min(dt_yr * solver.growth, dt_max) + + # Log a warning if the requested total time was not fully advanced, but do not raise an exception: + # the caller may have requested a very large time step that cannot be completed in a single call, + # and the controller is designed to handle that gracefully. + if t_elapsed < t_total_yr - 1e-9: + log.warning( + '%s: only advanced %.3e of the requested %.3e yr (%d accepted / ' + '%d rejected internal steps); reason: %s', + log_label, + t_elapsed, + t_total_yr, + n_steps, + n_rejected, + exit_reason or f'hit max_substeps={solver.max_substeps}', + ) + + # Log the exit status of this call, including the final elapsed time, number of steps, and whether + # the requested total time was fully advanced. + log.debug( + '%s: EXIT model=%s t_elapsed=%.6e/%.6e yr n_steps=%d n_rejected=%d ' + 'dt_yr_final=%.3e complete=%s', + log_label, + model, + t_elapsed, + t_total_yr, + n_steps, + n_rejected, + dt_yr, + t_elapsed >= t_total_yr - 1e-9, + ) + + # Persist the controller's own step-size state for the next call. + tides_o.dt_yr = dt_yr diff --git a/src/proteus/orbit/dummy.py b/src/proteus/orbit/dummy.py index 1cc73740c..4ce18d474 100644 --- a/src/proteus/orbit/dummy.py +++ b/src/proteus/orbit/dummy.py @@ -11,8 +11,8 @@ from proteus.config import Config -def run_dummy_orbit(config: Config, interior_o: Interior_t): - """Run the dummy orbit module. +def run_dummy_tides(config: Config, interior_o: Interior_t): + """Run the dummy tidal module. Sets interior tidal heating, returns Im(k2) value from config. diff --git a/src/proteus/orbit/hansen.py b/src/proteus/orbit/hansen.py new file mode 100644 index 000000000..1b1a629a6 --- /dev/null +++ b/src/proteus/orbit/hansen.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Dict, Optional + +import numpy as np +from numpy.typing import NDArray +from scipy.fft import fft, fftshift + +log = logging.getLogger('fwl.' + __name__) + + +def nextpow2_int(x): + """Return the integer p such that 2^p >= x. + + Attributes + ---------- + x : int + Input value. + + Returns + ------- + p : int + The smallest integer p such that 2^p >= x. + """ + return int(np.ceil(np.log2(x))) if x > 0 else 0 + + +def kepler_newton(M, e): + """ + Solve Kepler's equation E - e*sin(E) = M using Newton iteration. + Valid up to e ~ 0.9, fails to converge for e > 0.9. + + Attributes + ---------- + M : array_like + Mean anomaly in radians. + e : float + Orbital eccentricity (0 <= e < 1). + + Returns + ------- + E : ndarray + Eccentric anomaly in radians, same shape as M. + """ + if e > 0.90: + log.warning(f'Eccentricity e={e:.4f} > 0.90 exceeds stable convergence bound. ') + + M = np.array(M, dtype=float) + E = np.copy(M) + + # Danby-style improved initial guess + if e > 0: + E = M + (e * np.sin(M)) / (1 - np.sin(M + e) + np.sin(M)) + + # Newton iterations + for _ in range(10): + f = E - e * np.sin(E) - M + fp = 1 - e * np.cos(E) + dE = -f / fp + E += dE + if np.max(np.abs(dE)) < 1e-13: + break + + return np.mod(E, 2 * np.pi) + + +def hansen_fft(n, m, e, kmin, kmax, N=None): + """Compute Hansen coefficients X_k^{n,m}(e) using FFT on mean anomaly. + + Attributes + ---------- + n : int + Degree of the Hansen coefficient. + m : int + Order of the Hansen coefficient. + e : float + Orbital eccentricity (0 <= e < 1). + kmin : int + Minimum k value for which to compute the coefficient. + kmax : int + Maximum k value for which to compute the coefficient. + N : int, optional + Number of points for FFT. If None, it will be chosen adaptively. + + Returns + ------- + k : ndarray + Array of k values from kmin to kmax. + Xkm : ndarray + Corresponding Hansen coefficients X_k^{n,m}(e). + """ + # Choose FFT size adaptively + if N is None: + width = max(64, 4 * (kmax - kmin + 1)) + target = width * max(8, int(np.ceil(16 / (1 - e + np.finfo(float).eps)))) + p = max(12, int(np.ceil(np.log2(target)))) + N = 2**p + else: + p = nextpow2_int(N) + N = 2**p + + # Mean anomaly grid + M = np.arange(N) * (2 * np.pi / N) + + # Solve Kepler + E = kepler_newton(M, e) + + ce = np.cos(E) + se = np.sin(E) + r_over_a = 1 - e * ce + v = np.arctan2(np.sqrt(1 - e**2) * se, ce - e) # true anomaly + + # Hansen integrand + f = (r_over_a**n) * np.exp(1j * m * v) + + # FFT, normalized like Python’s fft(f)/N + F = fftshift(fft(f.astype(complex))) / N + + k_all = np.arange(-N // 2, N // 2) + mask = (k_all >= kmin) & (k_all <= kmax) + + k = k_all[mask] + Zk = F[mask] + Xkm = np.real(Zk) + + return k, Xkm + + +@dataclass +class _HansenTable: + """Tabulated Hansen coefficients X_k^{n,m}(e), n fixed, over an + eccentricity grid and a fixed [kmin, kmax] window, for fast linear + interpolation. Built once by init_hansen_table(); never rebuilt except + via force=True.""" + + e_grid: NDArray[np.floating] + kmin: int + kmax: int + n_deg: int + values: Dict[int, NDArray[np.floating]] # m -> array[len(e_grid), kmax-kmin+1] + + +@dataclass +class _KRangeTable: + """Tabulated eccentricity-appropriate [kmin, kmax] window. Built once by + init_k_range_table(); never rebuilt except via force=True.""" + + e_grid: NDArray[np.floating] + kmin: NDArray[np.integer] + kmax: NDArray[np.integer] + + +_hansen_table: Optional[_HansenTable] = None +_k_range_table: Optional[_KRangeTable] = None + +# Default eccentricity grid shared by both tables: fine near e=0 (where +# Hansen coefficients vary fastest in relative terms) and coarser at high e. +_DEFAULT_E_GRID = np.concatenate( + [ + np.arange(0.0, 0.05, 0.005), + np.arange(0.05, 0.85, 0.01), + np.arange(0.85, 0.90, 0.005), + ] +) + + +def _select_k_range( + e: float, threshold: float = 0.001, k_search_max: int = 450, pad: int = 2 +) -> tuple[int, int]: + """Widest [kmin, kmax] (padded) such that the m=0 and m=2 Hansen + branches (the dissipative/heating-relevant ones) both have |X_k| below + `threshold` everywhere outside it.""" + lo_all, hi_all = [], [] + for m in (0, 2): + k, X = hansen_fft(-3, m, e, -k_search_max, k_search_max) + above = k[np.abs(X) >= threshold] + if len(above) == 0: + lo_all.append(-2) + hi_all.append(4) + else: + lo_all.append(above.min()) + hi_all.append(above.max()) + kmin = min(min(lo_all), -2) - pad + kmax = max(max(hi_all), 4) + pad + return int(kmin), int(kmax) + + +def init_k_range_table( + e_grid: Optional[NDArray[np.floating]] = None, force: bool = False +) -> None: + """Build the eccentricity -> [kmin, kmax] lookup table once. + + Safe to call more than once: a no-op unless `force=True`, so callers + don't need to track whether this has already run. + """ + global _k_range_table + if _k_range_table is not None and not force: + return + + e_grid = _DEFAULT_E_GRID if e_grid is None else np.asarray(e_grid, dtype=float) + kmins = np.empty(len(e_grid), dtype=int) + kmaxs = np.empty(len(e_grid), dtype=int) + for i, e in enumerate(e_grid): + kmins[i], kmaxs[i] = _select_k_range(e) + _k_range_table = _KRangeTable(e_grid=e_grid, kmin=kmins, kmax=kmaxs) + log.info( + f'k-range table built: {len(e_grid)} grid points' + f'(e in [{e_grid.min():.3f}, {e_grid.max():.3f}], ' + f'n_modes in [{(kmaxs - kmins + 1).min()}, {(kmaxs - kmins + 1).max()}])' + ) + + +def kmin_kmax_for_e(e: float) -> tuple[int, int]: + """Eccentricity-appropriate [kmin, kmax] window. Lazily builds the + lookup table (with default settings) on first use if it hasn't been + built yet, so this is safe to call without any setup step.""" + if _k_range_table is None: + init_k_range_table() + table = _k_range_table + e = min(max(e, 0.0), table.e_grid[-1]) + idx = np.searchsorted(table.e_grid, e, side='right') - 1 + idx = min(max(idx, 0), len(table.e_grid) - 1) + return int(table.kmin[idx]), int(table.kmax[idx]) + + +def padded_k_range_for_evection( + e_now: float, + de_dt_yr: float, + dt_next_yr: float, + padding_factor: float = 1.0, + e_cap: float | None = None, +) -> tuple[int, int]: + """[kmin, kmax] appropriate for where eccentricity is headed over the + NEXT macro-step, not just where it is right now.""" + if e_cap is None: + e_cap = float(_DEFAULT_E_GRID[-1]) + + e_pad = min( + e_cap, + max(0.0, e_now) + abs(padding_factor) * abs(de_dt_yr) * max(dt_next_yr, 0.0), + ) + return kmin_kmax_for_e(e_pad) + + +def init_hansen_table( + e_grid: Optional[NDArray[np.floating]] = None, + kmin: Optional[int] = None, + kmax: Optional[int] = None, + n_deg: int = 2, + force: bool = False, +) -> None: + """Build the Hansen-coefficient value table once, over `e_grid` and + [kmin, kmax]. + + Safe to call more than once: a no-op unless `force=True`. If kmin/kmax + are not given, they are derived from the k-range table's own realized + bounds (building it first if needed) -- this keeps the two tables + consistent by construction rather than by a hand-picked guess. + """ + global _hansen_table + if _hansen_table is not None and not force: + return + + if kmin is None or kmax is None: + init_k_range_table() + kmin = int(_k_range_table.kmin.min()) if kmin is None else kmin + kmax = int(_k_range_table.kmax.max()) if kmax is None else kmax + + e_grid = _DEFAULT_E_GRID if e_grid is None else np.asarray(e_grid, dtype=float) + n_k = kmax - kmin + 1 + values = {m: np.zeros((len(e_grid), n_k)) for m in range(-n_deg, n_deg + 1)} + + for i, e in enumerate(e_grid): + for m in range(-n_deg, n_deg + 1): + _, X = hansen_fft(-(n_deg + 1), m, e, kmin, kmax) + values[m][i, :] = X + _hansen_table = _HansenTable( + e_grid=e_grid, kmin=kmin, kmax=kmax, n_deg=n_deg, values=values + ) + log.info( + f'Hansen table built: {len(e_grid)} e-points x {n_k} k-modes x ' + f'{2 * n_deg + 1} m-branches' + ) + + +def get_all_m_hansen(e: float, n_deg: int, kmin: int, kmax: int): + """Hansen coefficients X_k^{n,m}(e) for all m = -n_deg..n_deg, by linear + interpolation over the pre-tabulated values, sliced to [kmin, kmax]. + + Lazily builds the table (with default settings) on first call if it + hasn't been built yet -- this is what guarantees the expensive FFT + sweep runs exactly once per process regardless of whether any setup + code remembers to call init_hansen_table() explicitly: the first call + (from wherever it happens to come) pays the one-time cost, and this + function is called often (once per right-hand-side evaluation), so + every call after that is a cheap array lookup, not a recomputation. + + Returns + ------- + k_range : ndarray + Array of k values from kmin to kmax. + results : dict + m -> ndarray of Hansen coefficients X_k^{n_deg,m}(e), same shape as k_range. + """ + if _hansen_table is None: + init_hansen_table(n_deg=n_deg) + table = _hansen_table + + if kmin < table.kmin or kmax > table.kmax: + log.warning( + f'Requested k-range [{kmin}, {kmax}] exceeds pre-tabulated ' + f'[{table.kmin}, {table.kmax}]; results will be truncated.' + ) + kmin = max(kmin, table.kmin) + kmax = min(kmax, table.kmax) + + e = min(max(e, 0.0), table.e_grid[-1]) + idx = np.searchsorted(table.e_grid, e, side='right') - 1 + idx = min(max(idx, 0), len(table.e_grid) - 2) + e0, e1 = table.e_grid[idx], table.e_grid[idx + 1] + w = 0.0 if e1 == e0 else (e - e0) / (e1 - e0) + + lo = kmin - table.kmin + hi = kmax - table.kmin + 1 + k_range = np.arange(kmin, kmax + 1) + results = { + m: (1.0 - w) * values[idx, lo:hi] + w * values[idx + 1, lo:hi] + for m, values in table.values.items() + } + return k_range, results diff --git a/src/proteus/orbit/lovepy.py b/src/proteus/orbit/lovepy.py index 0d011e376..f5c774754 100644 --- a/src/proteus/orbit/lovepy.py +++ b/src/proteus/orbit/lovepy.py @@ -9,31 +9,66 @@ from juliacall import Main as jl from proteus.interior_energetics.common import Interior_t +from proteus.orbit.common import Tides_t from proteus.utils.helper import UpdateStatusfile +from proteus.utils.julia_common import make_julia_converters if TYPE_CHECKING: from proteus.config import Config log = logging.getLogger('fwl.' + __name__) +# LovePy-precision-bound converters +_jlarr, _, _jlsca = make_julia_converters('LovePy') + def import_lovepy(): log.debug('Import lovepy...') jl.seval('using LovePy') -def _jlarr(arr: np.array): - # Make copy of array, reverse order, and convert to Julia type - cop = np.array(arr, copy=True, dtype=float).flatten() - return juliacall.convert(jl.Array[jl.LovePy.prec, 1], cop) +def store_lovepy_tides(omega: float, imk2: float, config: Config, tides_o: Tides_t): + """Store LovePy's hardcoded tidal modes (n,m,k), forcing frequency, and + Im(k2) in tides_o, so the legacy LovePy module is compatible with + `sp1d`, `ps1d`, and `ps1d_evec`. LovePy itself assumes e<<1 and spin-orbit + synchronisation, which the 1d orbit models do not require (see "Tidal + response modules" in docs/Explanations/orbit.md). + + Parameters + ---------- + omega: float + Angular frequency of rotation + imk2: float + Imaginary part of k2 love number + tides_o: Tides_t + Struct containing tidal arrays at current time. + """ + omega = float(omega) + imk2 = float(imk2) -def _jlsca(sca: float): - # Make a copy of a scalar, and convert to Julia type - return juliacall.convert(jl.LovePy.prec, sca) + sign = np.sign(omega) + LN = -sign * 1j * np.abs(imk2) + # Collect tidal mode information + # Note that these modes are hardcoded into Lovepy. + nmk = np.array(([2, 0, 1], [2, 2, 1], [2, 2, 3]), dtype=int) + # Note we only have acces to the imaginary part of k2, so we set the real part to 0.0. + LNk = np.array((-LN, LN, -LN), dtype=complex) + # Note we consistently drop the minus sign on the East/West ward component of the + # forcing frequency and the imaginary part of the k2 love number. + sigma = np.array((-omega, omega, -omega), dtype=float) -def run_lovepy(hf_row: dict, dirs: dict, interior_o: Interior_t, config: Config) -> float: + # Store tidal mode information in tides_o object + storage = tides_o.add(primary='planet', perturber=config.orbit.perturber) + storage.nmk = nmk + storage.sigma = sigma + storage.LNk = LNk + + +def run_lovepy( + hf_row: dict, dirs: dict, interior_o: Interior_t, tides_o: Tides_t, config: Config +) -> float: """Run the lovepy tidal heating module. Sets the interior tidal heating and returns Im(k2) love number. @@ -46,6 +81,8 @@ def run_lovepy(hf_row: dict, dirs: dict, interior_o: Interior_t, config: Config) Dictionary of directories. interior_o: Interior_t Struct containing interior arrays at current time. + tides_o: Tides_t + Struct containing tidal arrays at current time. config: Config PROTEUS config object Returns @@ -53,9 +90,30 @@ def run_lovepy(hf_row: dict, dirs: dict, interior_o: Interior_t, config: Config) Imk2_love: float """ - # Calculate angular frequency of rotation - omega = _jlsca(2 * np.pi / hf_row['orbital_period']) - ecc = _jlsca(hf_row['eccentricity']) + if config.orbit.perturber == 'star': + log.debug('Running Lovepy for star-planet tides...') + + # Calculate orbital frequency of rotation + omega = _jlsca(2 * np.pi / hf_row['orbital_period']) + + # Convert planet-star orbital eccentricity + ecc = _jlsca(hf_row['eccentricity']) + + elif config.orbit.perturber == 'satellite': + log.debug('Running Lovepy for satellite-planet tides...') + + # Calculate orbital frequency of rotation + omega = _jlsca(2 * np.pi / hf_row['orbital_period_sat']) + + # Convert planet-satellite orbital eccentricity + ecc = _jlsca(hf_row['eccentricity_sat']) + + else: + UpdateStatusfile(dirs, 26) + raise ValueError( + f"run_lovepy requires config.orbit.perturber to be 'star' or 'satellite', " + f'got {config.orbit.perturber!r}' + ) # Copy arrays arr_keys = ('density', 'visc', 'shear', 'bulk', 'mass', 'radius') @@ -71,6 +129,8 @@ def run_lovepy(hf_row: dict, dirs: dict, interior_o: Interior_t, config: Config) i_top = 0 # index of topmost cell which has visc>visc_thresh if config.interior_energetics.module in ('dummy', 'boundary'): if lov['visc'][0] < config.orbit.lovepy.visc_thresh: + # Store empty tidal mode information + store_lovepy_tides(omega, 0.0, config, tides_o) return 0.0 # Construct arrays for lovepy (we need two cells, three edges here) @@ -91,6 +151,8 @@ def run_lovepy(hf_row: dict, dirs: dict, interior_o: Interior_t, config: Config) # fully liquid if i_top <= 1: + # Store empty tidal mode information + store_lovepy_tides(omega, 0.0, config, tides_o) return 0.0 # Construct arrays for lovepy @@ -138,5 +200,8 @@ def run_lovepy(hf_row: dict, dirs: dict, interior_o: Interior_t, config: Config) power_blk /= np.sum(lov['mass']) log.debug(' power from bulk calc: %.3e W kg-1' % power_blk) + # Store tidal mode information + store_lovepy_tides(omega, float(Imk2), config, tides_o) + # Return imaginary part of k2 love number return float(Imk2) diff --git a/src/proteus/orbit/obliqua.py b/src/proteus/orbit/obliqua.py new file mode 100644 index 000000000..974f20a66 --- /dev/null +++ b/src/proteus/orbit/obliqua.py @@ -0,0 +1,627 @@ +# Obliqua tidal heating module +from __future__ import annotations + +import json +import logging +import os +from typing import TYPE_CHECKING + +import juliacall +import netCDF4 as nc +import numpy as np +from attrs import asdict +from juliacall import Main as jl +from scipy.interpolate import interp1d + +from proteus.interior_energetics.common import Interior_t +from proteus.orbit.common import Tides_t +from proteus.orbit.hansen import padded_k_range_for_evection +from proteus.utils.helper import UpdateStatusfile +from proteus.utils.julia_common import make_julia_converters, make_log_syncer, to_julia_dict + +# Obliqua-precision-bound converters +_jlarr, _jlsca_float, _jlsca_prec = make_julia_converters('Obliqua') + +if TYPE_CHECKING: + from proteus.config import Config + +log = logging.getLogger('fwl.' + __name__) + +Obliqua_LOGFILE_NAME = 'obliqua_recent.log' + + +def import_obliqua(dirs: dict): + """Activate Obliqua's own cloned-and-instantiated Julia project + (``dirs['obliqua']``, populated by ``tools/get_obliqua.sh``) and import + it into the shared juliacall session. + """ + log.debug('Import Obliqua...') + jl.seval('using Pkg') + jl.Pkg.activate(dirs['obliqua']) + jl.seval('using Obliqua') + + +def _padded_obliqua_k_range( + hf_row: dict, interior_o: Interior_t, tides_o: Tides_t, config: Config +) -> tuple: + """(s_min, s_max) to pass to Obliqua's 'adaptive' spectrum for the + satellite-perturber (evection) case. + + An explicit user-supplied ``k_min``/``k_max`` (an int, not ``'none'``) + is only ever WIDENED by the padding, never narrowed past what the user + configured. + """ + k_min_cfg = config.orbit.obliqua.k_min + k_max_cfg = config.orbit.obliqua.k_max + + # In/near evection band, as judged by evolve_orbit_satellite at the end + # of its own last call -- internal-only orbit state (see Tides_t's own + # docstring), not exported to hf_row. + if not tides_o.evection_zone_active: + return k_min_cfg, k_max_cfg + + # Padding factor for the look-ahead window + padding_factor = float(config.orbit.obliqua.evection_padding_factor) + if padding_factor <= 0.0: + return k_min_cfg, k_max_cfg + + # Compute the rate of change of eccentricity (de/dt) over the last macro-step + e_now = float(hf_row['eccentricity_sat']) + t_now = float(hf_row['Time']) + e_prev = hf_row.get('_obliqua_prev_ecc') + t_prev = hf_row.get('_obliqua_prev_time') + + de_dt_yr = 0.0 + if e_prev is not None and t_prev is not None and t_now > t_prev: + de_dt_yr = (e_now - float(e_prev)) / (t_now - float(t_prev)) + + # Persist the cursor for the next call + hf_row['_obliqua_prev_ecc'] = e_now + hf_row['_obliqua_prev_time'] = t_now + + dt_next_yr = float(getattr(interior_o, 'dt', 0.0)) + + # Compute the padded k-range for the next macro-step + k_min_pad, k_max_pad = padded_k_range_for_evection( + e_now, de_dt_yr, dt_next_yr, padding_factor=padding_factor + ) + + # Enforce user-supplied k_min/k_max + if isinstance(k_min_cfg, int): + k_min_pad = min(k_min_pad, k_min_cfg) + if isinstance(k_max_cfg, int): + k_max_pad = max(k_max_pad, k_max_cfg) + + return int(k_min_pad), int(k_max_pad) + + +# Config fields that must NOT pass straight through into Obliqua's cfg dict: +# k_min/k_max are PROTEUS-side inputs to the adaptive s_min/s_max window +# computed per call (see run_obliqua/lookup_from_interior, each of which sets +# its own s_min/s_max or k_min/k_max explicitly); evection_padding_factor and +# verbosity are PROTEUS-side bookkeeping Obliqua itself never reads. +_OBLIQUA_CFG_EXCLUDE = ('k_min', 'k_max', 'evection_padding_factor', 'verbosity') + + +def _obliqua_module_cfg(config: Config) -> dict: + """Build the ``cfg['orbit']['obliqua']`` sub-dict from + ``config.orbit.obliqua`` dynamically (via ``attrs.asdict``), so a new + config field flows through without this function needing an update. + Excludes PROTEUS-only bookkeeping (``_OBLIQUA_CFG_EXCLUDE``) and patches + in ``visc_l``/``visc_s`` (from the interior's own log10-viscosity) and + ``fluid.sigma_R_inf`` (Obliqua's name for ``sigma_R_factor * + sigma_R``). Callers still set their own ``s_min``/``s_max`` (or + ``k_min``/``k_max``) and any call-specific keys (``spectrum``, + ``store_3D``, ...). + """ + obliqua_cfg = asdict(config.orbit.obliqua) + for key in _OBLIQUA_CFG_EXCLUDE: + obliqua_cfg.pop(key, None) + + obliqua_cfg['visc_l'] = 10**config.interior_energetics.melt_log10visc + obliqua_cfg['visc_s'] = 10**config.interior_energetics.solid_log10visc + + fluid = obliqua_cfg['fluid'] + fluid['sigma_R_inf'] = fluid.pop('sigma_R_factor') * fluid['sigma_R'] + + return obliqua_cfg + + +def run_obliqua( + hf_row: dict, dirs: dict, interior_o: Interior_t, tides_o: Tides_t, config: Config +) -> float: + """Run the Obliqua tidal heating module. + + Sets the interior tidal heating and returns k-love number. All tidal love-numbers + are stored in the tides_o object for the specific perturber. + + For the satellite perturber, the adaptive k-range window handed to + Obliqua is padded ahead of where eccentricity is headed over the next + macro-step while ``tides_o.evection_zone_active`` is set -- see + ``_padded_obliqua_k_range``. + + Parameters + ---------- + hf_row : dict + Dictionary of current runtime variables + dirs: dict + Dictionary of directories. + interior_o: Interior_t + Struct containing interior arrays at current time. + tides_o: Tides_t + Struct containing tidal arrays at current time. + config: Config + PROTEUS config object + Returns + ---------- + Imk: float + Averaged imaginary part of the k love numbers. + """ + + # Calculate axial frequency of rotation + axial = _jlsca_prec(2 * np.pi / hf_row['axial_period']) + + # Adaptive k-range window passed to Obliqua below + s_min_eff = config.orbit.obliqua.k_min + s_max_eff = config.orbit.obliqua.k_max + + if config.orbit.perturber == 'star': + log.debug('Running Obliqua for star-planet tides...') + + # Calculate orbital frequency of rotation + omega = _jlsca_prec(2 * np.pi / hf_row['orbital_period']) + + # Convert planet-star orbital eccentricity, semi-major axis, and mass + ecc = _jlsca_float(hf_row['eccentricity']) + sma = _jlsca_float(hf_row['semimajorax']) + M_pert = _jlsca_float(hf_row['M_star']) + + elif config.orbit.perturber == 'satellite': + log.debug('Running Obliqua for satellite-planet tides...') + + # Calculate orbital frequency of rotation + omega = _jlsca_prec(2 * np.pi / hf_row['orbital_period_sat']) + + # Convert planet-satellite orbital eccentricity, semi-major axis, and mass + ecc = _jlsca_float(hf_row['eccentricity_sat']) + sma = _jlsca_float(hf_row['semimajorax_sat']) + M_pert = _jlsca_float(hf_row['M_sat']) + + # Compute the padded k-range for the next macro-step if in/near evection band + s_min_eff, s_max_eff = _padded_obliqua_k_range(hf_row, interior_o, tides_o, config) + + else: + UpdateStatusfile(dirs, 26) + raise ValueError( + f"run_obliqua requires config.orbit.perturber to be 'star' or 'satellite', " + f'got {config.orbit.perturber!r}' + ) + + # Copy arrays + arr_keys = ('density', 'visc', 'shear', 'bulk', 'phi', 'mass', 'radius') + lov = {k: np.array(getattr(interior_o, k), copy=True, dtype=float) for k in arr_keys} + + # Reverse arrays if using SPIDER + # Such that i=0 is at the CMB + if config.interior_energetics.module == 'spider': + for k in arr_keys: + lov[k] = lov[k][::-1] + + if config.interior_energetics.module == 'dummy': + # Construct arrays for obliqua (we need two cells, three edges here) + for k in arr_keys: + if k == 'radius': + rmid = np.median(lov['radius']) + arr = [lov['radius'][0], rmid, lov['radius'][1]] + lov[k] = _jlarr(arr[:]) + else: + arr = [lov[k][0], lov[k][0]] + lov[k] = _jlarr(arr[:]) + + else: + # for spider/aragog + # Construct arrays for obliqua using the full interior profile. + # Obliqua's solid/mushy/fluid multi-phase model resolves the + # liquid/solid transition internally, so no viscosity-based + # cutoff is needed here (unlike LovePy's single-phase model). + n_lev = interior_o.nlev_s + for k in arr_keys: + if k == 'radius': + i = n_lev + 1 + else: + i = n_lev + lov[k] = _jlarr(lov[k][:i]) + + # Determine the spectrum type to use for this call, `legacy` mimics `lovepy` and + # assumes spin-orbit synchronization with eccentricity << 1. + spectrum = 'legacy' if config.orbit.star_planet_model == 'sp0d' else 'adaptive' + + # Create configuration dictionary for Obliqua + cfg = { + 'title': 'PROTEUS_run_' + str(round(hf_row['Time'])), + 'params': { + 'out': { + 'path': dirs['output/data'], + 'time': round(hf_row['Time']), + }, + }, + 'orbit': { + 'obliqua': { + **_obliqua_module_cfg(config), + 'spectrum': spectrum, + 's_min': s_min_eff, + 's_max': s_max_eff, + }, + }, + 'interior_energetics': { + 'grain_size': config.interior_energetics.grain_size, + }, + 'struct': { + 'core_density': hf_row.get('core_density', config.interior_struct.core_density), + 'core_shear': config.interior_energetics.boundary.core_shear, + 'core_bulk': config.interior_energetics.boundary.core_bulk, + }, + } + + cfg = to_julia_dict(cfg) + + # Calculate heating using obliqua + try: + # Extract arrays + rho = lov['density'] + radius = lov['radius'] + visc = lov['visc'] + shear = lov['shear'] + bulk = lov['bulk'] + phi = lov['phi'] + + # Add permeability and drained bulk modulus and limit porosity + perm = jl.Obliqua.interior.get_permeability(phi, cfg) + perm, phi = jl.Obliqua.interior.limit_porosity(perm, phi, cfg) + bulkd = jl.Obliqua.interior.get_drained_bulk(bulk, phi, cfg) + + # Run Obliqua to get tidal heating profile and love number + power_prf, power_blk, nmk, sigma, LNk = jl.Obliqua.run_tides( + omega, + axial, + ecc, + sma, + M_pert, + rho, + radius, + visc, + shear, + bulk, + bulkd, + phi, + perm, + cfg, + ) + + except juliacall.JuliaError as e: + UpdateStatusfile(dirs, 26) + log.error(e) + raise RuntimeError('Encountered problem when running Obliqua module') + + if config.interior_energetics.module == 'dummy': + interior_o.tides[0] = power_prf[1] + + else: + # Store result, flipping for SPIDER + if config.interior_energetics.module == 'spider': + interior_o.tides[:] = power_prf[::-1] + else: + interior_o.tides[:] = power_prf[:] + + # Verify result against bulk calculation + power_blk /= np.sum(lov['mass']) + log.debug(' power from bulk calc: %.3e W kg-1' % power_blk) + + # Store results in tides_o structure + storage = tides_o.add(primary='planet', perturber=config.orbit.perturber) + storage.nmk = np.vstack(nmk).astype(int) + storage.sigma = np.asarray(sigma, dtype=float) + storage.LNk = np.asarray(LNk, dtype=complex) + + # Logging + sync_log_files(dirs['output']) + + # Return the mean of the absolute value of the imaginary part of the k love numbers, + # with a sign consistent with omega. This is purely for `sp0d` and reflects `lovepy`. + return -np.sign(float(omega)) * np.mean(np.abs(np.imag(storage.LNk))) + + +def lookup_from_interior(dirs: dict, config: Config): + """Run the Obliqua tidal heating module for a simple 0-D interior model. + + Constructs the full k-love number spectrum lookup table. + + Parameters + ---------- + dirs: dict + Dictionary of directories. + config: Config + PROTEUS config object + """ + + log.info('Running Obliqua for lookup table generation...') + + # Read interior arrays from json file + file_path = config.orbit.satellite.love_number_sat + + if not file_path: + UpdateStatusfile(dirs, 26) + raise ValueError( + 'Satellite tidal data file path (`config.orbit.satellite.love_number_sat`) is not specified.' + ) + + # read interior arrays from json file + with open(file_path) as f: + d = json.load(f) + + # Convert orbital parameters to Julia types (not used for lookup table generation, but required by Obliqua) + omega = _jlsca_prec(d['omega']) + axial = _jlsca_prec(d['axial']) + ecc = _jlsca_float(d['ecc']) + sma = _jlsca_float(d['sma']) + M_pert = _jlsca_float(d['S_mass']) + + # Extract the iron core density from interior density profile, and convert the rest to a Julia array + density_full = np.array(d['density'], copy=True, dtype=float) + core_density = density_full[0] + rho = _jlarr(density_full[1:]) + + radius = _jlarr(np.array(d['radius'], copy=True, dtype=float)) + visc = _jlarr(np.array(d['visc'], copy=True, dtype=float)) + shear = _jlarr(np.array(d['shear'], copy=True, dtype=float)) + bulk = _jlarr(np.array(d['bulk'], copy=True, dtype=float)) + phi = _jlarr(np.array(d['phi'], copy=True, dtype=float)) + + # Create configuration dictionary for Obliqua + cfg = { + 'title': 'Lookup_table', + 'params': { + 'out': { + 'path': dirs['output/data'], + 'time': round(0.0), # store the Obliqua output file at t=0 to avoid + }, # overwriting the main PROTEUS+Obliqua output file + }, + 'orbit': { + 'obliqua': { + **_obliqua_module_cfg(config), + 'store_3D': False, + 'enforce_ec': False, + 'optimize_scales': False, + 'solid_shell': False, + 'spectrum': 'full', # full for lookup table generation + 'N_sigma': 100, # number of frequency points for the lookup table + 'p_min': -8, # Minimum period for orbital and axial frequencies [log(kyr)] + 'p_max': 4, # Maximum period for orbital and axial frequencies [log(kyr)] + 'k_min': 'none', # not used for lookup table generation, k = 1 for all samples + 'k_max': 'none', # not used for lookup table generation, k = 1 for all samples + # use 0-D modules for lookup table generation, since we are only interested in + # the love number spectrum for an unresolved interior structure. + 'module_solid': 'solid0d', + 'module_mushy': 'none', + 'module_fluid': 'fluid0d', + }, + }, + # Not used: + 'interior_energetics': { + 'grain_size': config.interior_energetics.grain_size, + }, + # Used: + 'struct': { + 'core_density': core_density, # used for density contrast in the fluid0d module + 'core_shear': config.interior_energetics.boundary.core_shear, # not used + 'core_bulk': config.interior_energetics.boundary.core_bulk, # not used + }, + } + + cfg = to_julia_dict(cfg) + + # Calculate heating using obliqua + try: + # Add permeability and drained bulk modulus and limit porosity + perm = jl.Obliqua.interior.get_permeability(phi, cfg) + perm, phi = jl.Obliqua.interior.limit_porosity(perm, phi, cfg) + bulkd = jl.Obliqua.interior.get_drained_bulk(bulk, phi, cfg) + + # Run Obliqua to get tidal heating profile and love number + power_prf, power_blk, nmk, sigma, LNk = jl.Obliqua.run_tides( + omega, + axial, + ecc, + sma, + M_pert, + rho, + radius, + visc, + shear, + bulk, + bulkd, + phi, + perm, + cfg, + ) + + except juliacall.JuliaError as e: + UpdateStatusfile(dirs, 26) + log.error(e) + raise RuntimeError('Encountered problem when running Obliqua module') + + # Store lookup table in netcdf file + nc_path = os.path.join(dirs['output/data'], 'sat_tides.nc') + with nc.Dataset(nc_path, 'w', format='NETCDF4') as ds: + N = len(nmk) + + ds.createDimension('mode', N) + + # Ensure elements are extracted cleanly from Julia objects to standard Python/NumPy types + nmk_rows = [[int(mode[0]), int(mode[1]), int(mode[2])] for mode in nmk] + nmk_np = np.array(nmk_rows, dtype=np.int64) + + n_vals = nmk_np[:, 0] + m_vals = nmk_np[:, 1] + k_vals = nmk_np[:, 2] + + v_n = ds.createVariable('n', 'i4', ('mode',)) + v_m = ds.createVariable('m', 'i4', ('mode',)) + v_k = ds.createVariable('k', 'i4', ('mode',)) + v_sigma = ds.createVariable('sigma', 'f8', ('mode',)) + v_LNk_r = ds.createVariable('LNk_real', 'f8', ('mode',)) + v_LNk_i = ds.createVariable('LNk_imag', 'f8', ('mode',)) + + v_n[:] = n_vals + v_m[:] = m_vals + v_k[:] = k_vals + v_sigma[:] = np.array(sigma, dtype=np.float64) + v_LNk_r[:] = np.real(np.array(LNk, dtype=np.complex128)) + v_LNk_i[:] = np.imag(np.array(LNk, dtype=np.complex128)) + + +def LN_from_lookup(hf_row: dict, dirs: dict, tides_o: Tides_t, config: Config): + """Extract and populate Love numbers for a satellite from a pre-generated lookup table. + + Since Hansen coefficients dictate which modes are relevant for tidal forcing and depend + only on orbital eccentricity, this function interpolates/matches the Love numbers (LNk) + for relevant modes from an Obliqua lookup file spanning a broad frequency range. + + Parameters + ---------- + hf_row : dict + Dictionary of current runtime variables + dirs: dict + Dictionary of directories. + tides_o: Tides_t + Struct containing tidal arrays at current time. + config: Config + PROTEUS config object + """ + # Fetch relevant modes from planet-side forcing + nmk_p = np.asarray(tides_o.get(primary='planet', perturber='satellite').nmk) + + # Vectorized calculation of forcing frequencies (sigma_s = m * omega_rot - k * omega_orb) + axial_freq_s = 2.0 * np.pi / hf_row['axial_period_sat'] + orbit_freq_s = 2.0 * np.pi / hf_row['orbital_period_sat'] + + # nmk_p[:, 1] is 'm', nmk_p[:, 2] is 'k' + sigma_s = nmk_p[:, 1] * axial_freq_s - nmk_p[:, 2] * orbit_freq_s + + # Retrieve or load satellite lookup data + try: + lookup = tides_o.get(primary='satellite_dict', perturber='planet') + except KeyError: + file_path = config.orbit.satellite.love_number_sat + + # Check if the file path is specified + if not file_path: + UpdateStatusfile(dirs, 26) + raise ValueError( + 'Satellite tidal data file path (`config.orbit.satellite.love_number_sat`) is not specified.' + ) + + # check the file extension to determine how to read the lookup data or generate it if it doesn't exist + if file_path.endswith('.nc'): + # Read the netcdf file + pass + elif file_path.endswith('.json'): + # Generate the lookup data (sat_tides.nc) from the interior json file + lookup_from_interior(dirs, config) + # Update the file path to point to the newly generated netcdf file + file_path = os.path.join(dirs['output/data'], 'sat_tides.nc') + + tides_o.add_from_file(primary='satellite_dict', perturber='planet', file_path=file_path) + + lookup = tides_o.get(primary='satellite_dict', perturber='planet') + + # Extract lookup arrays + sigma_lookup = np.asarray(lookup.sigma) + nmk_lookup = np.asarray(lookup.nmk) + LNk_lookup = np.asarray(lookup.LNk) + + # Interpolate Love numbers degree-by-degree + LNk_s = np.zeros(len(nmk_p), dtype=complex) + + for n in np.unique(nmk_p[:, 0]): + # Lookup entries for this degree + lookup_mask = nmk_lookup[:, 0] == n + + if not np.any(lookup_mask): + UpdateStatusfile(dirs, 26) + raise ValueError(f'Lookup table does not contain degree n = {int(n)}.') + + sigma_n = sigma_lookup[lookup_mask] + LNk_n = LNk_lookup[lookup_mask] + + # Create symmetric lookup + sigma_sym = np.concatenate((-sigma_n[::-1], sigma_n)) + LNk_sym = np.concatenate((np.conjugate(LNk_n[::-1]), LNk_n)) + + # Sort for interpolation + order = np.argsort(sigma_sym) + sigma_sym = sigma_sym[order] + LNk_sym = LNk_sym[order] + + interp_real = interp1d( + sigma_sym, + LNk_sym.real, + kind='linear', + bounds_error=False, + fill_value='extrapolate', + ) + + interp_imag = interp1d( + sigma_sym, + LNk_sym.imag, + kind='linear', + bounds_error=False, + fill_value='extrapolate', + ) + + # Modes requiring this degree + mode_mask = nmk_p[:, 0] == n + + sigma_eval = sigma_s[mode_mask] + + LNk_s[mode_mask] = interp_real(sigma_eval) + 1j * interp_imag(sigma_eval) + + # Enforce normalization + zero_mask = (nmk_p[:, 1] < 0) & (nmk_p[:, 2] < 0) + LNk_s[zero_mask] = 0.0 + 0.0j + + # Store results + storage = tides_o.add(primary='satellite', perturber='planet') + storage.nmk = nmk_p + storage.sigma = sigma_s + storage.LNk = LNk_s + + +def read_ncdf(fpath: str): + out = {} + ds = nc.Dataset(fpath) + + for key in ds.variables.keys(): + out[key] = ds.variables[key][:] + + ds.close() + return out + + +def read_ncdfs(output_dir: str, times: list): + return [read_ncdf(os.path.join(output_dir, 'data', '%d_obliqua.nc' % t)) for t in times] + + +def setup_logging(dirs: dict, verbosity: int): + # Setup logging from Obliqua + # This handle will be kept open throughout the PROTEUS simulation, so the file + # should not be deleted at runtime. However, it will be emptied when appropriate. + logpath = os.path.join(dirs['output'], Obliqua_LOGFILE_NAME) + jl.Obliqua.setup_logging(logpath, verbosity) + + log.debug("Obliqua will log to '%s'" % logpath) + + +# Bound to Obliqua's own recent-run logfile name -- see make_log_syncer's +# docstring; agni.py binds the same factory to its own AGNI_LOGFILE_NAME. +sync_log_files = make_log_syncer(Obliqua_LOGFILE_NAME) diff --git a/src/proteus/orbit/orbit.py b/src/proteus/orbit/orbit.py index e9f62a150..7c7e92a01 100644 --- a/src/proteus/orbit/orbit.py +++ b/src/proteus/orbit/orbit.py @@ -4,9 +4,14 @@ import logging from typing import TYPE_CHECKING +import numpy as np from scipy.integrate import solve_ivp -from proteus.utils.constants import AU, const_G +from proteus.interior_energetics.common import Interior_t +from proteus.orbit.common import Tides_t, kmin_kmax_for_m0_mirror, run_adaptive_orbit_substeps +from proteus.orbit.hansen import get_all_m_hansen +from proteus.utils.constants import const_G, secs_per_year +from proteus.utils.helper import UpdateStatusfile if TYPE_CHECKING: from proteus.config import Config @@ -14,56 +19,152 @@ log = logging.getLogger('fwl.' + __name__) -def de_dt(a, e, params): +def _state_is_valid_star(hf_row): + """Reject a substep whose resulting state is unphysical or non-finite: + the planet spiralling into the star (a <= 1.05 R_star), eccentricity + outside [0, 0.999), or a non-finite spin period. Mirrors + ``satellite._state_is_valid``; used by the adaptive substep controller. """ - ODE describing evolution of orbital eccentricity based on Eq. 16 of - Driscoll and Barnes (2015), Astrobiology 15, 739 (DOI 10.1089/ast.2015.1325). - - Sign convention note: in the paper, Im(k2) is negative for tidal - dissipation (Eq. 4 expresses -Im(k2) as the positive dissipation - efficiency). The current PROTEUS callers (dummy and lovepy backends) - feed a positive Imk2, which under the formula below produces a - positive de/dt and so EXPANDS the orbit rather than circularizing it. - The paper convention would require Imk2 < 0 to obtain the physical - circularization direction. Treat the sign as a known science item; - do not invert it without first checking every Imk2 producer - (proteus.orbit.dummy, proteus.orbit.lovepy, and any Imk2-dependent - test) so the change propagates consistently. - """ - Imk2, Mst, G, Rpl, Mpl = params - return (21 / 2) * Imk2 * Mst**1.5 * G**0.5 * Rpl**5 / (Mpl * a**6.5) * e + a = hf_row.get('semimajorax', np.nan) + e = hf_row.get('eccentricity', 0.0) + if not np.isfinite(a) or a <= 1.05 * hf_row.get('R_star', 0.0): + return False + if not np.isfinite(e) or e < 0.0 or e >= 0.999: + return False + axp = hf_row.get('axial_period') + if axp is not None and not np.isfinite(axp): + return False + return True -def da_dt(a, e, params): - """ - ODE describing evolution of semimajor axis based on Eq. 15 of - Driscoll and Barnes (2015), Astrobiology 15, 739. +def evolve_orbit_star( + hf_row: dict, config: Config, dirs: dict, tides_o: Tides_t, interior_o: Interior_t +): + """Evolve the planet's orbital parameters by interior_o.dt of physical + time. Dispatches to the requested star-planet model (sp0d, sp1d); sp1d + goes through the shared adaptive-substep controller (see + docs/Explanations/orbit.md). + + Parameters + ---------- + hf_row : dict + Dictionary of current runtime variables + config : Config + Configuration options + tides_o : Tides_t + Tides object containing tidal interactions + interior_o : Interior_t + Interior object; interior_o.dt is the requested total elapsed + time in years for this call """ - return 2 * a * e * de_dt(a, e, params) + model = config.orbit.star_planet_model + solver = config.orbit.solver + if model == 'sp0d': + sp0d(hf_row, interior_o.dt, config) + return -def orbitals(t, z, params): - """ - Helper function for solving coupled ODEs. - """ - a, e = z - return [da_dt(a, e, params), de_dt(a, e, params)] + elif model == 'sp1d': + + def step_fn(hf_row, dt_yr, t_elapsed_yr): + sp1d(hf_row, tides_o, dt_yr, config) + return None + + needs_c_planet = True + + else: + UpdateStatusfile(dirs, 26) + raise ValueError(f'unrecognised star_planet_model: {model!r}') + + def rel_change_fn(attempt, hf_row): + # A relative-change ratio needs a finite, nonzero prior value. + with np.errstate(divide='ignore', invalid='ignore'): + a_prev = hf_row.get('semimajorax', np.nan) + da = np.divide(abs(attempt['semimajorax'] - a_prev), a_prev) + + e_prev = hf_row.get('eccentricity', 0.0) + e_new = attempt.get('eccentricity', 0.0) + de = abs(e_new - e_prev) / max(e_prev, solver.de_floor) + + axp_prev = hf_row.get('axial_period', np.nan) + axp_new = attempt.get('axial_period', np.nan) + dOmega_p = np.divide( + abs(np.divide(1.0, axp_new) - np.divide(1.0, axp_prev)), + np.divide(1.0, axp_prev), + ) + + return { + key: value + for key, value in (('da', da), ('de', de), ('dOmega_p', dOmega_p)) + if np.isfinite(value) + } + + rel_change_limits = { + 'da': solver.max_rel_da, + 'de': solver.max_rel_de, + 'dOmega_p': solver.max_rel_dOmega, + } + run_adaptive_orbit_substeps( + hf_row, + config, + dirs, + tides_o, + interior_o, + model, + step_fn, + _state_is_valid_star, + rel_change_fn, + rel_change_limits, + needs_c_planet=needs_c_planet, + log_label='evolve_orbit_star', + ) -def evolve_orbital(hf_row: dict, config: Config, dt: float): - """Evolve the planet's orbital parameters module. - Updates the semi-major axis and eccentricity. +def sp0d(hf_row: dict, dt: float, config: Config): + """Evolve the planet's semi-major axis and eccentricity via + Driscoll & Barnes (2015), Eq. 15-16. Angular momentum is deliberately + not tracked: the model has no spin/rotation state (contrast sp1d), so + there is no conserved AM quantity to check. See "Star-planet models" + in docs/Explanations/orbit.md for the model's scope and its sign- + convention mismatch with PROTEUS's tidal modules. Parameters ---------- hf_row : dict Dictionary of current runtime variables - config : dict - Dictionary of configuration options dt : float Time interval over which escape is occuring [yr] + config : Config + Configuration options; reads config.orbit.solver for the + solve_ivp method/rtol/atol, shared with every other + orbital-evolution model. """ + + def de_dt(a, e, params): + """ODE for orbital eccentricity, Driscoll & Barnes (2015) Eq. 16. + Sign convention: PROTEUS's tidal modules feed a positive Imk2, + which expands the orbit rather than circularising it (see + docs/Explanations/orbit.md); do not invert without checking every + Imk2 producer. + """ + Imk2, Mst, G, Rpl, Mpl = params + return (21 / 2) * Imk2 * Mst**1.5 * G**0.5 * Rpl**5 / (Mpl * a**6.5) * e + + def da_dt(a, e, params): + """ + ODE describing evolution of semimajor axis based on Eq. 15 of + Driscoll and Barnes (2015), Astrobiology 15, 739. + """ + return 2 * a * e * de_dt(a, e, params) + + def orbitals(t, z, params): + """ + Helper function for solving coupled ODEs. + """ + a, e = z + return [da_dt(a, e, params), de_dt(a, e, params)] + Imk2 = hf_row['Imk2'] Rpl = hf_row['R_int'] @@ -73,26 +174,221 @@ def evolve_orbital(hf_row: dict, config: Config, dt: float): sma = float(hf_row['semimajorax']) ecc = float(hf_row['eccentricity']) - # Time step - current_time = float(hf_row['Time']) - - # Use config parameters as initial guess - if current_time <= 1: - # Set semimajor axis and eccentricity from config. - hf_row['semimajorax'] = config.orbit.semimajoraxis * AU - hf_row['eccentricity'] = config.orbit.eccentricity - return - else: - # Find previous_time from which to evolve orbit to current_time - previous_time = current_time - dt + # Convert time to seconds + dt = float(dt) * secs_per_year # Collect system parameters at previous_time params = (Imk2, Mst, const_G, Rpl, Mpl) - # Find new semimajor axis and eccentricity using RK5(4) integration method + # Find new semimajor axis and eccentricity using solve_ivp + solver = config.orbit.solver log.debug('Integrate sma and ecc with solve_ivp') - sol = solve_ivp(orbitals, [previous_time, current_time], [sma, ecc], args=(params,)) + sol = solve_ivp( + orbitals, + [0, dt], + [sma, ecc], + args=(params,), + method=solver.method, + rtol=solver.rtol, + atol=solver.atol, + ) # Update semimajor axis and eccentricity hf_row['semimajorax'] = sol.y[0][-1] hf_row['eccentricity'] = sol.y[1][-1] + + +def sp1d(hf_row, tides_o, dt, config: Config): + """Evolve the planet's semi-major axis, eccentricity, and spin under + planetary tides raised by the star, via Correia & Valente (2022); + angular-momentum-conserving by construction. Structurally identical to + ps1d, but omits stellar tides (the star is assumed non-dissipative). + See "Star-planet models" in docs/Explanations/orbit.md. + + Parameters + ---------- + hf_row : dict + Dictionary of current runtime variables + tides_o : Tides_t + Tides object containing tidal interactions + dt : float + Time interval over which escape is occuring [yr] + config : Config + Configuration options; reads config.orbit.solver for the + solve_ivp method/rtol/atol, shared with every other + orbital-evolution model. + """ + + # Convert time to seconds + dt = float(dt) * secs_per_year + + # Orbital parameters from helpfile + axial_p = 2 * np.pi / float(hf_row['axial_period']) + sma = float(hf_row['semimajorax']) + ecc = float(hf_row['eccentricity']) + + # Setup Initial State and Parameters + y0 = [ + axial_p, + sma, + ecc, + ] + + params = { + 'M_p': hf_row['M_int'], + 'M_s': hf_row['M_star'], + 'R_p': hf_row['R_int'], + 'R_s': hf_row['R_star'], + 'C_p': hf_row['C_int'], + } + + # Retrieve tidal mode information from tides_o object + nmk_p = np.asarray(tides_o.get(primary='planet', perturber='star').nmk) + LNk_p = np.asarray(tides_o.get(primary='planet', perturber='star').LNk) + + kmin, kmax = kmin_kmax_for_m0_mirror(nmk_p) + n_k = kmax - kmin + 1 + + def _dense_love(nmk, LNk, m_target): + # Sparse-mode-safe (real tidal data need not have a row for every + # integer s in [kmin, kmax]) AND folds in the m=0/s<0 modes that + # Obliqua's own emission only supplies for s>=0. + mask = (nmk[:, 1] == m_target) & (nmk[:, 2] >= kmin) & (nmk[:, 2] <= kmax) + dense = np.zeros(n_k, dtype=complex) + dense[(nmk[mask, 2] - kmin).astype(int)] = LNk[mask] + if m_target == 0: + pos_mask = mask & (nmk[:, 2] > 0) + s_pos = nmk[pos_mask, 2].astype(int) + neg_idx = -s_pos - kmin + valid = (neg_idx >= 0) & (neg_idx < n_k) + dense[neg_idx[valid]] = np.conj(LNk[pos_mask][valid]) + return dense + + LNk_p_m0 = _dense_love(nmk_p, LNk_p, 0) + LNk_p_m2 = _dense_love(nmk_p, LNk_p, 2) + + def domega_dt(I_j, C_j, sum_dOmega): + """Planar secular tidal spin""" + return -(3.0 * I_j / (2.0 * C_j)) * sum_dOmega + + def smooth_sign(sigma, scale=1e-12): + """Smooth approximation to sign(sigma) using tanh to avoid solver kinks.""" + return np.tanh(sigma / scale) + + def dE_dt(z, p): + """Tidal energy dissipation rate""" + + Omega_p, a, e = z + e_safe = min( + max(e, 1e-12), 1.0 - 1e-9 + ) # symmetric: also guards e briefly exceeding 1 during a solver trial + + # Basic Orbital and Physical Parameters + n_mm = np.sqrt(const_G * (p['M_p'] + p['M_s']) / a**3) + I_p = (const_G * p['M_s'] ** 2 * p['R_p'] ** 5) / a**6 + + k, X_all = get_all_m_hansen(e_safe, 2, kmin, kmax) + s_arr = k.astype(float) + + X_0 = X_all[0] + X_2 = X_all[2] + X0_sq = X_0**2 + X2_sq = X_2**2 + + K_p0 = -LNk_p_m0.imag + K_p2 = -LNk_p_m2.imag + + dE_orb_p = I_p * n_mm * np.sum(s_arr * (K_p0 * X0_sq + 3.0 * K_p2 * X2_sq)) / 4 + + dE_rot_p = -I_p * 3 * Omega_p * np.sum(K_p2 * X2_sq) / 2 + + return -(dE_orb_p + dE_rot_p) + + def orbitals(t, z, p): + Omega_p, a, e = z + e_safe = min( + max(e, 1e-12), 1.0 - 1e-9 + ) # symmetric: also guards e briefly exceeding 1 during a solver trial + + # Basic Orbital and Physical Parameters + n_mm = np.sqrt(const_G * (p['M_p'] + p['M_s']) / a**3) + + # Tidal scaling factors + E_p = n_mm * (p['M_s'] / p['M_p']) * (p['R_p'] / a) ** 5 + I_p = (const_G * p['M_s'] ** 2 * p['R_p'] ** 5) / a**6 + + k, X_all = get_all_m_hansen(e_safe, 2, kmin, kmax) + s_arr = k.astype(float) + sig_scale = max(1e-12, 1e-4 * n_mm) + sigma_0 = -s_arr * n_mm + sigma_p2 = 2 * Omega_p - s_arr * n_mm + + K_p0 = np.abs(LNk_p_m0.imag) * smooth_sign(sigma_0, sig_scale) + K_p2 = np.abs(LNk_p_m2.imag) * smooth_sign(sigma_p2, sig_scale) + + X_0 = X_all[0] + X_2 = X_all[2] + X0_sq = X_0**2 + X2_sq = X_2**2 + sqrt_e = np.sqrt(1.0 - e_safe**2) + + dOmega_p = np.sum(K_p2 * X2_sq) + da_p = np.sum(s_arr * (K_p0 * X0_sq + 3.0 * K_p2 * X2_sq)) + de_p = np.sum( + K_p0 * X0_sq * s_arr * sqrt_e - 3.0 * K_p2 * X2_sq * (2.0 - s_arr * sqrt_e) + ) + + sqrt_term = np.sqrt(1.0 - e_safe**2) + da_dt_p = a * (E_p / 2.0) * da_p + de_dt_p = (E_p * sqrt_term / (4.0 * e_safe)) * de_p + + return [ + domega_dt(I_p, p['C_p'], dOmega_p), + da_dt_p, + de_dt_p, + ] + + # Integration + solver = config.orbit.solver + log.debug('Integrating the sp1d orbital model with solve_ivp') + sol = solve_ivp( + fun=lambda t, y: orbitals(t, y, params), + t_span=(0, dt), + y0=y0, + method=solver.method, + rtol=solver.rtol, + atol=solver.atol, + ) + + # Compute total angular momentum at the end of the integration + L_final = params['C_p'] * sol.y[0][-1] + (params['M_p'] * params['M_s']) / ( + params['M_p'] + params['M_s'] + ) * np.sqrt( + const_G * (params['M_p'] + params['M_s']) * sol.y[1][-1] * (1 - sol.y[2][-1] ** 2) + ) + + # Compute total energy dissipated by tides over the time step + dE_tide_p = dE_dt(y0, params) + + # log energy per surface area for debugging + energy_per_area = dE_tide_p / (4 * np.pi * params['R_p'] ** 2) + log.debug( + f'Total tidal power: {dE_tide_p:.3e} W, Energy per unit area: {energy_per_area:.3e} W/m^2' + ) + + # Exact, solver-consistent split of the changes accumulated over this step + da_planet_tide = sol.y[1][-1] - sol.y[1][0] + de_planet_tide = sol.y[2][-1] - sol.y[2][0] + + # Update semimajor axis and axial period + hf_row['sma_dot_planet'] = da_planet_tide / dt # m/s, planet-raised tide + hf_row['ecc_dot_planet'] = de_planet_tide / dt # 1/s + + # Update semimajor axis and axial period + hf_row['axial_period'] = 2 * np.pi / sol.y[0][-1] + hf_row['semimajorax'] = sol.y[1][-1] + # Circularization (e -> 0) is a valid terminal state, but the ODE can + # cross exactly zero and land on a floating-point-noise-scale negative + # value. + hf_row['eccentricity'] = max(sol.y[2][-1], 0.0) + hf_row['plan_star_am'] = L_final diff --git a/src/proteus/orbit/satellite.py b/src/proteus/orbit/satellite.py index 37327982b..5b00dc294 100644 --- a/src/proteus/orbit/satellite.py +++ b/src/proteus/orbit/satellite.py @@ -1,13 +1,19 @@ -# Orbit evolution module from __future__ import annotations import logging +import os from typing import TYPE_CHECKING import numpy as np from scipy.integrate import solve_ivp +from scipy.optimize import brentq -from proteus.utils.constants import const_G, secs_per_hour +from proteus.interior_energetics.common import Interior_t +from proteus.orbit.common import Tides_t, kmin_kmax_for_m0_mirror, run_adaptive_orbit_substeps +from proteus.orbit.hansen import get_all_m_hansen +from proteus.orbit.timestep import _estimate_evection_dt_cap_yr +from proteus.utils.constants import R_earth, const_G, secs_per_year +from proteus.utils.helper import UpdateStatusfile if TYPE_CHECKING: from proteus.config import Config @@ -15,186 +21,1225 @@ log = logging.getLogger('fwl.' + __name__) -def Ltot(ω, a, params): - """Total angular momentum of the planet plus satellite system. +def _state_is_valid(hf_row): + """Reject a substep whose resulting state is unphysical or non-finite. - Implements Korenaga (2023) Icarus 400, 115564, Eq. 60: + Checks the satellite hasn't spiralled below/through the planet's + surface (a <= 1.05 R_planet), that eccentricity is in the physically + sane, sub-parabolic range [0, 0.999), and that both spin periods are + finite. Used by `evolve_orbit_satellite`'s accept/reject controller; + a False return discards the tentative substep and triggers a smaller + retry `dt_yr`. + """ + a = hf_row.get('semimajorax_sat', np.nan) + e = hf_row.get('eccentricity_sat', 0.0) + if not np.isfinite(a) or a <= 1.05 * hf_row.get('R_planet', 0.0): + return False + if not np.isfinite(e) or e < 0.0 or e >= 0.999: + return False + for key in ('axial_period', 'axial_period_sat'): + val = hf_row.get(key, np.nan) + if val is not None and not np.isfinite(val): + return False + return True - L = I_E * Omega + M_M * sqrt(G * (M_E + M_M) * a) (Eq. 60) - where I_E and Omega are the planet's moment of inertia and rotation - frequency, M_M is the satellite mass, M_E is the planet mass, G is - Newton's constant, and a is the planet-satellite semi-major axis. +def _in_evection_band(hf_row, resonance_state, margin_enter=0.10, margin_exit=0.35): + """Debounced, hysteretic near-resonance detector. - Derivation - ---------- - The first term is the planet's spin angular momentum, I_E * Omega. + resonance_state is a small dict the CALLER owns and must pass back in + unchanged on every call, it carries the 2-step history and the + current on/off state. Mutated in place. As a side effect, this also + stashes the latest raw (unsmoothed, undebounced) relative distance + under ``resonance_state['d_a_rel_now']`` used by the caller to + derive the wider, purely-diagnostic ``near_evection_band`` signal (see + ``evolve_orbit_satellite``), without recomputing ``compute_a_res_prime`` + a second time. - The second term is the orbital angular momentum of the planet- - satellite two-body problem. The textbook expression for a two-body - orbital angular momentum about the system barycenter is + Returns True if the satellite is judged to currently be "in" the + evection resonance band (i.e. the oscillating-filter term should be + active), False otherwise. + """ + a_prime_now = hf_row['semimajorax_sat'] / R_earth + a_res_now = compute_a_res_prime(hf_row) - L_orb = mu * v_rel * a (textbook) + # Check resonance history and current state + hist = resonance_state.setdefault('hist_d_a_rel', []) + active = resonance_state.get('active', False) - with reduced mass mu = M_E * M_M / (M_E + M_M) and orbital speed - v_rel = sqrt(G * (M_E + M_M) / a) (vis-viva at a circular orbit). - Substituting, + # Check if resonance is defined + if not np.isfinite(a_res_now) or a_res_now == 0: + # Reset the resonance state and history if resonance is not defined + resonance_state['active'] = False + resonance_state['d_a_rel_now'] = np.inf + hist.clear() + return False - L_orb = mu * sqrt(G * (M_E + M_M) * a) + # Calculate the relative distance + d_a_rel = (a_prime_now - a_res_now) / a_res_now + # Update the resonance state and history + resonance_state['d_a_rel_now'] = d_a_rel + hist.append(d_a_rel) + # Keep only the last two entries in the history + if len(hist) > 2: + hist.pop(0) - Korenaga (2023) replaces mu by M_M, which is the limit of mu as - M_M / M_E -> 0: + # Determine the margin based on the current active state + margin = margin_exit if active else margin_enter + d_smoothed = float(np.mean(hist)) + smoothed_inside = abs(d_smoothed) <= margin - mu = M_E M_M / (M_E + M_M) = M_M / (1 + M_M / M_E) -> M_M. + # If we have two history points... + if len(hist) == 2: + # ...check if both history points are within 1.5 times the margin + both_in_neighbourhood = all(abs(v) <= margin * 1.5 for v in hist) + else: + # ...if we have only one history point, just check that point + both_in_neighbourhood = smoothed_inside - For the Earth-Moon system the relative error of this substitution is - M_M / M_E ~ 1/81 ~ 1.2%; for any heavier-satellite system the - approximation would degrade, but PROTEUS's satellite module is - currently targeted at the Earth-Moon regime, so we keep Korenaga's - form verbatim. + # Update the active state based on the smoothed value and the neighbourhood check + resonance_state['active'] = bool(smoothed_inside and both_in_neighbourhood) + return resonance_state['active'] - Sign convention: positive angular momentum corresponds to a prograde - Moon (counter-clockwise from the planet's north pole). The integration - constant L produced here is consumed by ``dω_dt`` and ``da_dt`` below, - so any change to this formula MUST be paired with sanity checks on - the time-evolution equations (Eqs. 58 + 59). - """ - I, _, G, Mpl, Msa, _ = params - # Korenaga (2023) Eq. 60: the orbital prefactor is the SATELLITE mass - # M_M, which is the M_M << M_E limit of the textbook reduced-mass - # formula. Substituting M_planet here inflates L by M_planet/M_sat - # (~80x for Earth-Moon); see the reference-pinned test in - # tests/orbit/test_satellite.py for the discriminating numeric guard. - return I * ω + Msa * (G * (Mpl + Msa) * a) ** 0.5 - - -def dω_dt(a, ω, params): - """Right-hand side of the planet-rotation ODE. - - Implements Korenaga (2023) Icarus 400, 115564, Eq. 58: - - dOmega/dt = -E_tide_dot / (I_E * Omega + G * M_E * M_M * I_E - / (a * (L - I_E * Omega))) (Eq. 58) - - where E_tide_dot is the tidal heat flux dissipated in the planet - (positive, in W). The minus sign in front of E_tide_dot ensures the - spin slows whenever tidal energy is being dissipated, matching the - physical expectation that dissipation transfers angular momentum - from the planet's spin to the satellite's orbit. - - The denominator is the partial derivative of the system's total - energy with respect to Omega, evaluated at constant L (the - integration constant set up by ``Ltot`` above). The bracketed second - term is the orbital contribution; for the Earth-Moon system its - magnitude is comparable to the spin term once the Moon recedes past - a few Earth radii. - - See Korenaga (2023) Section 2.7 ("Orbital evolution") for the full - derivation; the formulation closely follows Zahnle et al. (2015). + +def _flush_fine_evection_csv( + tides_o, data_dir, fine_entry, in_band, storage_target_interval_yr +): + """Append ONE accepted ps1d_evec macro-step's fine samples to disk, at + the "storage clock" rate (see "The three clocks" in + docs/Explanations/orbit.md). This is the only place + `fine_evection_data.csv` is written, and must only be called AFTER a + substep has been confirmed accepted, never from inside the solver + itself, which cannot know if its result will later be rejected. + + Each candidate sample passes two filters: (1) dedup against + `tides_o.fine_csv_last_t_yr`, the last timestamp actually written, + dropping any sample that does not advance past it; (2) storage-clock + density, every deduped sample is kept while `in_band`, otherwise + only once `t_abs_yr` reaches `tides_o.fine_csv_next_target_yr`, which + then advances to `t_abs_yr + storage_target_interval_yr`. + + Parameters + ---------- + tides_o : Tides_t + Read/written for the two persisted cursors described above. + data_dir : str + Directory containing (or to contain) fine_evection_data.csv. + fine_entry : dict + One entry from `ps1d_evec`'s `fine_sink` list: equal-length + 1-D arrays keyed by 't_abs_yr', 'omega_p', 'omega_s', 'sma', + 'ecc', 'phi', 'da_planet_tide_cum', 'da_sat_tide_cum', + 'de_planet_tide_cum', 'de_sat_tide_cum', 'filter'. + in_band : bool + Whether this accepted substep was inside the evection band; + selects which storage-clock policy applies. + storage_target_interval_yr : float + Target spacing [yr] between stored out-of-band samples for + the current `evolve_orbit_satellite` call. Unused if in_band. """ - I, L, G, Mpl, Msa, dE_tidal = params - return -dE_tidal / (I * ω + (G * Mpl * Msa * I) / (a * (L - I * ω))) + t_abs_yr = fine_entry['t_abs_yr'] + if len(t_abs_yr) == 0: + return + + # Get the last timestamp actually written and the next storage-clock target + last_t = tides_o.fine_csv_last_t_yr if tides_o.fine_csv_last_t_yr is not None else -np.inf + next_target = ( + tides_o.fine_csv_next_target_yr + if tides_o.fine_csv_next_target_yr is not None + else -np.inf + ) + # Determine which samples to keep based on the storage-clock + keep = np.zeros(len(t_abs_yr), dtype=bool) + for i, t in enumerate(t_abs_yr): + if t <= last_t: + continue + if in_band: + keep[i] = True + elif t >= next_target: + keep[i] = True + next_target = t + storage_target_interval_yr -def da_dt(a, ω, params): - """Right-hand side of the satellite semi-major-axis ODE. + # Persist the storage-clock cursor (even on calls that end up keeping nothing) + tides_o.fine_csv_next_target_yr = next_target - Implements Korenaga (2023) Icarus 400, 115564, Eq. 59: + # If no samples passed the storage-clock filter, return early + if not np.any(keep): + return - da/dt = -2 * I_E * a / (L - I_E * Omega) * dOmega/dt (Eq. 59) + # Collect the filtered samples into a 2D array for CSV writing + fine_data = np.column_stack( + ( + t_abs_yr[keep], + fine_entry['omega_p'][keep], + fine_entry['omega_s'][keep], + fine_entry['sma'][keep], + fine_entry['ecc'][keep], + fine_entry['phi'][keep], + fine_entry['da_planet_tide_cum'][keep], + fine_entry['da_sat_tide_cum'][keep], + fine_entry['de_planet_tide_cum'][keep], + fine_entry['de_sat_tide_cum'][keep], + fine_entry['filter'][keep], + ) + ) - This is a direct consequence of differentiating the angular-momentum - closure ``L = I_E * Omega + M_M * sqrt(G * (M_E + M_M) * a)`` (Eq. 60) - with respect to time at constant L and solving for da/dt. Whenever the - planet's spin slows (dOmega/dt < 0), the satellite's orbit expands - (da/dt > 0) provided L > I_E * Omega, which is the prograde-Moon - regime PROTEUS targets. + # Append the filtered samples to the CSV file, creating it with a header if it doesn't exist + fine_file_path = os.path.join(data_dir, 'fine_evection_data.csv') + file_exists = os.path.exists(fine_file_path) + with open(fine_file_path, 'a') as f: + if not file_exists: + f.write( + 't_abs_yr,omega_p,omega_s,sma,ecc,phi,' + 'da_planet_tide_cum,da_sat_tide_cum,' + 'de_planet_tide_cum,de_sat_tide_cum,filter\n' + ) + np.savetxt(f, fine_data, delimiter=',', fmt='%.8e') + + # Advance the cursor to the last timestamp actually written + tides_o.fine_csv_last_t_yr = float(t_abs_yr[keep][-1]) + + +def evolve_orbit_satellite( + hf_row: dict, + config: Config, + dirs: dict, + tides_o: Tides_t, + interior_o: Interior_t, +): + """Advance the satellite's orbital parameters over `interior_o.dt` + years. Dispatches to the requested model (`ps0d`, `ps1d`, `ps1d_evec`), + all three via the shared adaptive substep controller (see + docs/Explanations/orbit.md). + + TODO: Assumes constant planetary mass, if atmospheric mass loss occurs + between steps, escaping angular momentum must be handled as an explicit + sink rather than absorbed here. + + Parameters + ---------- + hf_row : dict + Current runtime state variables. + config : Config + System configuration options. + dirs : dict + Output and data directory paths. + tides_o : Tides_t + Container for tidal interaction parameters. + interior_o : Interior_t + Interior model state; `interior_o.dt` defines total elapsed integration time (years). """ - I, L, *_ = params - return -2 * I * a / (L - I * ω) * dω_dt(a, ω, params) + model = config.orbit.planet_satellite_model + solver = config.orbit.solver + + # Get the evection resonance state from tides_o + resonance_state = tides_o.resonance_state + + # Get the total integration time and the start time of the current window + t_total_yr = interior_o.dt + t_window_start_abs_yr = float(hf_row['Time']) - t_total_yr + + # Target spacing [yr] between stored out-of-band fine samples for this call + storage_target_interval_yr = solver.fine_csv_target_rel_dt * t_total_yr + + last_in_band = [None] # mutable box: tracks band transitions for logging only + + if model == 'ps0d': + # Define the integrator: ps0d. + def step_fn(hf_row, dt_yr, t_elapsed_yr): + ps0d(hf_row, dt_yr, config) + return None + + # No extra action on accept for ps0d + on_accept_fn = None + + elif model == 'ps1d': + # Define the integrator: ps1d + def step_fn(hf_row, dt_yr, t_elapsed_yr): + ps1d(hf_row, tides_o, dt_yr, config) + return None + + # No extra action on accept for ps1d + on_accept_fn = None + + elif model == 'ps1d_evec': + # Define the integrator: ps1d_evec + def step_fn(hf_row, dt_yr, t_elapsed_yr): + # Check if in evection resonance band + in_band = _in_evection_band( + hf_row, + resonance_state, + margin_enter=solver.resonance_margin_enter, + margin_exit=solver.resonance_margin_exit, + ) + + # Log band transitions + if last_in_band[0] is not None and in_band != last_in_band[0]: + log.debug( + 'evolve_orbit_satellite: evection-band TRANSITION ' + '%s -> %s at t_elapsed=%.6e/%.6e yr ' + '(a=%.6g, e=%.4f, dt_yr=%.3e)', + last_in_band[0], + in_band, + t_elapsed_yr, + t_total_yr, + hf_row.get('semimajorax_sat', float('nan')), + hf_row.get('eccentricity_sat', float('nan')), + dt_yr, + ) + # Update the last_in_band state for the next call + last_in_band[0] = in_band + + # If in resonance band, then include evection resonance terms + filter_value = 1.0 if in_band else 0.0 + + # Define current time in the solver clock + substep_start_abs_yr = t_window_start_abs_yr + t_elapsed_yr + + # Determine if the next substep will cross the storage-clock target, + # and if so, prepare to store fine samples + next_storage_target_yr = ( + tides_o.fine_csv_next_target_yr + if tides_o.fine_csv_next_target_yr is not None + else -np.inf + ) + might_cross_target = (substep_start_abs_yr + dt_yr) >= next_storage_target_yr + # Prepare a list to collect fine samples if needed + fine_sink = [] if (in_band or might_cross_target) else None + + # Run the Planet-Satellite-1D model with evection resonance + ps1d_evec( + hf_row, + tides_o, + dt_yr, + config, + fine_sink, + 20, + filter_value, + t_abs_start_yr=substep_start_abs_yr, + ) + return (fine_sink, in_band) + + # Define the on_accept function to flush fine samples to disk after a substep is accepted + def on_accept_fn(hf_row, extra): + # This substep is now confirmed ACCEPTED (state valid, all + # rel_change_* within tolerance). Only now is it safe to + # persist its fine-grained samples. + fine_sink, in_band = extra + if fine_sink: + # Flush the fine samples to disk + _flush_fine_evection_csv( + tides_o, + dirs['output/data'], + fine_sink[0], + in_band=bool(in_band), + storage_target_interval_yr=storage_target_interval_yr, + ) + + else: + # Unrecognized model: raise an error and update the status file + UpdateStatusfile(dirs, 26) + raise ValueError(f'unrecognised planet_satellite_model: {model!r}') + + # Define the relative-change function for the adaptive substep controller + def rel_change_fn(attempt, hf_row): + # This function computes the relative changes in key orbital parameters between + # the current attempt and the previous state, which are used to determine if the + # substep is acceptable. + + # A relative-change ratio needs a finite, nonzero prior value. + with np.errstate(divide='ignore', invalid='ignore'): + # Compute semimajor-axis gradient + a_prev = hf_row.get('semimajorax_sat', np.nan) + da = np.divide(abs(attempt['semimajorax_sat'] - a_prev), a_prev) + + # Compute eccentricity gradient + e_prev = hf_row.get('eccentricity_sat', 0.0) + e_new = attempt.get('eccentricity_sat', 0.0) + de = abs(e_new - e_prev) / max(e_prev, solver.de_floor) + + # Compute planet spin rate gradient + axp_prev = hf_row.get('axial_period', np.nan) + axp_new = attempt.get('axial_period', np.nan) + dOmega_p = np.divide( + abs(np.divide(1.0, axp_new) - np.divide(1.0, axp_prev)), + np.divide(1.0, axp_prev), + ) + + # Compute satellite spin rate gradient + axs_prev = hf_row.get('axial_period_sat', np.nan) + axs_new = attempt.get('axial_period_sat', np.nan) + dOmega_s = np.divide( + abs(np.divide(1.0, axs_new) - np.divide(1.0, axs_prev)), + np.divide(1.0, axs_prev), + ) + + return { + key: value + for key, value in ( + ('da', da), + ('de', de), + ('dOmega_p', dOmega_p), + ('dOmega_s', dOmega_s), + ) + if np.isfinite(value) + } + + # Define the relative-change limits for the adaptive substep controller, + # based on the solver configuration + rel_change_limits = { + 'da': solver.max_rel_da, + 'de': solver.max_rel_de, + 'dOmega_p': solver.max_rel_dOmega, + 'dOmega_s': solver.max_rel_dOmega, + } + + # Snapshot Time before the substep controller runs + t_call_start_yr = float(hf_row['Time']) + + # Run the adaptive substep controller, which will call the appropriate + # step function (ps0d, ps1d, or ps1d_evec) and manage substep acceptance/ + # rejection based on the relative changes in orbital parameters. + run_adaptive_orbit_substeps( + hf_row, + config, + dirs, + tides_o, + interior_o, + model, + step_fn, + _state_is_valid, + rel_change_fn, + rel_change_limits, + needs_c_planet=True, + on_accept_fn=on_accept_fn, + log_label='evolve_orbit_satellite', + ) + + # After the adaptive substep controller has completed, update the evection resonance state + in_band = bool(resonance_state.get('active', False)) + d_a_rel_now = resonance_state.get('d_a_rel_now', np.inf) + near_band = abs(d_a_rel_now) <= solver.resonance_margin_approach + tides_o.evection_zone_active = in_band or near_band + + # Evection dt cap (see docs/Explanations/orbit.md, "Evection resonance"). + ecc_window = max(2, int(getattr(config.params.dt, 'evection_rate_window', 2))) + # Store the current eccentricity and time in the history + tides_o.evection_ecc_history.append( + (float(hf_row['Time']), float(hf_row['eccentricity_sat'])) + ) + # Keep only the last `ecc_window` entries in the history + del tides_o.evection_ecc_history[:-ecc_window] + + # Compute the time since the start of this call, used for estimating the evection dt cap + dt_prev_actual_yr = float(hf_row['Time']) - t_call_start_yr + hf_row['evection_dt_cap_yr'] = _estimate_evection_dt_cap_yr( + tides_o, tides_o.evection_zone_active, dt_prev_actual_yr, config + ) -def orbitals(t, z, params): +def compute_a_res_prime(hf_row): + """Compute the resonant semi-major axis (a'_res) in units of the planet's radius. + Following Rufu & Canup (2020), below Eq 11. """ - Helper function for solving coupled ODEs. + + Omega_planet = np.sqrt(const_G * hf_row['M_int'] / hf_row['R_int'] ** 3) + Omega_sun = 2 * np.pi / secs_per_year + Lambda = np.sqrt(1.5 * 0.315 * Omega_planet / Omega_sun) + + e = hf_row['eccentricity_sat'] + s_prime = (2 * np.pi / hf_row['axial_period']) / Omega_planet + with np.errstate(invalid='ignore'): + return (Lambda * s_prime / (1.0 - e**2)) ** (4.0 / 7.0) + + +def _solve_e_stationary(a_prime, s_prime, Lambda, Omega_ratio): + """Solve Rufu & Canup (2020), Eq. 12, for the stable stationary eccentricity e_s. + + Note: + Rufu & Canup (2020) use the following normalization: + - a' = a / R_p + - s' = Omega / Omega_p + - Lambda = sqrt(1.5 * J_star * Omega_p / Omega_star) + - Omega_ratio = Omega_star / Omega_p + + where: + Omega_p = sqrt(const_G * M_p / R_p**3) + + Parameters + ---------- + a_prime : float + Scaled semi-major axis. + s_prime : float + Scaled spin rate. + Lambda : float + Scaled angular momentum. + Omega_ratio : float + Ratio of the planet's spin rate to the orbital mean motion. + + Returns + ------- + e_s : float + The stable stationary eccentricity, or np.nan if no solution exists. """ - a, ω = z - return [da_dt(a, ω, params), dω_dt(a, ω, params)] + if not (np.isfinite(a_prime) and np.isfinite(s_prime)): + return np.nan + def f(e): + return ( + Lambda**2 * s_prime**2 / (a_prime**3.5 * (1.0 - e**2) ** 2) + - 1.0 + - 3.0 * np.sqrt(1.0 - e**2) * a_prime**1.5 * Omega_ratio + ) -def update_satellite(hf_row: dict, config: Config, dt: float): + lo, hi = 1e-8, 1.0 - 1e-8 + f_lo, f_hi = f(lo), f(hi) + if not (np.isfinite(f_lo) and np.isfinite(f_hi)) or f_lo * f_hi > 0: + return np.nan + return brentq(f, lo, hi) + + +def ps0d(hf_row, dt, config): """Evolve the Satellite's orbital parameters module. - Updates the semi-major axis and primary rotation - frequency based on angular momentum conservation. + Updates the semi-major axis and primary spin rate based on angular + momentum conservation. The model is based on Korenaga (2023). + + DOI: 10.1016/j.icarus.2023.115564 Parameters ---------- hf_row : dict Dictionary of current runtime variables - config : dict - Dictionary of configuration options dt : float Time interval over which escape is occuring [yr] + config : Config + Configuration options; reads config.orbit.solver """ + + def Ltot(ω, a, params): + """Total planet+satellite angular momentum, Korenaga (2023) Eq. 60: + spin (I*Omega) plus orbital (M_sat * sqrt(G*(M_pl+M_sat)*a)), the + latter using the M_sat << M_pl limit of the reduced-mass formula + (~1.2% error for Earth-Moon). Sign convention: positive L is a + prograde satellite. See docs/Validation/orbit/satellite.md for the + re-derivation and the discrimination guard against the M_planet + substitution. + """ + I, _, G, Mpl, Msa, _ = params + return I * ω + Msa * (G * (Mpl + Msa) * a) ** 0.5 + + def dω_dt(a, ω, params): + """Planet-rotation ODE, Korenaga (2023) Eq. 58: spin slows as + tidal energy `dE_tidal` [W] is dissipated, transferring angular + momentum to the satellite's orbit. See + docs/Validation/orbit/satellite.md. + """ + I, L, G, Mpl, Msa, dE_tidal = params + return -dE_tidal / (I * ω + (G * Mpl * Msa * I) / (a * (L - I * ω))) + + def da_dt(a, ω, params): + """Satellite semi-major-axis ODE, Korenaga (2023) Eq. 59: follows + from differentiating the Ltot closure (Eq. 60) at constant L. The + orbit expands (da/dt > 0) as spin slows (dOmega/dt < 0), provided + L > I*Omega (the prograde regime PROTEUS targets). + """ + I, L, *_ = params + return -2 * I * a / (L - I * ω) * dω_dt(a, ω, params) + + def orbitals(t, z, params): + """ + Helper function for solving coupled ODEs. + """ + a, ω = z + return [da_dt(a, ω, params), dω_dt(a, ω, params)] + + # Set parameters from helpfile Rpl = hf_row['R_int'] Mpl = hf_row['M_int'] + Msa = hf_row['M_sat'] + + sma = float(hf_row['semimajorax_sat']) + omega = 2 * np.pi / float(hf_row['axial_period']) + + L = hf_row['plan_sat_am'] # Calculate bulk tidal power dE_tidal = hf_row['F_tidal'] * 4 * np.pi * Rpl**2 # Js-1 - # Calculate moment of inertia of planet (assuming solid sphere) - I = 2 / 5 * Mpl * Rpl**2 # kg.m-1 + # Planet's moment-of-inertia coefficient, from the live interior + # state (evolve_orbit_satellite's get_C_planet + spin-rescale + # block keeps this angular-momentum-consistent across structural + # changes, see that function's docstring), not a fixed + # uniform-sphere approximation unlike in Korenaga (2023). + I = hf_row['C_int'] # kg m^2 + + # Convert time to seconds + dt = float(dt) * secs_per_year # Time step current_time = float(hf_row['Time']) - # Use config parameters as initial guess - if current_time <= 1: - # Set satellite semimajor axis, satellite mass, and planet rotation frequency from config. - hf_row['semimajorax_sat'] = float(config.orbit.semimajoraxis_sat) # m - hf_row['M_sat'] = float(config.orbit.mass_sat) # kg - - Msa = hf_row['M_sat'] - - if config.orbit.axial_period is None: - # set by user to 'none', use 1:1 SOR - hf_row['axial_period'] = float(hf_row['orbital_period']) - else: - hf_row['axial_period'] = float(config.orbit.axial_period) * secs_per_hour - + # On the first run of this orbital module, instantiate the system angular-momentum + if current_time <= 10 and L == 0: # Calculate the system angular-momentum integration constant # via the dedicated ``Ltot`` helper above, which implements # Korenaga (2023) Eq. 60 with the satellite-mass prefactor in # the orbital sqrt. Using the helper avoids duplicating the - # formula and keeps any future revision in one place. - sma = float(hf_row['semimajorax_sat']) - omega = 2 * np.pi / float(hf_row['axial_period']) - - am_params = (I, None, const_G, Mpl, Msa, None) - hf_row['plan_sat_am'] = Ltot(omega, sma, am_params) - log.info(' sys.am = %.5f kg.m2.s-1' % (hf_row['plan_sat_am'])) - - return - else: - # Find previous_time from which to evolve orbit to current_time - previous_time = current_time - dt - - # Set semimajor axis, rotation frequency, satellite mass, and system AM from config. - sma = float(hf_row['semimajorax_sat']) - omega = 2 * np.pi / float(hf_row['axial_period']) - Msa = hf_row['M_sat'] - - # Could be allowed to vary to mimic resonance effects - L = hf_row['plan_sat_am'] + # formula and keeps any future revision in one place. Uses the + # same interior-derived I as the ODE itself just above. + L = Ltot(omega, sma, (I, 0, const_G, Mpl, Msa, 0)) + hf_row['plan_sat_am'] = L # Collect system parameters at previous_time params = (I, L, const_G, Mpl, Msa, dE_tidal) # Find new satellite semimajor axis and axial frequency using RK5(4) integration method - log.debug("Integrate satellite's sma and planet's omega with solve_ivp") - sol = solve_ivp(orbitals, [previous_time, current_time], [sma, omega], args=(params,)) + solver = config.orbit.solver + log.debug('Integrating the ps0d orbital model with solve_ivp') + sol = solve_ivp( + orbitals, + [0, dt], + [sma, omega], + args=(params,), + method=solver.method, + rtol=solver.rtol, + atol=solver.atol, + ) # Update semimajor axis and axial period hf_row['semimajorax_sat'] = sol.y[0][-1] hf_row['axial_period'] = 2 * np.pi / sol.y[1][-1] + + +def ps1d(hf_row, tides_o, dt, config): + """Evolve planet-satellite orbit based on Correia & Valente (2022) + + Evolve both primary and perturber spin rates, semi-major axis, and eccentricity using the + secular tidal model of Correia & Valente (2022). It assumes a vectorial approach expressed + on Hansen coefficients. + + DOI: 10.1007/s10569-022-10079-3 + + Parameters + ---------- + hf_row : dict + Dictionary of current runtime variables + tides_o : Tides_t + Tides object containing tidal interactions + dt : float + Time interval over which escape is occuring [yr] + config : Config + Configuration options; reads config.orbit.solver + """ + + # Convert time to seconds + dt = float(dt) * secs_per_year + + # Orbital parameters from helpfile + axial_p = 2 * np.pi / float(hf_row['axial_period']) + axial_s = 2 * np.pi / float(hf_row['axial_period_sat']) + sma = float(hf_row['semimajorax_sat']) + ecc = float(hf_row['eccentricity_sat']) + + # Setup Initial State and Parameters + y0 = [ + axial_p, + axial_s, + sma, + ecc, + 0.0, # cumulative delta-a from planet-raised tide + 0.0, # cumulative delta-a from satellite-raised tide + 0.0, # cumulative delta-e from planet-raised tide + 0.0, # cumulative delta-e from satellite-raised tide + ] + + params = { + 'M_p': hf_row['M_int'], + 'M_s': hf_row['M_sat'], + 'R_p': hf_row['R_int'], + 'R_s': hf_row['R_sat'], + 'C_p': hf_row['C_int'], + 'C_s': hf_row['C_sat'], + } + + # Retrieve tidal mode information from tides_o object + nmk_p = np.asarray(tides_o.get(primary='planet', perturber='satellite').nmk) + LNk_p = np.asarray(tides_o.get(primary='planet', perturber='satellite').LNk) + + nmk_s = np.asarray(tides_o.get(primary='satellite', perturber='planet').nmk) + LNk_s = np.asarray(tides_o.get(primary='satellite', perturber='planet').LNk) + + # Combine both sides so kmin/kmax cover whichever needs a wider m=0 mirror + kmin, kmax = kmin_kmax_for_m0_mirror(np.vstack([nmk_p, nmk_s])) + n_k = kmax - kmin + 1 + + def _dense_love(nmk, LNk, m_target): + # Sparse-mode-safe (real tidal data need not have a row for every + # integer s in [kmin, kmax]) AND folds in the m=0/s<0 modes that + # Obliqua's own emission only supplies for s>=0. + mask = (nmk[:, 1] == m_target) & (nmk[:, 2] >= kmin) & (nmk[:, 2] <= kmax) + dense = np.zeros(n_k, dtype=complex) + dense[(nmk[mask, 2] - kmin).astype(int)] = LNk[mask] + if m_target == 0: + pos_mask = mask & (nmk[:, 2] > 0) + s_pos = nmk[pos_mask, 2].astype(int) + neg_idx = -s_pos - kmin + valid = (neg_idx >= 0) & (neg_idx < n_k) + dense[neg_idx[valid]] = np.conj(LNk[pos_mask][valid]) + return dense + + LNk_p_m0 = _dense_love(nmk_p, LNk_p, 0) + LNk_p_m2 = _dense_love(nmk_p, LNk_p, 2) + LNk_s_m0 = _dense_love(nmk_s, LNk_s, 0) + LNk_s_m2 = _dense_love(nmk_s, LNk_s, 2) + + def domega_dt(I_j, C_j, sum_dOmega): + """Planar secular tidal spin""" + # Eq 132 from Correia & Valente (2022) + return -(3.0 * I_j / (2.0 * C_j)) * sum_dOmega + + def smooth_sign(sigma, scale=1e-12): + """Smooth approximation to sign(sigma) using tanh to avoid solver kinks.""" + return np.tanh(sigma / scale) + + def dE_dt(z, p): + """Tidal energy dissipation rate""" + Omega_p, Omega_s, a, e, *_ = z + e_safe = min( + max(e, 1e-12), 1.0 - 1e-9 + ) # symmetric: also guards e briefly exceeding 1 during a solver trial + + n_mm = np.sqrt(const_G * (p['M_p'] + p['M_s']) / a**3) + I_p = (const_G * p['M_s'] ** 2 * p['R_p'] ** 5) / a**6 + I_s = (const_G * p['M_p'] ** 2 * p['R_s'] ** 5) / a**6 + + k, X_all = get_all_m_hansen(e_safe, 2, kmin, kmax) + s_arr = k.astype(float) + + X_0 = X_all[0] + X_2 = X_all[2] + X0_sq = X_0**2 + X2_sq = X_2**2 + + K_p0 = -LNk_p_m0.imag + K_p2 = -LNk_p_m2.imag + K_s0 = -LNk_s_m0.imag + K_s2 = -LNk_s_m2.imag + + # Eqs 133, 134, 135 from Correia & Valente (2022) + dE_orb_p = I_p * n_mm * np.sum(s_arr * (K_p0 * X0_sq + 3.0 * K_p2 * X2_sq)) / 4 + dE_orb_s = I_s * n_mm * np.sum(s_arr * (K_s0 * X0_sq + 3.0 * K_s2 * X2_sq)) / 4 + + dE_rot_p = -I_p * 3 * Omega_p * np.sum(K_p2 * X2_sq) / 2 + dE_rot_s = -I_s * 3 * Omega_s * np.sum(K_s2 * X2_sq) / 2 + + return -(dE_orb_p + dE_rot_p), -(dE_orb_s + dE_rot_s) + + def orbitals(t, z, p): + Omega_p, Omega_s, a, e, *_ = z + e_safe = min( + max(e, 1e-12), 1.0 - 1e-9 + ) # symmetric: also guards e briefly exceeding 1 during a solver trial + + # Eqs 91 and 84 from Correia & Valente (2022) + n_mm = np.sqrt(const_G * (p['M_p'] + p['M_s']) / a**3) + E_p = n_mm * (p['M_s'] / p['M_p']) * (p['R_p'] / a) ** 5 + I_p = (const_G * p['M_s'] ** 2 * p['R_p'] ** 5) / a**6 + E_s = n_mm * (p['M_p'] / p['M_s']) * (p['R_s'] / a) ** 5 + I_s = (const_G * p['M_p'] ** 2 * p['R_s'] ** 5) / a**6 + + k, X_all = get_all_m_hansen(e_safe, 2, kmin, kmax) + s_arr = k.astype(float) + sig_scale = max(1e-12, 1e-4 * n_mm) + sigma_0 = -s_arr * n_mm + sigma_p2 = 2 * Omega_p - s_arr * n_mm + sigma_s2 = 2 * Omega_s - s_arr * n_mm + + K_p0 = np.abs(LNk_p_m0.imag) * smooth_sign(sigma_0, sig_scale) + K_p2 = np.abs(LNk_p_m2.imag) * smooth_sign(sigma_p2, sig_scale) + K_s0 = np.abs(LNk_s_m0.imag) * smooth_sign(sigma_0, sig_scale) + K_s2 = np.abs(LNk_s_m2.imag) * smooth_sign(sigma_s2, sig_scale) + + X_0 = X_all[0] + X_2 = X_all[2] + X0_sq = X_0**2 + X2_sq = X_2**2 + sqrt_e = np.sqrt(1.0 - e_safe**2) + + # Eqs 129, 131, 132 from Correia & Valente (2022) + dOmega_p = np.sum(K_p2 * X2_sq) + dOmega_s = np.sum(K_s2 * X2_sq) + da_p = np.sum(s_arr * (K_p0 * X0_sq + 3.0 * K_p2 * X2_sq)) + da_s = np.sum(s_arr * (K_s0 * X0_sq + 3.0 * K_s2 * X2_sq)) + de_p = np.sum( + K_p0 * X0_sq * s_arr * sqrt_e - 3.0 * K_p2 * X2_sq * (2.0 - s_arr * sqrt_e) + ) + de_s = np.sum( + K_s0 * X0_sq * s_arr * sqrt_e - 3.0 * K_s2 * X2_sq * (2.0 - s_arr * sqrt_e) + ) + + sqrt_term = np.sqrt(1.0 - e_safe**2) + da_dt_p = a * (E_p / 2.0) * da_p + da_dt_s = a * (E_s / 2.0) * da_s + de_dt_p = (E_p * sqrt_term / (4.0 * e_safe)) * de_p + de_dt_s = (E_s * sqrt_term / (4.0 * e_safe)) * de_s + + return [ + domega_dt(I_p, p['C_p'], dOmega_p), + domega_dt(I_s, p['C_s'], dOmega_s), + da_dt_p + da_dt_s, + de_dt_p + de_dt_s, + da_dt_p, + da_dt_s, + de_dt_p, + de_dt_s, + ] + + # Integration + solver = config.orbit.solver + log.debug('Integrating the ps1d orbital model with solve_ivp') + sol = solve_ivp( + fun=lambda t, y: orbitals(t, y, params), + t_span=(0, dt), + y0=y0, + method=solver.method, + rtol=solver.rtol, + atol=solver.atol, + ) + + # Compute total angular momentum at the end of the integration + L_final = ( + params['C_p'] * sol.y[0][-1] + + params['C_s'] * sol.y[1][-1] + + (params['M_p'] * params['M_s']) + / (params['M_p'] + params['M_s']) + * np.sqrt( + const_G * (params['M_p'] + params['M_s']) * sol.y[2][-1] * (1 - sol.y[3][-1] ** 2) + ) + ) + + # Compute total energy dissipated by tides over the time step + dE_tide_p, _ = dE_dt(y0, params) + + # log energy per surface area for debugging + energy_per_area = dE_tide_p / (4 * np.pi * params['R_p'] ** 2) + log.debug( + f'Total tidal power: {dE_tide_p:.3e} W, Energy per unit area: {energy_per_area:.3e} W/m^2' + ) + + # Exact, solver-consistent split of the changes accumulated over this step + da_planet_tide = sol.y[4][-1] - sol.y[4][0] + da_sat_tide = sol.y[5][-1] - sol.y[5][0] + de_planet_tide = sol.y[6][-1] - sol.y[6][0] + de_sat_tide = sol.y[7][-1] - sol.y[7][0] + + # Self-consistency check: the two contributions must sum to the total change + da_total_check = da_planet_tide + da_sat_tide - (sol.y[2][-1] - sol.y[2][0]) + de_total_check = de_planet_tide + de_sat_tide - (sol.y[3][-1] - sol.y[3][0]) + log.debug(f'tidal split residuals: da={da_total_check:.3e} m, de={de_total_check:.3e}') + + # Update semimajor axis and axial period + hf_row['sma_dot_planet'] = da_planet_tide / dt # m/s, planet-raised tide + hf_row['sma_dot_sat'] = da_sat_tide / dt # m/s, satellite-raised tide + hf_row['ecc_dot_planet'] = de_planet_tide / dt # 1/s + hf_row['ecc_dot_sat'] = de_sat_tide / dt # 1/s + + hf_row['axial_period'] = 2 * np.pi / sol.y[0][-1] + hf_row['axial_period_sat'] = 2 * np.pi / sol.y[1][-1] + hf_row['semimajorax_sat'] = sol.y[2][-1] + # Circularization (e -> 0) is a valid terminal state, but the ODE can + # cross exactly zero and land on a floating-point-noise-scale negative + # value; left unclamped, _state_is_valid's e < 0.0 check rejects that + # step forever and the step-size controller collapses trying to + # satisfy an unsatisfiable condition. Clamp rather than loosen the + # validity check. + hf_row['eccentricity_sat'] = max(sol.y[3][-1], 0.0) + hf_row['plan_sat_am'] = L_final + + +def ps1d_evec( + hf_row, + tides_o, + dt, + config, + fine_sink=None, + fine_stride=1, + filter_value=None, + t_abs_start_yr=None, +): + """Evolve planet-satellite orbit (spins, semi-major axis, eccentricity, evection_angle). + + Combines the Correia & Valente (2022) secular model with Rufu & Canup (2020) + evection-resonance terms. See "Evection resonance" in docs/Explanations/orbit.md. + + Parameters + ---------- + hf_row : dict + Current runtime variables + tides_o : Tides_t + Tidal interactions container + dt : float + Integration time step [yr] + config : Config + Configuration options (reads `config.orbit.solver` for `solve_ivp` parameters) + fine_sink : list, optional + List to append solver accepted-step samples for high-resolution inspection + fine_stride : int, default=1 + Downsampling stride for internal solver steps saved to `fine_sink` + filter_value : float, optional + Resonance activation weight (0.0 to 1.0) gating only `dw_evection_oscillating`. + Secular apsidal precession remains active when 0.0. Default is 1.0 + t_abs_start_yr : float, optional + Absolute start time [yr] for `fine_sink` timestamps; defaults to `hf_row['Time']` + """ + + # Convert time to seconds + dt = float(dt) * secs_per_year + + # Orbital parameters from helpfile + axial_p = 2 * np.pi / float(hf_row['axial_period']) + axial_s = 2 * np.pi / float(hf_row['axial_period_sat']) + sma = float(hf_row['semimajorax_sat']) + ecc = float(hf_row['eccentricity_sat']) + evection_angle = float(hf_row['evection_angle']) + + # Setup Initial State and Parameters + y0 = [ + axial_p, + axial_s, + sma, + ecc, + evection_angle, + 0.0, # cumulative delta-a from planet-raised tide + 0.0, # cumulative delta-a from satellite-raised tide + 0.0, # cumulative delta-e from planet-raised tide + 0.0, # cumulative delta-e from satellite-raised tide + ] + + # Mean motion of star-planet system + n_star = np.sqrt( + const_G * (hf_row['M_star'] + hf_row['M_int']) / hf_row['semimajorax'] ** 3 + ) + + params = { + 'M_p': hf_row['M_int'], + 'M_s': hf_row['M_sat'], + 'R_p': hf_row['R_int'], + 'R_s': hf_row['R_sat'], + 'C_p': hf_row['C_int'], + 'C_s': hf_row['C_sat'], + 'n_star': n_star, + 'J_struc': 0.315, + } + + # Retrieve tidal mode information from tides_o object + nmk_p = np.asarray(tides_o.get(primary='planet', perturber='satellite').nmk) + LNk_p = np.asarray(tides_o.get(primary='planet', perturber='satellite').LNk) + + nmk_s = np.asarray(tides_o.get(primary='satellite', perturber='planet').nmk) + LNk_s = np.asarray(tides_o.get(primary='satellite', perturber='planet').LNk) + + # Combine both sides so kmin/kmax cover whichever needs a wider m=0 mirror + kmin, kmax = kmin_kmax_for_m0_mirror(np.vstack([nmk_p, nmk_s])) + n_k = kmax - kmin + 1 + + def _dense_love(nmk, LNk, m_target): + # Sparse-mode-safe scatter (real tidal-mode data doesn't have a row + # for every integer s in [kmin, kmax]) PLUS an m=0/s<0 mirror fix. + mask = (nmk[:, 1] == m_target) & (nmk[:, 2] >= kmin) & (nmk[:, 2] <= kmax) + dense = np.zeros(n_k, dtype=complex) + dense[(nmk[mask, 2] - kmin).astype(int)] = LNk[mask] + if m_target == 0: + pos_mask = mask & (nmk[:, 2] > 0) + s_pos = nmk[pos_mask, 2].astype(int) + neg_idx = -s_pos - kmin + valid = (neg_idx >= 0) & (neg_idx < n_k) + dense[neg_idx[valid]] = np.conj(LNk[pos_mask][valid]) + return dense + + LNk_p_m0 = _dense_love(nmk_p, LNk_p, 0) + LNk_p_m2 = _dense_love(nmk_p, LNk_p, 2) + LNk_s_m0 = _dense_love(nmk_s, LNk_s, 0) + LNk_s_m2 = _dense_love(nmk_s, LNk_s, 2) + + def domega_dt(I_j, C_j, sum_dOmega): + """Planar secular tidal spin""" + # Eq 132 from Correia & Valente (2022) + return -(3.0 * I_j / (2.0 * C_j)) * sum_dOmega + + def dw_dt(e, e_safe, n_mm, n_star, phi, dw_J2, E_p, E_s, sum_dw_p, sum_dw_s, scale_width): + """Apsidal precession / Evection Angle""" + # Eq 11 from Rufu & Canup (2020) + prefactor = 1.0 / (e_safe**2 * np.sqrt(1.0 - e_safe**2)) + + dw_tide_p = E_p * prefactor * sum_dw_p + dw_tide_s = E_s * prefactor * sum_dw_s + + dw_secular_star = (3.0 / 4.0) * (n_star**2 / n_mm) * np.sqrt(1.0 - e_safe**2) + dw_secular_total = dw_J2 + dw_tide_p + dw_tide_s + dw_secular_star + + if filter_value is not None: + local_filter = filter_value + else: + local_filter = 1.0 + + dw_evection_oscillating = ( + (15.0 / 4.0) * np.sqrt(1.0 - e_safe**2) * (n_star**2 / n_mm) * np.cos(2.0 * phi) + ) + dphi = dw_secular_total + (local_filter * dw_evection_oscillating) - n_star + return dphi, local_filter + + def smooth_sign(sigma, scale=1e-12): + """Smooth approximation to sign(sigma) using tanh to avoid solver kinks.""" + return np.tanh(sigma / scale) + + def smooth_amplitude_near_zero(val_real, sigma, scale): + """Smoothly blend the amplitude of a real value towards a target (1.5) near zero forcing frequency.""" + zero_weight = np.exp(-((sigma / scale) ** 2)) + return zero_weight * 1.5 + (1.0 - zero_weight) * val_real + + def dE_dt(z, p): + """Tidal energy dissipation rate""" + Omega_p, Omega_s, a, e, *_ = z + e_safe = min( + max(e, 1e-12), 1.0 - 1e-9 + ) # symmetric: also guards e briefly exceeding 1 during a solver trial + + n_mm = np.sqrt(const_G * (p['M_p'] + p['M_s']) / a**3) + I_p = (const_G * p['M_s'] ** 2 * p['R_p'] ** 5) / a**6 + I_s = (const_G * p['M_p'] ** 2 * p['R_s'] ** 5) / a**6 + + k, X_all = get_all_m_hansen(e_safe, 2, kmin, kmax) + s_arr = k.astype(float) + + X_0 = X_all[0] + X_2 = X_all[2] + X0_sq = X_0**2 + X2_sq = X_2**2 + + K_p0 = -LNk_p_m0.imag + K_p2 = -LNk_p_m2.imag + K_s0 = -LNk_s_m0.imag + K_s2 = -LNk_s_m2.imag + + # Eqs 133, 134, 135 from Correia & Valente (2022) + dE_orb_p = I_p * n_mm * np.sum(s_arr * (K_p0 * X0_sq + 3.0 * K_p2 * X2_sq)) / 4 + dE_orb_s = I_s * n_mm * np.sum(s_arr * (K_s0 * X0_sq + 3.0 * K_s2 * X2_sq)) / 4 + + dE_rot_p = -I_p * 3 * Omega_p * np.sum(K_p2 * X2_sq) / 2 + dE_rot_s = -I_s * 3 * Omega_s * np.sum(K_s2 * X2_sq) / 2 + + return -(dE_orb_p + dE_rot_p), -(dE_orb_s + dE_rot_s) + + def orbitals(t, z, p): + Omega_p, Omega_s, a, e, phi, *_ = z + e_safe = min( + max(e, 1e-12), 1.0 - 1e-9 + ) # symmetric: also guards e briefly exceeding 1 during a solver trial + + # Eqs 91 and 84 from Correia & Valente (2022) + n_mm = np.sqrt(const_G * (p['M_p'] + p['M_s']) / a**3) + E_p = n_mm * (p['M_s'] / p['M_p']) * (p['R_p'] / a) ** 5 + I_p = (const_G * p['M_s'] ** 2 * p['R_p'] ** 5) / a**6 + E_s = n_mm * (p['M_p'] / p['M_s']) * (p['R_s'] / a) ** 5 + I_s = (const_G * p['M_p'] ** 2 * p['R_s'] ** 5) / a**6 + + # Below Eq 11 from Rufu & Canup (2020) + Omega_b = np.sqrt(const_G * p['M_p'] / p['R_p'] ** 3) + J2 = p['J_struc'] * (Omega_p / Omega_b) ** 2 + dw_J2 = 1.5 * J2 * n_mm * (p['R_p'] / a) ** 2 / (1.0 - e_safe**2) ** 2 + + k, X_all = get_all_m_hansen(e_safe, 2, kmin, kmax) + + s_arr = k.astype(float) + sig_scale = max(1e-12, 1e-4 * n_mm) + sigma_0 = -s_arr * n_mm + sigma_p2 = 2 * Omega_p - s_arr * n_mm + sigma_s2 = 2 * Omega_s - s_arr * n_mm + + A_p0 = smooth_amplitude_near_zero(LNk_p_m0.real, sigma_0, sig_scale) + A_p2 = smooth_amplitude_near_zero(LNk_p_m2.real, sigma_p2, sig_scale) + A_s0 = smooth_amplitude_near_zero(LNk_s_m0.real, sigma_0, sig_scale) + A_s2 = smooth_amplitude_near_zero(LNk_s_m2.real, sigma_s2, sig_scale) + K_p0 = np.abs(LNk_p_m0.imag) * smooth_sign(sigma_0, sig_scale) + K_p2 = np.abs(LNk_p_m2.imag) * smooth_sign(sigma_p2, sig_scale) + K_s0 = np.abs(LNk_s_m0.imag) * smooth_sign(sigma_0, sig_scale) + K_s2 = np.abs(LNk_s_m2.imag) * smooth_sign(sigma_s2, sig_scale) + + X_0 = X_all[0] + X_2 = X_all[2] + X_m1 = X_all[-1] + X_1 = X_all[1] + X_m2 = X_all[-2] + X0_sq = X_0**2 + X2_sq = X_2**2 + sqrt_e = np.sqrt(1.0 - e_safe**2) + + # Eqs 129, 131, 132 from Correia & Valente (2022) + # Eqs 10, 11 from Rufu & Canup (2020) + dOmega_p = np.sum(K_p2 * X2_sq) + dOmega_s = np.sum(K_s2 * X2_sq) + da_p = np.sum(s_arr * (K_p0 * X0_sq + 3.0 * K_p2 * X2_sq)) + da_s = np.sum(s_arr * (K_s0 * X0_sq + 3.0 * K_s2 * X2_sq)) + de_p = np.sum( + K_p0 * X0_sq * s_arr * sqrt_e - 3.0 * K_p2 * X2_sq * (2.0 - s_arr * sqrt_e) + ) + de_s = np.sum( + K_s0 * X0_sq * s_arr * sqrt_e - 3.0 * K_s2 * X2_sq * (2.0 - s_arr * sqrt_e) + ) + term0 = ( + 2.0 * e_safe**2 * X0_sq + + e_safe**2 * X_0 * (X_m2 + X_2) + + 2.0 * e_safe * X_0 * (X_m1 + X_1) + ) + term2 = ( + (12.0 * (2.0 - s_arr * sqrt_e**3) - 9.0 * e_safe**2) * X2_sq + + 3.0 * e_safe**2 * X_2 * X_m2 + + (4.0 * s_arr * sqrt_e**3 - 6.0 * e_safe**2) * X_0 * X_2 + + 6.0 * e_safe * X_2 * (X_m1 + X_1) + ) + dw_p = np.sum((3.0 / 16.0) * A_p0 * term0 - (1.0 / 16.0) * A_p2 * term2) + dw_s = np.sum((3.0 / 16.0) * A_s0 * term0 - (1.0 / 16.0) * A_s2 * term2) + + sums = {'dw_p': dw_p, 'dw_s': dw_s} + + dphi_dt, r_filter = dw_dt( + e, + e_safe, + n_mm, + p['n_star'], + phi, + dw_J2, + E_p, + E_s, + sums['dw_p'], + sums['dw_s'], + scale_width=1e-8, + ) + + sqrt_term = np.sqrt(1.0 - e_safe**2) + da_dt_p = a * (E_p / 2.0) * da_p + da_dt_s = a * (E_s / 2.0) * da_s + de_dt_p = (E_p * sqrt_term / (4.0 * e_safe)) * de_p + de_dt_s = (E_s * sqrt_term / (4.0 * e_safe)) * de_s + de_res = ( + (15.0 / 4.0) * e_safe * sqrt_term * (p['n_star'] ** 2 / n_mm) * np.sin(2.0 * phi) + ) + + return [ + domega_dt(I_p, p['C_p'], dOmega_p), + domega_dt(I_s, p['C_s'], dOmega_s), + da_dt_p + da_dt_s, + de_dt_p + de_dt_s + (r_filter * de_res), + dphi_dt, + da_dt_p, + da_dt_s, + de_dt_p, + de_dt_s, + ] + + # Integration + solver = config.orbit.solver + log.debug('Integrating the ps1d_evec orbital model with solve_ivp') + sol = solve_ivp( + fun=lambda t, y: orbitals(t, y, params), + t_span=(0, dt), + y0=y0, + method=solver.method, + rtol=solver.rtol, + atol=solver.atol, + ) + + y_end = sol.y[:, -1] + + # Compute total angular momentum at the end of the integration + L_final = ( + params['C_p'] * y_end[0] + + params['C_s'] * y_end[1] + + (params['M_p'] * params['M_s']) + / (params['M_p'] + params['M_s']) + * np.sqrt(const_G * (params['M_p'] + params['M_s']) * y_end[2] * (1 - y_end[3] ** 2)) + ) + + dE_tide_p, _ = dE_dt(y0, params) + energy_per_area = dE_tide_p / (4 * np.pi * params['R_p'] ** 2) + log.debug( + f'Total tidal power: {dE_tide_p:.3e} W, Energy per unit area: {energy_per_area:.3e} W/m^2' + ) + + da_planet_tide = sol.y[5][-1] - sol.y[5][0] + da_sat_tide = sol.y[6][-1] - sol.y[6][0] + de_planet_tide = sol.y[7][-1] - sol.y[7][0] + de_sat_tide = sol.y[8][-1] - sol.y[8][0] + + if fine_sink is not None: + # Populates fine_sink with the solver-clock samples (band- + # independent); does not touch disk or apply the storage-clock + # throttle, which is the caller's job. + t_start = t_abs_start_yr if t_abs_start_yr is not None else hf_row['Time'] + t_abs_yr = t_start + sol.t / secs_per_year + omega_p_f = sol.y[0] + omega_s_f = sol.y[1] + sma_f = sol.y[2] + ecc_f = sol.y[3] + phi_f = sol.y[4] + cum_da_p_f = sol.y[5] + cum_da_s_f = sol.y[6] + cum_de_p_f = sol.y[7] + cum_de_s_f = sol.y[8] + + # Band-independent solver-clock decimation (storage/plotting + # thinning only, not a physics decision. + if fine_stride is not None and fine_stride > 1: + keep = np.zeros(len(t_abs_yr), dtype=bool) + keep[::fine_stride] = True + keep[-1] = True + else: + keep = np.ones(len(t_abs_yr), dtype=bool) + + t_abs_yr_k = t_abs_yr[keep] + omega_p_k = omega_p_f[keep] + omega_s_k = omega_s_f[keep] + sma_k = sma_f[keep] + ecc_k = ecc_f[keep] + phi_k = phi_f[keep] + cum_da_p_k = cum_da_p_f[keep] + cum_da_s_k = cum_da_s_f[keep] + cum_de_p_k = cum_de_p_f[keep] + cum_de_s_k = cum_de_s_f[keep] + filter_k = np.full(len(t_abs_yr_k), filter_value) + + fine_sink.append( + { + 't_abs_yr': t_abs_yr_k, + 'omega_p': omega_p_k, + 'omega_s': omega_s_k, + 'sma': sma_k, + 'ecc': ecc_k, + 'phi': phi_k, + 'da_planet_tide_cum': cum_da_p_k, + 'da_sat_tide_cum': cum_da_s_k, + 'de_planet_tide_cum': cum_de_p_k, + 'de_sat_tide_cum': cum_de_s_k, + 'filter': filter_k, + } + ) + + hf_row['sma_dot_planet'] = da_planet_tide / dt + hf_row['sma_dot_sat'] = da_sat_tide / dt + hf_row['ecc_dot_planet'] = de_planet_tide / dt + hf_row['ecc_dot_sat'] = de_sat_tide / dt + + hf_row['axial_period'] = 2 * np.pi / y_end[0] + hf_row['axial_period_sat'] = 2 * np.pi / y_end[1] + hf_row['semimajorax_sat'] = y_end[2] + # Circularization (e -> 0) is a valid terminal state, but the ODE can + # cross exactly zero and land on a floating-point-noise-scale negative + # value; left unclamped, _state_is_valid's e < 0.0 check rejects that + # step forever and evolve_orbit_satellite's step-size controller + # collapses trying to satisfy an unsatisfiable condition. Clamp rather + # than loosen the validity check. + hf_row['eccentricity_sat'] = max(y_end[3], 0.0) + hf_row['evection_angle'] = y_end[4] + hf_row['plan_sat_am'] = L_final diff --git a/src/proteus/orbit/timestep.py b/src/proteus/orbit/timestep.py new file mode 100644 index 000000000..f5c63c013 --- /dev/null +++ b/src/proteus/orbit/timestep.py @@ -0,0 +1,113 @@ +# Evection-resonance dt-cap logic for the planet-satellite orbit model. +# See "Evection resonance" in docs/Explanations/orbit.md. +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from proteus.orbit.common import Tides_t + +if TYPE_CHECKING: + from proteus.config import Config + + +def _evection_rate_cap_yr(tides_o: Tides_t, zone_active: bool, config: Config) -> float: + """Bound the NEXT macro-step by the secular rate of change of the + satellite eccentricity, while the system is inside or approaching the + evection resonance band (see docs/Explanations/orbit.md). Fit over + ``tides_o.evection_ecc_history``, a rolling window maintained by + ``satellite.evolve_orbit_satellite``. + + Returns ``np.inf`` when the cap does not apply. + """ + # Check configured maximum step size for evection resonance + evection_max_cfg = config.params.dt.evection_maximum + if evection_max_cfg is None or not zone_active: + return np.inf + evection_max = float(evection_max_cfg) + + # Check the history of eccentricity samples + history = tides_o.evection_ecc_history + if len(history) < 2: + # No history to derive a rate from yet, fall back to the ceiling + return evection_max + + # Determine the window size for the rate calculation, and extract the recent samples + window = max(2, int(getattr(config.params.dt, 'evection_rate_window', 2))) + n_use = min(window, len(history)) + recent = history[-n_use:] + t_window = np.array([t for t, _ in recent], dtype=float) + e_window = np.array([e for _, e in recent], dtype=float) + + # Compute the secular rate of change of eccentricity over the window + dt_span = t_window[-1] - t_window[0] + if dt_span <= 0.0: + # No time span to derive a rate from, fall back to the ceiling + return evection_max + + # Compute the secular rate of change of eccentricity over the window + if n_use == 2: + # Simple two-point slope + de_dt = abs(e_window[-1] - e_window[0]) / dt_span + else: + # Least-squares secular slope: uses every sample in the window to + # average out short-period oscillations. + slope, _ = np.polyfit(t_window, e_window, 1) + de_dt = abs(float(slope)) + + if de_dt <= 0.0: + # No net secular trend over the window; fall back to the + # ceiling rather than letting a naive e/de_dt blow up. + return evection_max + + # Compute the cap on the next step size from the observed |de/dt| + e_now = float(e_window[-1]) + target_rel_de = float(getattr(config.params.dt, 'evection_target_rel_de', 0.05)) + de_floor = float(getattr(config.params.dt, 'evection_de_floor', 0.02)) + e_ref = max(e_now, de_floor) + + dt_rate_cap = target_rel_de * e_ref / de_dt + return min(evection_max, dt_rate_cap) + + +def _estimate_evection_dt_cap_yr( + tides_o: Tides_t, zone_active: bool, dt_prev_actual_yr: float, config: Config +) -> float: + """Bound the NEXT macro-step via evection + Controls ``hf_row['evection_dt_cap_yr']``, folding together the + secular-rate cap (``_evection_rate_cap_yr``) and a growth limiter that + bounds dt to ``dt_prev_actual_yr * evection_growth_factor`` while the + zone is active, or for ``evection_cooldown_iters`` calls after it was + last active (see docs/Explanations/orbit.md, "Evection resonance"). + + Returns ``np.inf`` when neither bound applies. + """ + dt_cfg = config.params.dt + + # Compute the secular-rate cap on the next step size from the observed |de/dt| + rate_cap = _evection_rate_cap_yr(tides_o, zone_active, config) + + # Compute the growth limiter + evection_growth_cfg = getattr(dt_cfg, 'evection_growth_factor', None) + cooldown_remaining = int(tides_o.evection_cooldown_remaining) + growth_cap = np.inf + # The growth limiter is only active (evection_growth_factor is not + # None) while the zone is active, or for a short cooldown period after + # it was last active. + if evection_growth_cfg is not None and (zone_active or cooldown_remaining > 0): + if dt_prev_actual_yr is not None and dt_prev_actual_yr > 0.0: + growth_cap = dt_prev_actual_yr * float(evection_growth_cfg) + + # Refresh/decrement the cooldown counter for the NEXT call, using the + # zone state observed THIS call. evection_cooldown_iters=None means no + # cooldown tail, same as 0. + evection_cooldown_iters_cfg = getattr(dt_cfg, 'evection_cooldown_iters', None) + if zone_active: + tides_o.evection_cooldown_remaining = ( + int(evection_cooldown_iters_cfg) if evection_cooldown_iters_cfg is not None else 0 + ) + elif cooldown_remaining > 0: + tides_o.evection_cooldown_remaining = cooldown_remaining - 1 + + return min(rate_cap, growth_cap) diff --git a/src/proteus/orbit/wrapper.py b/src/proteus/orbit/wrapper.py index 91fc96b95..50fadc825 100644 --- a/src/proteus/orbit/wrapper.py +++ b/src/proteus/orbit/wrapper.py @@ -7,7 +7,18 @@ import numpy as np from proteus.interior_energetics.common import Interior_t -from proteus.utils.constants import AU, L_sun, R_sun, const_G, secs_per_day, secs_per_hour +from proteus.orbit.common import Tides_t +from proteus.utils.constants import ( + AU, + L_sun, + M_earth, + R_earth, + R_sun, + const_G, + secs_per_day, + secs_per_hour, +) +from proteus.utils.helper import UpdateStatusfile if TYPE_CHECKING: from proteus import Proteus @@ -32,6 +43,23 @@ def init_orbit(handler: Proteus): from proteus.orbit.lovepy import import_lovepy import_lovepy() + elif module == 'obliqua': + from proteus.orbit.obliqua import import_obliqua, setup_logging + + import_obliqua(handler.directories) + # setup logging for Obliqua + setup_logging(handler.directories, handler.config.orbit.obliqua.verbosity) + + if handler.config.orbit.planet_satellite_model in ['ps1d', 'ps1d_evec']: + # ps1d/ps1d_evec read the satellite's tidal response from Obliqua's + # lookup table (LN_from_lookup) regardless of which module handles + # the planet's own tides, so Obliqua must be active even when + # module == 'lovepy'. + from proteus.orbit.obliqua import import_obliqua, setup_logging + + import_obliqua(handler.directories) + # setup logging for Obliqua + setup_logging(handler.directories, handler.config.orbit.obliqua.verbosity) def update_separation(hf_row: dict): @@ -48,19 +76,38 @@ def update_separation(hf_row: dict): Current helpfile row """ - sma = hf_row['semimajorax'] # already in SI units + sma = hf_row['semimajorax'] ecc = hf_row['eccentricity'] - sma_sat = hf_row['semimajorax_sat'] # already in SI units - # Time-averaged separation hf_row['separation'] = sma * (1 + 0.5 * ecc * ecc) # Periapsis distance around star hf_row['perihelion'] = sma * (1 - ecc) - # Periapsis distance around planet (assuming circular orbiting satellite) - hf_row['perigee'] = sma_sat + +def update_separation_sat(hf_row: dict): + """ + Calculate time-averaged orbital separation on an elliptical path. + https://physics.stackexchange.com/a/715749 + + Calculate periapsis distance on an elliptical path. + https://mathworld.wolfram.com/Periapsis.html + + Parameters + ------------- + hf_row: dict + Current helpfile row + """ + + sma = hf_row['semimajorax_sat'] + ecc = hf_row['eccentricity_sat'] + + # Time-averaged separation + hf_row['separation_sat'] = sma * (1 + 0.5 * ecc * ecc) + + # Periapsis distance around star + hf_row['perigee'] = sma * (1 - ecc) def update_period(hf_row: dict): @@ -93,6 +140,36 @@ def update_period(hf_row: dict): hf_row['orbital_period'] = 2 * np.pi * (sma * sma * sma / mu) ** 0.5 +def update_period_sat(hf_row: dict): + """ + Calculate orbital and axial periods, on an elliptical path for satellite. + + Assuming that M_volatiles << M_satellite + M_mantle + M_core. + https://en.wikipedia.org/wiki/Elliptic_orbit#Orbital_period + + Parameters + ------------- + hf_row: dict + Current helpfile row + """ + + # Total mass of system, kg + M_total = hf_row['M_planet'] + hf_row['M_sat'] + + # Sanity check + if M_total < 1e3: + log.error('Unreasonable planet+satellite mass: %.5e kg' % M_total) + + # Standard gravitational parameter (planet mass + satellite mass) + mu = const_G * M_total + + # Semimajor axis is already in SI units + sma = hf_row['semimajorax_sat'] + + # Orbital period [seconds] + hf_row['orbital_period_sat'] = 2 * np.pi * (sma * sma * sma / mu) ** 0.5 + + def update_hillradius(hf_row: dict): """ Calculate Hill radius. @@ -132,6 +209,25 @@ def update_rochelimit(hf_row: dict): hf_row['roche_limit'] = Rpl * (2 * Mst / Mpl) ** (1.0 / 3) +def update_rochelimit_sat(hf_row: dict): + """ + Calculate Roche limit for the satellite. + + Using equation from: http://astro.vaporia.com/start/rochelimit.html + + Parameters + ------------- + hf_row: dict + Current helpfile row + """ + + Rsa = hf_row['R_sat'] + Msa = hf_row['M_sat'] + Mpl = hf_row['M_int'] + + hf_row['roche_limit_sat'] = Rsa * (2 * Mpl / Msa) ** (1.0 / 3) + + def update_breakup_period(hf_row: dict): """ Calculate Breakup period. @@ -152,7 +248,29 @@ def update_breakup_period(hf_row: dict): hf_row['breakup_period'] = 2 * np.pi / np.sqrt(const_G * Mpl / (Rpl**3)) -def run_orbit(hf_row: dict, config: Config, dirs: dict, interior_o: Interior_t): +def update_breakup_period_sat(hf_row: dict): + """ + Calculate Breakup period for the satellite. + + Using equation from: https://arxiv.org/abs/2508.09273 + (Note, the equation contains a typo, it should + read: 2pi/T = Ω = sqrt( G Mp / Rp^3 ). ) + + Parameters + ------------- + hf_row: dict + Current helpfile row + """ + + Rsa = hf_row['R_sat'] + Msa = hf_row['M_sat'] + + hf_row['breakup_period_sat'] = 2 * np.pi / np.sqrt(const_G * Msa / (Rsa**3)) + + +def run_orbit( + hf_row: dict, config: Config, dirs: dict, tides_o: Tides_t, interior_o: Interior_t +): """Update parameters relating to orbital evolution and tides. Parameters @@ -163,23 +281,22 @@ def run_orbit(hf_row: dict, config: Config, dirs: dict, interior_o: Interior_t): Model configuration. dirs: dict Dictionary of directories. + tides_o: Tides_t + Tides data containing Imk2 spectra at current time. interior_o: Interior_t Struct containing interior arrays at current time. """ log.info('Evolve orbit and tides...') - # Set semimajor axis and eccentricity, through the desired method... - if config.orbit.evolve: - # set by orbital evolution, based on tidal love number - from proteus.orbit.orbit import evolve_orbital + # Time step + current_time = float(hf_row['Time']) - evolve_orbital(hf_row, config, interior_o.dt) - - else: - # orbital parameters are held constant over time - hf_row['eccentricity'] = config.orbit.eccentricity + # Use config parameters as initial guess + if current_time <= 1: + # Set independent orbital parameters from config. hf_row['semimajorax'] = config.orbit.semimajoraxis * AU + hf_row['eccentricity'] = config.orbit.eccentricity # set semi-major axis to obtain a particular bolometric instellation flux if config.orbit.instellation_method == 'inst' and config.star.module == 'dummy': @@ -191,25 +308,8 @@ def run_orbit(hf_row: dict, config: Config, dirs: dict, interior_o: Interior_t): hf_row['semimajorax'] = np.sqrt(Lbol / (4 * np.pi * S_0)) - # Inform user - log.info(' Orb SMaxis = %.5f AU' % (hf_row['semimajorax'] / AU)) - log.info(' Orb eccent = %.5f ' % (hf_row['eccentricity'])) - - # Update orbital separation and period, from other variables above - update_separation(hf_row) - update_period(hf_row) - - log.info(' Orb period = %.5f days' % (hf_row['orbital_period'] / secs_per_day)) - - if config.orbit.satellite: - # set by orbital evolution, based on tidal love number - from proteus.orbit.satellite import update_satellite - - update_satellite(hf_row, config, interior_o.dt) - - else: - # Satellite SMA - hf_row['semimajorax_sat'] = float(config.orbit.semimajoraxis_sat) + # Update orbital period (dependent) + update_period(hf_row) # Axial period [seconds] if config.orbit.axial_period is None: @@ -223,6 +323,116 @@ def run_orbit(hf_row: dict, config: Config, dirs: dict, interior_o: Interior_t): hf_row['longitude'] = 0.0 hf_row['latitude'] = 0.0 + # Set independent satellite orbital parameters, if included + if config.orbit.satellite.include_satellite: + hf_row['M_sat'] = config.orbit.satellite.mass_sat * M_earth # [kg] + hf_row['R_sat'] = config.orbit.satellite.radius_sat * R_earth # [m] + hf_row['C_sat'] = ( + config.orbit.satellite.c_factor_sat * hf_row['M_sat'] * hf_row['R_sat'] ** 2 + ) + + hf_row['semimajorax_sat'] = ( + config.orbit.satellite.semimajoraxis_sat * R_earth + ) # [m] + hf_row['eccentricity_sat'] = config.orbit.satellite.eccentricity_sat + + hf_row['evection_angle'] = np.deg2rad(config.orbit.satellite.evection_angle) + + # Update satellite orbital period (dependent) + update_period_sat(hf_row) + + # Axial period [seconds] + if config.orbit.satellite.axial_period_sat is None: + # set by user to 'none', use 1:1 SOR + hf_row['axial_period_sat'] = hf_row['orbital_period_sat'] + else: + # set by user with float, use that + hf_row['axial_period_sat'] = ( + float(config.orbit.satellite.axial_period_sat) * secs_per_hour + ) + + # initialize the Hansen coefficient table + if config.orbit.planet_satellite_model in ['ps1d', 'ps1d_evec']: + from proteus.orbit.hansen import init_hansen_table, init_k_range_table + + init_k_range_table() + + e_grid_wide = np.concatenate( + [ + np.arange(0.0, 0.1, 0.002), + np.arange(0.1, 0.9, 0.003), + ] + ) + init_hansen_table(e_grid_wide) + + else: + # Set independent orbital parameters, through the desired method... (Star-Planet) + if config.orbit.star_planet_model is not None: + # set by orbital evolution, based on tidal love number + from proteus.orbit.orbit import evolve_orbit_star + + try: + evolve_orbit_star(hf_row, config, dirs, tides_o, interior_o) + except Exception as err: + UpdateStatusfile(dirs, 26) + raise RuntimeError( + f'Star-Planet orbital evolution failed for Time={float(hf_row["Time"]):.6e} yr ' + f'(model={config.orbit.star_planet_model})' + ) from err + + else: + # set semi-major axis to obtain a particular bolometric instellation flux + if config.orbit.instellation_method == 'inst' and config.star.module == 'dummy': + from proteus.star.dummy import calc_star_luminosity, get_star_radius + + Lbol = calc_star_luminosity( + config.star.dummy.Teff, get_star_radius(config) * R_sun + ) + S_earth = L_sun / (4 * np.pi * AU * AU) + S_0 = config.orbit.instellationflux * S_earth + + hf_row['semimajorax'] = np.sqrt(Lbol / (4 * np.pi * S_0)) + + # Set independent orbital parameters, through the desired method... (Planet-Satellite) + if config.orbit.planet_satellite_model is not None: + # set by orbital evolution, based on tidal love number + from proteus.orbit.satellite import evolve_orbit_satellite + + try: + evolve_orbit_satellite(hf_row, config, dirs, tides_o, interior_o) + except Exception as err: + UpdateStatusfile(dirs, 26) + raise RuntimeError( + f'Planet-Satellite orbital evolution failed for Time={float(hf_row["Time"]):.6e} yr ' + f'(model={config.orbit.planet_satellite_model})' + ) from err + + # Update orbital period, from independent variables above + update_period(hf_row) + + # Update satellite orbital period, from independent variables above + update_period_sat(hf_row) + + # Inform user + log.info(' Planet SMaxis = %.5f AU ' % (hf_row['semimajorax'] / AU)) + log.info(' Planet eccent = %.5f ' % (hf_row['eccentricity'])) + log.info(' Planet period = %.5f days' % (hf_row['orbital_period'] / secs_per_day)) + log.info(' Planet day = %.5f days' % (hf_row['axial_period'] / secs_per_day)) + + if config.orbit.satellite.include_satellite: + log.info(' Satellite SMaxis = %.5f AU' % (hf_row['semimajorax_sat'] / AU)) + log.info(' Satellite eccent = %.5f ' % (hf_row['eccentricity_sat'])) + log.info( + ' Satellite period = %.5f days' % (hf_row['orbital_period_sat'] / secs_per_day) + ) + log.info(' Satellite day = %.5f days' % (hf_row['axial_period_sat'] / secs_per_day)) + + log.info(' Planet + Sat. AM = %.3e kg.m^2/s' % (hf_row['plan_sat_am'])) + + # Update dependent orbital parameters, from independent variables above + # Update separation + update_separation(hf_row) + # Update Breakup period update_breakup_period(hf_row) if hf_row['axial_period'] <= hf_row['breakup_period'] + float( @@ -246,27 +456,88 @@ def run_orbit(hf_row: dict, config: Config, dirs: dict, interior_o: Interior_t): if max(hf_row['R_obs'], hf_row['R_xuv']) > hf_row['hill_radius']: log.warning('Atmosphere extends beyond the Hill radius') + # Update Breakup period and Roche limit for satellite + if config.orbit.satellite.include_satellite: + # Update separation + update_separation_sat(hf_row) + + update_breakup_period_sat(hf_row) + if hf_row['axial_period_sat'] <= hf_row['breakup_period_sat'] + float( + config.params.stop.disint_sat.offset_spin + ): + log.warning('Satellite is spinning faster than the Breakup rate') + + update_rochelimit_sat(hf_row) + if hf_row['perigee'] <= hf_row['roche_limit_sat'] + float( + config.params.stop.disint_sat.offset_roche + ): + log.warning('Satellite is orbiting within the Roche limit of its planet') + + # If satellite orbit extends beyond the Hill radius, then warn user + if hf_row['semimajorax_sat'] > hf_row['hill_radius']: + log.warning('Satellite orbit extends beyond the Hill radius of its planet') + + # Call tidal heating module, if enabled # Initialise, set tidal heating to zero interior_o.tides = np.zeros(len(interior_o.phi)) # Call tides module, calculates heating rates and new love number if config.orbit.module == 'dummy': - from proteus.orbit.dummy import run_dummy_orbit + from proteus.orbit.dummy import run_dummy_tides - hf_row['Imk2'] = run_dummy_orbit(config, interior_o) + hf_row['Imk2'] = run_dummy_tides(config, interior_o) elif config.orbit.module == 'lovepy': from proteus.orbit.lovepy import run_lovepy - hf_row['Imk2'] = run_lovepy(hf_row, dirs, interior_o, config) + hf_row['Imk2'] = run_lovepy(hf_row, dirs, interior_o, tides_o, config) + + elif config.orbit.module == 'obliqua': + from proteus.orbit.obliqua import run_obliqua + + Imk = run_obliqua(hf_row, dirs, interior_o, tides_o, config) + + if config.orbit.obliqua.n == [2]: + hf_row['Imk2'] = Imk + else: + # Imk2 is only meaningful for degree n=2; for other degrees the + # full spectrum is still available on tides_o (see + # docs/Explanations/orbit.md, "Tidal response modules"). + hf_row['Imk2'] = 0.0 else: hf_row['Imk2'] = 0.0 # Print info - if config.orbit.module is not None: - log.info(' Pla H_tide = %.1e W kg-1 (mean) ' % np.mean(interior_o.tides)) - log.info(' Pla Im(k2) = %.1e ' % hf_row['Imk2']) + if config.orbit.module == 'obliqua': + log.info(' Planet H_tide = %.1e W kg-1 (mean) ' % np.mean(interior_o.tides)) + log.info(' Planet Im(k) = %.1e ' % Imk) + + elif config.orbit.module is not None: + log.info(' Planet H_tide = %.1e W kg-1 (mean) ' % np.mean(interior_o.tides)) + log.info(' Planet Im(k2) = %.1e ' % hf_row['Imk2']) + + # If satellite orbital evolution is enabled, then extract the satellite love number from + # the provided lookup file + if config.orbit.planet_satellite_model in ['ps1d', 'ps1d_evec']: + from proteus.orbit.obliqua import LN_from_lookup + + log.info(' Extracting Love number from satellite lookup table') - # Call tides module for satellite, calculates heating rates and new love number - # To Do + LN_from_lookup(hf_row, dirs, tides_o, config) + + +def read_tides_data(output_dir: str, model: str, times: list): + if len(times) == 0: + return [] + + if model == 'obliqua': + from proteus.orbit.obliqua import read_ncdfs + + return read_ncdfs(output_dir, times) + + else: + log.warning( + f"Cannot read tides data for model '{model}', returning empty list of tides data." + ) + return [] diff --git a/src/proteus/plot/cpl_orbit.py b/src/proteus/plot/cpl_orbit.py index 78ab15caa..1c93b0a2a 100644 --- a/src/proteus/plot/cpl_orbit.py +++ b/src/proteus/plot/cpl_orbit.py @@ -11,7 +11,18 @@ from cmcrameri import cm from mpl_toolkits.axes_grid1 import make_axes_locatable -from proteus.utils.constants import AU, secs_per_hour +from proteus.orbit.satellite import _solve_e_stationary +from proteus.orbit.wrapper import read_tides_data +from proteus.utils.constants import ( + AU, + M_earth, + R_earth, + const_G, + secs_per_day, + secs_per_hour, + secs_per_year, +) +from proteus.utils.plot import sample_output if TYPE_CHECKING: from proteus import Proteus @@ -20,7 +31,11 @@ def plot_orbit( - hf_all: pd.DataFrame, output_dir: str, plot_format: str = 'pdf', t0: float = 100.0 + hf_all: pd.DataFrame, + output_dir: str, + has_sat: bool, + plot_format: str = 'pdf', + t0: float = 100.0, ): time = np.array(hf_all['Time']) if np.amax(time) <= t0: @@ -31,82 +46,172 @@ def plot_orbit( # Plotting parameters lw = 2.0 - figscale = 1.4 + figscale = 1.2 yext = 1.05 - fig, axs = plt.subplots(2, 1, figsize=(5 * figscale, 4 * figscale), sharex=True) - ax_t = axs[0] - ax_b = axs[1] - - # left axis - y = hf_all['semimajorax'] / AU - ax_t.plot(time, y, lw=lw, color='k') - ax_t.set_ylabel('Planet semi-major axis [AU]') - ax_t.set_ylim(0, np.amax(y) * yext) - - # right axis - ax_tr = ax_t.twinx() - color = 'tab:red' - y = hf_all['eccentricity'] - ax_tr.plot(time, y, lw=lw, color=color) - ax_tr.set_ylabel('Planet orbital eccentricity') - ax_tr.yaxis.label.set_color(color) - ax_tr.tick_params(axis='y', colors=color) - ymin = np.amin(y) / yext - ymax = max(np.amax(y) * yext, ymin + 0.01) - ax_tr.set_ylim(ymin, ymax) - - # x-axis - ax_t.set_xscale('log') - ax_t.set_xlim(left=t0, right=np.amax(time)) - ax_t.grid(alpha=0.2) - - # left axis - y = hf_all['semimajorax_sat'] / 1e6 - ax_b.plot(time, y, lw=lw, color='k') - ax_b.set_ylabel(r'Satellite semi-major axis [$10^6$m]') - ax_b.set_ylim(0, np.amax(y) * yext) - - # right axis - ax_br = ax_b.twinx() - color = 'tab:red' - y = hf_all['axial_period'] / secs_per_hour - ax_br.plot(time, y, lw=lw, color=color) - ax_br.set_ylabel('Planet axial period [hours]') - ax_br.yaxis.label.set_color(color) - ax_br.tick_params(axis='y', colors=color) - ax_br.set_ylim(0, np.amax(y) * yext) - - # x-axis - ax_b.set_xlabel('Time [yr]') - ax_b.set_xscale('log') - ax_b.set_xlim(left=t0, right=np.amax(time)) - ax_b.grid(alpha=0.2) - - plt.close() - plt.ioff() + # 3 Rows (Semi-major axis, Eccentricity, Timescales) + # 2 Columns (Planet on left, Satellite on right) + fig, axs = plt.subplots(3, 2, figsize=(11 * figscale, 9 * figscale), sharex=True) + + # ----------------- COLUMN 0: PLANET ----------------- + # Panel 0,0: Planet Semi-major Axis + y_a_pl = hf_all['semimajorax'] / AU + axs[0, 0].plot(time, y_a_pl, lw=lw, color=mpl.rcParams['text.color']) + axs[0, 0].set_ylabel('Semi-major Axis [AU]') + axs[0, 0].set_ylim(np.amin(y_a_pl) / yext, np.amax(y_a_pl) * yext) + axs[0, 0].set_title('Planet Orbiting Star') + axs[0, 0].grid(alpha=0.2) + + # Panel 1,0: Planet Eccentricity + y_e_pl = hf_all['eccentricity'] + axs[1, 0].plot(time, y_e_pl, lw=lw, color='tab:blue') + axs[1, 0].set_ylabel('Eccentricity') + ymin_e_pl = np.amin(y_e_pl) / yext + ymax_e_pl = max(np.amax(y_e_pl) * yext, ymin_e_pl + 0.01) + axs[1, 0].set_ylim(ymin_e_pl, ymax_e_pl) + axs[1, 0].grid(alpha=0.2) + + # Panel 2,0: Planet Rotational & Orbital Periods (Time comparison) + p_orb_pl = hf_all['orbital_period'] / secs_per_day + p_spin_pl = hf_all['axial_period'] / secs_per_hour + + # Left Y-axis: Orbital Period + ax_left = axs[2, 0] + l1 = ax_left.plot(time, p_orb_pl, lw=lw, label='Orbital Period', color='tab:orange') + ax_left.set_ylabel('Orbital Period [days]', color='tab:orange') + ax_left.tick_params(axis='y', labelcolor='tab:orange') + ax_left.set_yscale('log') + # Add a small buffer to the y-limits to avoid clipping the data points + ax_left.set_ylim(np.amin(p_orb_pl) / yext, np.amax(p_orb_pl) * yext) + ax_left.grid(alpha=0.2, which='both') + + # Right Y-axis: Spin Period + ax_right = ax_left.twinx() + l2 = ax_right.plot(time, p_spin_pl, lw=lw, label='Axial Spin Period', color='tab:red') + ax_right.set_ylabel('Axial Spin Period [hours]', color='tab:red') + ax_right.tick_params(axis='y', labelcolor='tab:red') + ax_right.set_yscale('log') + + # Combined Legend + lines = l1 + l2 + labels = [l.get_label() for l in lines] + ax_left.legend(lines, labels, loc='best') + + # ----------------- COLUMN 1: SATELLITE ----------------- + if has_sat: + # Panel 0,1: Satellite Semi-major Axis + # Using AU to keep consistent scale, or feel free to use e.g. 1e6 meters or Earth-Radii + y_a_sat = hf_all['semimajorax_sat'] / R_earth + axs[0, 1].plot(time, y_a_sat, lw=lw, color=mpl.rcParams['text.color']) + axs[0, 1].set_ylabel('Semi-major Axis [R_earth]') + axs[0, 1].set_ylim(np.amin(y_a_sat) / yext, np.amax(y_a_sat) * yext) + axs[0, 1].set_title('Satellite Orbiting Planet') + axs[0, 1].grid(alpha=0.2) + + # Panel 1,1: Satellite Eccentricity + y_e_sat = hf_all['eccentricity_sat'] + axs[1, 1].plot(time, y_e_sat, lw=lw, color='tab:blue') + axs[1, 1].set_ylabel('Eccentricity') + ymin_e_sat = np.amin(y_e_sat) / yext + ymax_e_sat = max(np.amax(y_e_sat) * yext, ymin_e_sat + 0.01) + axs[1, 1].set_ylim(ymin_e_sat, ymax_e_sat) + axs[1, 1].grid(alpha=0.2) + + # Panel 2,1: Satellite Periods & Optional Precession + p_orb_sat = hf_all['orbital_period_sat'] / secs_per_hour + p_spin_sat = hf_all['axial_period_sat'] / secs_per_hour + + axs[2, 1].plot(time, p_orb_sat, lw=lw, label='Orbital Period', color='tab:orange') + axs[2, 1].plot(time, p_spin_sat, lw=lw, label='Axial Spin Period', color='tab:red') + axs[2, 1].set_ylabel('Periods [hours]') + axs[2, 1].set_yscale('log') + axs[2, 1].legend(loc='best') + axs[2, 1].grid(alpha=0.2, which='both') + else: + # Gracefully leave satellite panels blank/notate if not simulated + for row in range(3): + axs[row, 1].text( + 0.5, + 0.5, + 'No Satellite Data', + transform=axs[row, 1].transAxes, + ha='center', + va='center', + color='grey', + ) + + # ----------------- SHARED X-AXIS CONFIG ----------------- + for ax in axs.flat: + ax.set_xscale('log') + ax.set_xlim(left=t0, right=np.amax(time)) + + axs[2, 0].set_xlabel('Time [yr]') + axs[2, 1].set_xlabel('Time [yr]') fig.tight_layout() + # Save the figure fpath = os.path.join(output_dir, 'plots', 'plot_orbit.%s' % plot_format) fig.savefig(fpath, dpi=200, bbox_inches='tight') + plt.close(fig) + plt.ioff() + -def plot_orbit_system(hf_all: pd.DataFrame, output_dir: str, plot_format: str = 'pdf', t0=1e3): +def plot_orbit_system( + hf_all: pd.DataFrame, + output_dir: str, + is_satellite_system: bool, + plot_format: str = 'pdf', + t0=1e3, +): + """Plot the orbit(s) actually being evolved, as seen from the body they + orbit -- either the planet around the star (``star_planet_model`` + active) or the satellite around the planet (``planet_satellite_model`` + active). ``orbit.star_planet_model`` and ``orbit.planet_satellite_model`` + are mutually exclusive (enforced at config load), so exactly one of + these two views is ever meaningful for a given run: plotting both in one + AU-scaled panel previously buried the satellite's orbit (~400x smaller + than a 1 AU planet-star separation) as an invisible speck. ``is_satellite_system`` + picks the one that actually evolves, each drawn in its own natural + length unit (AU around the star, R_earth around the planet). + """ if np.amax(hf_all['Time']) <= t0 + 1: log.debug('Insufficient data to make plot_system') return log.info('Plot orbit_system') + if is_satellite_system: + center_label = 'Planet' + center_marker = 'o' + center_color = 'tab:blue' + sma_col = 'semimajorax_sat' + ecc_col = 'eccentricity_sat' + roche_col = 'roche_limit_sat' + length_unit = R_earth + length_unit_label = 'R_earth' + orbit_label = 'Satellite orbit' + else: + center_label = 'Star' + center_marker = '*' + center_color = 'orange' + sma_col = 'semimajorax' + ecc_col = 'eccentricity' + roche_col = 'roche_limit' + length_unit = AU + length_unit_label = 'AU' + orbit_label = 'Planet orbit' + # Plotting parameters - lw_pla = 1.2 - lw_sat = 0.8 + lw_orb = 1.2 figscale = 1.4 fig, ax = plt.subplots(1, 1, figsize=(4 * figscale, 4 * figscale)) - # plot star - ax.scatter(0, 0, color='orange', s=60, zorder=4, label='Star', marker='*') + # plot central body + ax.scatter( + 0, 0, color=center_color, s=60, zorder=4, label=center_label, marker=center_marker + ) # Colors times = np.array(hf_all['Time'][:]) @@ -114,42 +219,35 @@ def plot_orbit_system(hf_all: pd.DataFrame, output_dir: str, plot_format: str = sm = plt.cm.ScalarMappable(cmap=cm.batlow, norm=norm) sm.set_array([]) - # plot planet at time + # plot orbit at time t = np.linspace(0, np.pi * 2, 80) - def _plot_planet(i): + def _plot_orbit_snapshot(i): hf_row = hf_all.iloc[i] col = sm.to_rgba(hf_row['Time']) - # planet orbit parameters - a = hf_row['semimajorax'] / AU - e = hf_row['eccentricity'] + # orbit parameters + a = hf_row[sma_col] / length_unit + e = hf_row[ecc_col] b = a * np.sqrt(1 - e * e) # location of focus f = a * e - # plot ellipse of planet orbit + # plot orbit ellipse x = a * np.cos(t) - f y = b * np.sin(t) - ax.plot(x, y, color=col, alpha=0.8, zorder=5, lw=lw_pla) - - # plot satellite orbit around planet - asat = hf_row['semimajorax_sat'] / AU - x0 = np.amin(x) - xx = asat * np.cos(t) + x0 - yy = asat * np.sin(t) - ax.plot(xx, yy, lw=lw_sat, color=col, alpha=0.4, zorder=5) + ax.plot(x, y, color=col, alpha=0.8, zorder=5, lw=lw_orb) return max(rmax, np.amax(np.abs(x))) # make orbits rmax = 0.01 for i in range(len(hf_all)): - rmax = max(_plot_planet(i), rmax) + rmax = max(_plot_orbit_snapshot(i), rmax) - # roche radius of star - roche = hf_all.iloc[-1]['roche_limit'] / AU + # roche radius of the central body + roche = hf_all.iloc[-1][roche_col] / length_unit ax.plot(roche * np.cos(t), roche * np.sin(t), ls='dashed', c='tab:red', label='Roche limit') # Plot colourbar @@ -158,9 +256,8 @@ def _plot_planet(i): cbar = fig.colorbar(sm, cax=cax, orientation='horizontal') cbar.set_label('Time [yr]') - # dummy labels - ax.plot([], [], label='Planet orbit', c='purple', lw=lw_pla) - ax.plot([], [], label='Moon orbit', c='purple', lw=lw_sat) + # dummy label + ax.plot([], [], label=orbit_label, c='purple', lw=lw_orb) # decorate rmax *= 1.2 @@ -168,7 +265,7 @@ def _plot_planet(i): ax.set_xlim(lims) ax.set_ylim(lims) ax.set_xticklabels([]) - ax.set_ylabel('Distance [AU]') + ax.set_ylabel(f'Distance [{length_unit_label}]') ax.grid(zorder=0, alpha=0.3) ax.legend(loc='upper right') @@ -181,24 +278,437 @@ def _plot_planet(i): fig.savefig(fpath, dpi=200, bbox_inches='tight') +def plot_evection( + hf_all: pd.DataFrame, + output_dir: str, + plot_format: str = 'pdf', + t0: float = 100.0, + xscale: str = 'linear', + t_max: float = 1e5, + fine_t=None, + fine_phi=None, + filter_toggle_t=None, +): + """Plot the evection diagnostics.""" + time = np.array(hf_all['Time']) + if np.amax(time) <= t0: + log.debug('Insufficient data to make plot_evection') + return + + log.info('Plot evection') + + lw = 2.0 + figscale = 1.2 + yext = 1.05 + + fig, axs = plt.subplots(4, 1, figsize=(11 * figscale, 9 * figscale), sharex=True) + + Omega_earth = np.sqrt(const_G * M_earth / R_earth**3) + Omega_sun = 2 * np.pi / secs_per_year + J_star = 0.315 + Lambda = np.sqrt(1.5 * J_star * Omega_earth / Omega_sun) + Omega_ratio = Omega_sun / Omega_earth + + a_prime = (hf_all['semimajorax_sat'] / R_earth).to_numpy() + e_arr = hf_all['eccentricity_sat'].to_numpy() + s_prime = (2 * np.pi / hf_all['axial_period'].to_numpy()) / Omega_earth + + with np.errstate(invalid='ignore'): + a_res = (Lambda * s_prime / (1.0 - e_arr**2)) ** (4.0 / 7.0) + + e_s = np.array( + [ + _solve_e_stationary(a_prime[i], s_prime[i], Lambda, Omega_ratio) + for i in range(len(a_prime)) + ] + ) + + # Panel (a): a' and a'_res + y_a_sat = hf_all['semimajorax_sat'] / R_earth + axs[0].plot( + time, a_res, lw=lw, ls='--', color='#a8c6e8', label="a'_res (evection)", zorder=2 + ) + axs[0].plot(time, y_a_sat, lw=lw, color='black', label="a' (satellite)", zorder=3) + axs[0].set_ylabel('Semi-major Axis [R_Earth]') + axs[0].set_ylim(np.amin(y_a_sat) / yext, np.amax(y_a_sat) * yext) + axs[0].set_title('Satellite Orbiting Planet') + axs[0].legend(loc='best', fontsize=9, framealpha=0.9) + axs[0].grid(alpha=0.2) + + # Panel (b): e_s and e + y_e_sat = hf_all['eccentricity_sat'] + axs[1].plot(time, y_e_sat, lw=lw, color='tab:blue', label='e', zorder=2) + axs[1].plot( + time, e_s, lw=lw, ls='--', color='#ff8c00', label='e_s (stable stationary)', zorder=3 + ) + axs[1].set_ylabel('Eccentricity') + ymin_e_sat = np.amin(y_e_sat) / yext + ymax_e_sat = max(np.amax(y_e_sat) * yext, ymin_e_sat + 0.01) + axs[1].set_ylim(ymin_e_sat, ymax_e_sat) + axs[1].legend(loc='best', fontsize=9, framealpha=0.9) + axs[1].grid(alpha=0.2) + + # Panel (c): Resonance/evection angle. + if fine_t is not None and fine_phi is not None: + y_evec = np.mod(np.asarray(fine_phi), 2 * np.pi) + t_evec = np.asarray(fine_t) + else: + y_evec = hf_all['evection_angle'].to_numpy().copy() + if np.ptp(y_evec) < 2 * np.pi - 1e-9: + pass # pure libration: bounded already, no wrap needed + else: + y_evec = np.mod(y_evec, 2 * np.pi) + t_evec = time + log.debug( + 'plot_evection: no fine phi trace supplied -- panel (c) uses ' + 'the coarse, potentially aliased evection_angle column' + ) + + axs[2].plot(t_evec, y_evec, lw=0.8 if fine_t is not None else lw, color='tab:green') + axs[2].set_ylabel('Evection Angle [rad]') + axs[2].set_yticks([0, np.pi, 2 * np.pi]) + axs[2].set_yticklabels(['0', r'$\pi$', r'$2\pi$']) + axs[2].set_ylim(-0.1, 2 * np.pi + 0.1) + axs[2].grid(alpha=0.2) + if filter_toggle_t is not None: + axs[2].axvline( + filter_toggle_t, + color='crimson', + ls=':', + lw=1.2, + alpha=0.7, + label=f'filter activates (t~{filter_toggle_t:.0f} yr)', + ) + axs[2].legend(loc='upper right', fontsize=9, framealpha=0.9) + if fine_t is None: + pass + + # Panel (d): total AM and normalized planet spin + norm_SR = Omega_earth + norm_MoI = 0.335 * M_earth * R_earth**2 + norm_AM = norm_MoI * norm_SR + + y_AM = hf_all['plan_sat_am'] / norm_AM + axs[3].plot( + time, y_AM, lw=lw, label='Normalized Angular Momentum', color='tab:orange', zorder=3 + ) + + Omega_p_arr = 2 * np.pi / hf_all['axial_period'].to_numpy() + s_p_prime = Omega_p_arr / norm_SR + axs[3].plot( + time, + s_p_prime, + lw=lw, + ls='-.', + label="Planet Spin, s_p' (normalized)", + color='tab:red', + zorder=2, + ) + + axs[3].set_ylabel('Normalized AM / Spin') + axs[3].set_ylim(0.0, 1.0) + axs[3].legend(loc='best', fontsize=9, framealpha=0.9) + axs[3].grid(alpha=0.2) + + for ax in (axs[0], axs[1], axs[3]): + if filter_toggle_t is not None: + ax.axvline(filter_toggle_t, color='crimson', ls=':', lw=1.0, alpha=0.5) + + if xscale == 'log': + for ax in axs.flat: + ax.set_xscale('log') + ax.set_xlim(left=t0, right=t_max) + axs[3].set_xlabel('Time [log10(yr)]') + else: + import matplotlib.ticker as mticker + + for ax in axs.flat: + ax.set_xscale('linear') + ax.set_xlim(left=0.0, right=t_max) + ax.xaxis.set_major_locator(mticker.MultipleLocator(1e4)) + axs[3].set_xlabel('Time [yr]') + + fig.tight_layout() + + # Save figure + os.makedirs(os.path.join(output_dir, 'plots'), exist_ok=True) + fpath = os.path.join(output_dir, 'plots', 'plot_evection.%s' % plot_format) + fig.savefig(fpath, dpi=200, bbox_inches='tight') + + plt.close(fig) + plt.ioff() + + +def plot_lovenumber( + output_dir: str, times: list | np.ndarray, data: list, plot_format: str = 'pdf' +): + if times is None or len(times) == 0: + log.debug('No times provided for plot_lovenumber') + return + + if np.amax(times) < 2: + log.debug('Insufficient data to make plot_lovenumber') + return + + log.info('Plot Lovenumber') + + # Structure data by unique mode across all time steps + # Key: (n, m, k), Value: dict of array lists + modes = {} + + for i, time in enumerate(times): + ds = data[i] + + n_arr = ds['n'][:] + m_arr = ds['m'][:] + k_arr = ds['k'][:] + sigma_arr = ds['sigma_range'][:] + raw_imag = ds['knms_total'] + knms_total = raw_imag[0, :] + 1j * raw_imag[1, :] + + # Group data per mode index + for j in range(len(n_arr)): + mode_key = (int(n_arr[j]), int(m_arr[j]), int(k_arr[j])) + if mode_key not in modes: + modes[mode_key] = { + 'time': [], + 'sigma': [], + 'real_log': [], + 'imag_log': [], + 'real_raw': [], + 'imag_raw': [], + } + + real_val = ( + np.log10(np.abs(knms_total[j].real)) if knms_total[j].real != 0 else -np.inf + ) + imag_val = ( + np.log10(np.abs(knms_total[j].imag)) if knms_total[j].imag != 0 else -np.inf + ) + + modes[mode_key]['time'].append(time) + modes[mode_key]['sigma'].append(np.abs(sigma_arr[j])) + modes[mode_key]['real_log'].append(real_val) + modes[mode_key]['imag_log'].append(imag_val) + modes[mode_key]['real_raw'].append(knms_total[j].real) + modes[mode_key]['imag_raw'].append(knms_total[j].imag) + + # Determine global colorbar bounds across all mode points + all_real_log = [ + val for mode in modes.values() for val in mode['real_log'] if np.isfinite(val) + ] + all_imag_log = [ + val for mode in modes.values() for val in mode['imag_log'] if np.isfinite(val) + ] + + if not all_real_log or not all_imag_log: + log.warning('No valid non-zero Love numbers to plot.') + return + + vmin_real, vmax_real = np.min(all_real_log), np.max(all_real_log) + vmin_imag, vmax_imag = np.min(all_imag_log), np.max(all_imag_log) + + # Thresholds beyond which a Love number is likely dominated by a + # normal-mode (seismic) resonance in the body's rheological structure + real_resonance_thresh = 1.5 + imag_resonance_thresh = 1.0 + + # Setup Figure + scale = 1.0 + fig, axs = plt.subplots(1, 2, figsize=(14 * scale, 6 * scale), sharey=True) + + cmap_real = cm.batlow + cmap_imag = cm.imola + + # Plot connecting lines and mode markers + for mode_key, mode_data in modes.items(): + # Sort trajectories chronologically by time + sort_idx = np.argsort(mode_data['time']) + t_sorted = np.array(mode_data['time'])[sort_idx] + y_vals = np.array(mode_data['sigma'])[sort_idx] + real_vals = np.array(mode_data['real_log'])[sort_idx] + imag_vals = np.array(mode_data['imag_log'])[sort_idx] + real_raw = np.array(mode_data['real_raw'])[sort_idx] + imag_raw = np.array(mode_data['imag_raw'])[sort_idx] + + # Drop t=0: log10(0) is -inf, and a single instant at the very + # start of the run adds nothing to this log-time plot. + keep = t_sorted > 0 + t_sorted = t_sorted[keep] + x_vals = np.log10(t_sorted) + y_vals = y_vals[keep] + real_vals = real_vals[keep] + imag_vals = imag_vals[keep] + real_raw = real_raw[keep] + imag_raw = imag_raw[keep] + + # Points where the Love number is likely near a normal-mode resonance + near_resonance = (real_raw > real_resonance_thresh) | (imag_raw > imag_resonance_thresh) + + # Draw connecting trajectory lines across time + axs[0].plot( + x_vals, y_vals, color='gray', linestyle='-', linewidth=0.8, alpha=0.4, zorder=1 + ) + axs[1].plot( + x_vals, y_vals, color='gray', linestyle='-', linewidth=0.8, alpha=0.4, zorder=1 + ) + + # Overlay scatter points colored by magnitude + sc_real = axs[0].scatter( + x_vals, + y_vals, + c=real_vals, + cmap=cmap_real, + vmin=vmin_real, + vmax=vmax_real, + edgecolors='none', + s=20, + alpha=0.8, + zorder=2, + ) + + sc_imag = axs[1].scatter( + x_vals, + y_vals, + c=imag_vals, + cmap=cmap_imag, + vmin=vmin_imag, + vmax=vmax_imag, + edgecolors='none', + s=20, + alpha=0.8, + zorder=2, + ) + + # Ring out points beyond the resonance thresholds, on both panels + if np.any(near_resonance): + for ax in axs: + ax.scatter( + x_vals[near_resonance], + y_vals[near_resonance], + facecolors='none', + edgecolors='red', + marker='o', + s=70, + linewidths=1.2, + zorder=3, + ) + + # Formatting & Colorbars + for ax in axs: + ax.set_yscale('log') + ax.set_xlabel(r'$\log_{10}(\text{Time [yr]})$') + ax.grid(True, which='both', ls='--', alpha=0.5) + + axs[0].set_ylabel(r'Forcing Frequency $|\sigma|$ (Log Scale)') + axs[0].set_title(r'Real Part: $\log_{10}(|\text{Re}(k_{nm})|)$') + axs[1].set_title(r'Imaginary Part: $\log_{10}(|\text{Im}(k_{nm})|)$') + + resonance_proxy = mpl.lines.Line2D( + [], + [], + marker='o', + markerfacecolor='none', + markeredgecolor='red', + linestyle='none', + markersize=8, + label=rf'Seismic resonance ($\text{{Re}}>{real_resonance_thresh:g}$ or ' + rf'$\text{{Im}}>{imag_resonance_thresh:g}$)', + ) + fig.legend( + handles=[resonance_proxy], + loc='upper center', + bbox_to_anchor=(0.5, 1.02), + ncol=1, + frameon=False, + ) + + fig.colorbar( + sc_real, + ax=axs[0], + orientation='vertical', + shrink=0.8, + label=r'$\log_{10}(|\text{Re}(k_{nm})|)$', + ) + fig.colorbar( + sc_imag, + ax=axs[1], + orientation='vertical', + shrink=0.8, + label=r'$\log_{10}(|\text{Im}(k_{nm})|)$', + ) + + fig.tight_layout() + + # Save figure + os.makedirs(os.path.join(output_dir, 'plots'), exist_ok=True) + fpath = os.path.join(output_dir, 'plots', f'plot_lovenumber.{plot_format}') + fig.savefig(fpath, dpi=200, bbox_inches='tight') + + plt.close(fig) + plt.ioff() + + def plot_orbit_entry(handler: Proteus): # read helpfile hf_all = pd.read_csv( os.path.join(handler.directories['output'], 'runtime_helpfile.csv'), sep=r'\s+' ) + # plots for orbit # make plot plot_orbit( hf_all, handler.directories['output'], + handler.config.orbit.satellite.include_satellite, plot_format=handler.config.params.out.plot_fmt, ) plot_orbit_system( hf_all, handler.directories['output'], + handler.config.orbit.planet_satellite_model is not None, plot_format=handler.config.params.out.plot_fmt, ) + if handler.config.orbit.planet_satellite_model == 'ps1d_evec': + # get data from fine output for evection angle + fine_t, fine_phi = None, None + fine_path = os.path.join(handler.directories['output/data'], 'fine_evection_data.csv') + + if os.path.exists(fine_path): + try: + fine_t, fine_phi = np.loadtxt(fine_path, skiprows=1, delimiter=',').T + except Exception as e: + log.warning(f'Failed to load fine evection data: {e}') + + plot_evection( + hf_all, + handler.directories['output'], + plot_format=handler.config.params.out.plot_fmt, + t0=1e1, + xscale='linear', + t_max=1e5, + fine_t=fine_t, + fine_phi=fine_phi, + ) + + # plots for tides + # if obliqua plot the Lovenumber spectrum evolution + if handler.config.orbit.module == 'obliqua': + extension = '_obliqua.nc' + + plot_times, _ = sample_output(handler, extension=extension, tmin=1e3) + log.info('Snapshots: %s', plot_times) + + data = read_tides_data(handler.directories['output'], 'obliqua', plot_times) + + plot_lovenumber( + output_dir=handler.directories['output'], + times=plot_times, + data=data, + plot_format=handler.config.params.out.plot_fmt, + ) + if __name__ == '__main__': from proteus.plot._cpl_helpers import get_handler_from_argv diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index cc856ee70..3ae00a312 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -156,6 +156,9 @@ def __init__(self, *, config_path: Path | str) -> None: # Atmosphere self.atmos_o = None # Atmosphere object from atmos_clim/common.py + # Orbit and tides + self.tides_o = None # Orbit/tides object from orbit/common.py + # Model has finished? self.finished_prev = False # Satisfied termination in prev iteration self.finished_both = False # Satisfied termination in current and previous @@ -398,6 +401,7 @@ def start(self, *, resume: bool = False, offline: bool = False): from proteus.observe.wrapper import run_observe # orbit + from proteus.orbit.common import Tides_t from proteus.orbit.wrapper import init_orbit, run_orbit # outgassing @@ -550,6 +554,9 @@ def start(self, *, resume: bool = False, offline: bool = False): # Initialise atmosphere object self.atmos_o = Atmos_t() + # Initialise tides object + self.tides_o = Tides_t() + # Is the model resuming from a previous state? if not self.config.params.resume: # New simulation @@ -1049,7 +1056,7 @@ def start(self, *, resume: bool = False, offline: bool = False): ############### ORBIT AND TIDES PrintHalfSeparator() _t0 = time.perf_counter() if _IT_TIMING_ENABLED else 0.0 - run_orbit(self.hf_row, self.config, self.directories, self.interior_o) + run_orbit(self.hf_row, self.config, self.directories, self.tides_o, self.interior_o) if _IT_TIMING_ENABLED: _t_mod['orbit'] = time.perf_counter() - _t0 diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 89030ff52..06b9bb4a5 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -45,6 +45,7 @@ LOCKFILE_NAME = 'keepalive' AGNI_MIN_VERSION = '1.8.0' +OBLIQUA_MIN_VERSION = '0.1.0' def _get_current_time(): @@ -138,6 +139,17 @@ def _get_agni_version(dirs: dict): return agni_meta['version'] +def _get_obliqua_version(dirs: dict): + """ + Get the installed Obliqua version + """ + from tomllib import load as tomlload + + with open(os.path.join(dirs['obliqua'], 'Project.toml'), 'rb') as hdl: + obliqua_meta = tomlload(hdl) + return obliqua_meta['version'] + + def _get_julia_version(): """ Get the installed Julia version @@ -294,6 +306,10 @@ def _valid_ver(act_str: str, exp_str: str, name: str) -> bool: valid &= _valid_ver(mors_version, _get_expver('fwl-mors'), 'MORS') + # Orbit module + if config.orbit.module == 'obliqua': + valid &= _valid_ver(_get_obliqua_version(dirs), OBLIQUA_MIN_VERSION, 'Obliqua') + # Exit if not valid: UpdateStatusfile(dirs, 20) @@ -408,8 +424,11 @@ def print_module_configuration(dirs: dict, config: Config, config_path: str): log.info(write) # Orbit module - log.info('Orbit module %s' % config.orbit.module) - if config.orbit.module == 'lovepy': + write = 'Orbit module %s' % config.orbit.module + if config.orbit.module == 'obliqua': + write += ' version ' + _get_obliqua_version(dirs) + log.info(write) + if config.orbit.module in ['lovepy', 'obliqua']: log.info(' - Julia version ' + _get_julia_version()) # Accretion module @@ -782,20 +801,37 @@ def GetHelpfileKeys(): # Orbital and spin parameters of planet 'semimajorax', # semi-major axis [m] + 'sma_dot_planet', # semi-major axis derivative [m s-1] 'separation', # time-averaged separation [m] 'perihelion', # lowest point in orbit [m] 'orbital_period', # orbital duration [s] 'eccentricity', # orbital eccentricity [1] - 'Imk2', # Imaginary part of k2 Love Number [1] + 'ecc_dot_planet', # eccentricity derivative [1 s-1] + 'plan_star_am', # angular momentum of star+planet [kg m2 s-1] 'axial_period', # day length of planet around its axis [s] + + 'Imk2', # Imaginary part of k2 Love Number [1] + 'longitude', # column longitude relative to substellar point [deg] 'latitude', # column latitude relative to substellar point [deg] # Satellite system - 'perigee', # lowest point in orbit [m] 'semimajorax_sat', # semi-major axis [m] + 'sma_dot_sat', # semi-major axis derivative [m s-1] + 'separation_sat', # time-averaged separation [m] + 'perigee', # lowest point in orbit [m] + 'orbital_period_sat', # orbital duration [s] + 'eccentricity_sat', # orbital eccentricity of satellite [1] + 'ecc_dot_sat', # eccentricity derivative [1 s-1] + 'plan_sat_am', # angular momentum of satellite+planet [kg m2 s-1] + 'axial_period_sat', # day length of satellite around its axis [s] + + 'R_sat', # radius of satellite [m] 'M_sat', # mass of satellite [kg] - 'plan_sat_am', # angular momentum of sat+pla [kg m2 s-1], + 'C_sat', # principal moment of inertia of satellite [kg m2] + + 'evection_angle', # evection angle [rad] + 'evection_dt_cap_yr', # next macro-step dt cap, rate + growth limiter folded in [yr] # Planet structure 'R_int', # interior radius [m] @@ -803,6 +839,7 @@ def GetHelpfileKeys(): 'M_planet', # total planet wet+dry mass [kg] 'M_vaps', # vapourised rock mass, including the vapourised oxygen [kg] 'R_core', # core radius [m] + 'C_int', # principal moment of inertia of planet [kg m2] 'R_solvus', # solvus radius for global_miscibility mode [m] 'P_solvus', # solvus pressure for global_miscibility mode [Pa] 'T_solvus', # solvus temperature for global_miscibility mode [K] @@ -1036,13 +1073,15 @@ def GetHelpfileKeys(): keys.append(s + '_ocean') # ocean surface density [kg m-2] # Diagnostic variables - keys.append('wtg_surf') # Weak temperature gradient parameter at the surface [1] - keys.append('roche_limit') # Roche limit, orbital distance [m] - keys.append('breakup_period') # Critical day length [s] - keys.append('hill_radius') # Hill radius, radial distance [m] + keys.append('wtg_surf') # Weak temperature gradient parameter at the surface [1] + keys.append('roche_limit') # Roche limit, orbital distance [m] + keys.append('breakup_period') # Critical day length [s] + keys.append('hill_radius') # Hill radius, radial distance [m] + keys.append('roche_limit_sat') # Roche limit, orbital distance for the satellite [m] + keys.append('breakup_period_sat') # Critical day length for satellite [s] # Simulation's computational variables - keys.append('runtime') # Simulation wall-clock runtime [s] + keys.append('runtime') # Simulation wall-clock runtime [s] # fmt: on return keys @@ -1863,6 +1902,7 @@ def UpdatePlots(hf_all: pd.DataFrame, dirs: dict, config: Config, end=False, num # Import utilities from proteus.atmos_clim.common import read_atmosphere_data from proteus.interior_energetics.wrapper import read_interior_data + from proteus.orbit.wrapper import read_tides_data # Import plotting functions from proteus.plot.cpl_atmosphere import plot_atmosphere @@ -1875,7 +1915,11 @@ def UpdatePlots(hf_all: pd.DataFrame, dirs: dict, config: Config, end=False, num from proteus.plot.cpl_global import plot_global from proteus.plot.cpl_interior import plot_interior from proteus.plot.cpl_interior_cmesh import plot_interior_cmesh - from proteus.plot.cpl_orbit import plot_orbit + from proteus.plot.cpl_orbit import ( + plot_lovenumber, + plot_orbit, + plot_orbit_system, + ) from proteus.plot.cpl_population import ( plot_population_mass_radius, plot_population_time_density, @@ -1898,6 +1942,7 @@ def UpdatePlots(hf_all: pd.DataFrame, dirs: dict, config: Config, end=False, num agni = config.atmos_clim.module == 'agni' spider = config.interior_energetics.module == 'spider' aragog = config.interior_energetics.module == 'aragog' + obliqua = config.orbit.module == 'obliqua' observed = bool(config.observe.module is not None) # Get all output times @@ -1919,8 +1964,22 @@ def UpdatePlots(hf_all: pd.DataFrame, dirs: dict, config: Config, end=False, num plot_escape(hf_all, output_dir, plot_format=config.params.out.plot_fmt) # Planet and satellite orbit parameters - if config.orbit.evolve or config.orbit.satellite: - plot_orbit(hf_all, output_dir, config.params.out.plot_fmt) + if ( + config.orbit.star_planet_model is not None + or config.orbit.planet_satellite_model is not None + ): + plot_orbit( + hf_all, + output_dir, + config.orbit.satellite.include_satellite, + plot_format=config.params.out.plot_fmt, + ) + plot_orbit_system( + hf_all, + output_dir, + config.orbit.planet_satellite_model is not None, + plot_format=config.params.out.plot_fmt, + ) # Which times do we have atmosphere data for? if not dummy_atm: @@ -1979,6 +2038,21 @@ def UpdatePlots(hf_all: pd.DataFrame, dirs: dict, config: Config, end=False, num # Energy flux profiles plot_fluxes_atmosphere(output_dir, config.params.out.plot_fmt) + # Lovenumber spectra for tidal dissipation + if obliqua: + # Which times do we have tides data for? + ncs = glob.glob(os.path.join(output_dir, 'data', '*_obliqua.nc')) + plot_times_obliqua = [int(f.split('/')[-1].split('_obliqua')[0]) for f in ncs] + + tide_data = read_tides_data(output_dir, 'obliqua', plot_times_obliqua) + + plot_lovenumber( + output_dir=output_dir, + times=plot_times_obliqua, + data=tide_data, + plot_format=config.params.out.plot_fmt, + ) + # Only at the end of the simulation if end: # Global plot with linear-time axis @@ -2119,6 +2193,7 @@ def get_proteus_directories(outdir='_unset') -> dict[str, str]: 'proteus': root_dir, 'agni': os.path.join(root_dir, 'AGNI'), 'lovepy': os.path.join(root_dir, 'lovepy'), + 'obliqua': os.path.join(root_dir, 'Obliqua'), 'input': os.path.join(root_dir, 'input'), 'spider': os.path.join(root_dir, 'SPIDER'), 'aragog': os.path.join(root_dir, 'aragog'), diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 96a6b5abc..9b1db92ca 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -300,6 +300,10 @@ def CommentFromStatus(status: int): desc = 'Completed (volatiles escaped)' case 16: desc = 'Completed (planet disintegrated)' + case 17: + desc = 'Completed (satellite escaped)' + case 18: + desc = 'Completed (satellite disintegrated)' # Error cases case 20: desc = 'Error (generic case, or configuration issue)' diff --git a/src/proteus/utils/julia_common.py b/src/proteus/utils/julia_common.py new file mode 100644 index 000000000..fbd4e68f0 --- /dev/null +++ b/src/proteus/utils/julia_common.py @@ -0,0 +1,110 @@ +# Julia helper functions shared between julia based modules. +from __future__ import annotations + +import os + +import juliacall +import numpy as np +from juliacall import Main as jl + +from proteus.utils.logs import GetCurrentLogfileIndex, GetLogfilePath + + +def to_julia_dict(obj): + """Recursively convert Python dict/list to native Julia Dict/Vector.""" + if isinstance(obj, dict): + jd = jl.Dict() + for k, v in obj.items(): + jd[k] = to_julia_dict(v) + return jd + elif isinstance(obj, list): + return [to_julia_dict(v) for v in obj] + else: + return obj + + +def make_julia_converters(jl_module_name: str): + """Build array/scalar-to-Julia converters bound to one Julia + submodule's own precision type. + + Parameters + ---------- + jl_module_name : str + Name of the imported Julia submodule as it appears in the + `jl` namespace, e.g. 'Obliqua' or 'LovePy'. + + Returns + ------- + jlarr, jlsca_float, jlsca_prec : callables + `jlarr(arr)` converts a numpy array to a Julia `Array{.prec, 1}`. + `jlsca_float(sca)` converts a Python scalar to `.Float64`. + `jlsca_prec(sca)` converts a Python scalar to `.prec`. + """ + + def _jl_module(): + return getattr(jl, jl_module_name) + + def jlarr(arr: np.ndarray): + # Make copy of array, and convert to Julia type + cop = np.array(arr, copy=True, dtype=float).flatten() + return juliacall.convert(jl.Array[_jl_module().prec, 1], cop) + + def jlsca_float(sca: float): + # Make a copy of a scalar, and convert to Julia type + return juliacall.convert(_jl_module().Float64, sca) + + def jlsca_prec(sca: float): + # Make a copy of a scalar, and convert to Julia type + return juliacall.convert(_jl_module().prec, sca) + + return jlarr, jlsca_float, jlsca_prec + + +def make_log_syncer(module_logfile_name: str): + """Build a ``sync_log_files(outdir) -> list[str]`` bound to one Julia + submodule's own recent-run logfile name (e.g. ``'obliqua_recent.log'``, + ``'agni_recent.log'``). + + Each Julia-backed submodule (Obliqua, AGNI, ...) writes its own + solver-run log to a fixed filename in ``outdir``; the returned function + moves that content into PROTEUS's own logfile and clears the + submodule's copy, so callers can scan the just-synced lines for the + submodule's own failure-mode markers (e.g. AGNI's + ``_extract_agni_failure_reason``). + """ + + def sync_log_files(outdir: str) -> list[str]: + """Move the submodule's logfile content into the PROTEUS logfile + and clear it. + + Returns the list of lines that were copied, so that callers can + scan them for failure-mode markers. Returns an empty list if the + submodule's logfile cannot be read. + """ + # Logfile paths + module_logpath = os.path.join(outdir, module_logfile_name) + logpath = GetLogfilePath(outdir, GetCurrentLogfileIndex(outdir)) + + # Copy logfile content + try: + with open(module_logpath) as infile: + inlines = infile.readlines() + except OSError: + return [] + + with open(logpath, 'a') as outfile: + for i, line in enumerate(inlines): + # First line of the submodule's logfile has NULL chars at + # the start, for some reason + if i == 0 and '[' in line: + line = '[' + line.split('[', 1)[1] + # copy the line + outfile.write(line) + + # Remove logfile content + with open(module_logpath, 'w') as hdl: + hdl.write('') + + return inlines + + return sync_log_files diff --git a/src/proteus/utils/terminate.py b/src/proteus/utils/terminate.py index 332c4b481..da8100e46 100644 --- a/src/proteus/utils/terminate.py +++ b/src/proteus/utils/terminate.py @@ -7,6 +7,7 @@ import os from typing import TYPE_CHECKING +from proteus.utils.constants import R_earth from proteus.utils.helper import UpdateStatusfile if TYPE_CHECKING: @@ -143,6 +144,53 @@ def _check_spinrate(handler: Proteus) -> bool: return False +def _check_satellite(handler: Proteus) -> bool: + log.debug('Check satellite') + + sma = handler.hf_row['semimajorax_sat'] + sma_max = handler.config.params.stop.satellite.sma_max * R_earth + log.debug(' sma, sma_max = %.3e, %.3e m' % (sma, sma_max)) + + if sma >= sma_max: + UpdateStatusfile(handler.directories, 17) + _msg_termination('Satellite reached escape semimajor axis') + return True + + return False + + +def _check_satellite_separation(handler: Proteus) -> bool: + log.debug('Check satellite separation') + + separation_sat = handler.hf_row['separation_sat'] + roche_limit_sat = handler.hf_row['roche_limit_sat'] + offset = handler.config.params.stop.disint_sat.offset_roche + log.debug(' sep, roc = %.3e, %.3e m' % (separation_sat, roche_limit_sat - offset)) + + if separation_sat <= roche_limit_sat + offset: + UpdateStatusfile(handler.directories, 18) + _msg_termination('Satellite has disintegrated') + return True + + return False + + +def _check_satellite_spinrate(handler: Proteus) -> bool: + log.debug('Check satellite spin rate') + + axial_period_sat = handler.hf_row['axial_period_sat'] + breakup_period_sat = handler.hf_row['breakup_period_sat'] + offset = handler.config.params.stop.disint_sat.offset_spin + log.debug(' axr, bur = %.3e, %.3e s' % (axial_period_sat, breakup_period_sat)) + + if axial_period_sat <= breakup_period_sat + offset: + UpdateStatusfile(handler.directories, 18) + _msg_termination('Satellite has disintegrated') + return True + + return False + + # Maximum time def _check_maxtime(handler: Proteus) -> bool: log.debug('Check maximum time') @@ -283,6 +331,20 @@ def check_termination(handler: Proteus) -> bool: if handler.config.params.stop.disint.spin_enabled: finished = finished or _check_spinrate(handler) + # Two criteria for satellite disintegration + if handler.config.params.stop.disint_sat.enabled: + # Orbiting within Roche limit (tidal disruption when close to planet) + if handler.config.params.stop.disint_sat.roche_enabled: + finished = finished or _check_satellite_separation(handler) + + # Spinning faster than breakup rate (centrifugal disruption) + if handler.config.params.stop.disint_sat.spin_enabled: + finished = finished or _check_satellite_spinrate(handler) + + # Satellite escaped + if handler.config.params.stop.satellite.enabled: + finished = finished or _check_satellite(handler) + # ------------------------ # 3) Check resource-based criteria, set by user according to the # available the machine / job. These are not physical criteria. diff --git a/tests/config/test_config.py b/tests/config/test_config.py index d94d26056..aa4f51db8 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -24,6 +24,7 @@ instmethod_dummy, instmethod_evolve, janus_escape_atmosphere, + obliqua_requires_perturber, observe_resolved_atmosphere, satellite_evolve, spada_zephyrus, @@ -448,6 +449,42 @@ def test_read_config_object_rejects_explicit_zero_step_cap(tmp_path): assert '-1.0' in str(excinfo.value) +@pytest.mark.unit +def test_structure_k_val_converts_a_numeric_override_to_int(tmp_path): + """``orbit.obliqua.k_min``/``k_max`` are ``Union[int, Literal['none']]``: + the 'none' sentinel structures through unchanged (the schema default, + exercised implicitly by every other config-loading test), while a + numeric override must structure to a real ``int``, not stay a raw + TOML value or string. + """ + cfg = read_config_object(PROTEUS_ROOT / 'input' / 'minimal.toml') + assert cfg.orbit.obliqua.k_min == 'none' + + out = tmp_path / 'k_range.toml' + cfg.write(str(out), overrides={'orbit.obliqua.k_min': 5, 'orbit.obliqua.k_max': 20}) + + reloaded = read_config_object(out) + assert reloaded.orbit.obliqua.k_min == 5 + assert isinstance(reloaded.orbit.obliqua.k_min, int) + assert reloaded.orbit.obliqua.k_max == 20 + + +@pytest.mark.unit +@pytest.mark.parametrize('bad_val', [True, False, 1.5, [1, 2], None]) +def test_structure_k_val_rejects_non_int_non_str(bad_val): + """``structure_k_val`` accepts only ``int`` or ``str`` (the 'none' + sentinel) -- a ``bool`` (a ``int`` subclass that would otherwise slip + past the ``isinstance(val, (int, str))`` check unnoticed), a ``float``, + or any other type must raise a clear ``ValueError`` up front, not + silently coerce (e.g. ``int(1.5)`` truncating to ``1``) or structure to + a nonsensical k_min/k_max. + """ + from proteus.config import structure_k_val + + with pytest.raises(ValueError, match='Expected int or "none"'): + structure_k_val(bad_val, None) + + @pytest.mark.unit def test_read_config_object_omitted_step_cap_resolves_to_schema_default(): """An absent step-cap key resolves to the schema default, same as an explicit 0.0 rejects. @@ -737,29 +774,35 @@ def test_instmethod_dummy_rejects_non_dummy_star(): @pytest.mark.unit def test_instmethod_evolve_rejects_evolving_inst_method(): - """Instellation method cannot evolve when evolve flag is already active.""" - inst = SimpleNamespace(orbit=SimpleNamespace(instellation_method='inst', evolve=True)) + """Instellation method cannot evolve when star-planet evolution is active.""" + inst = SimpleNamespace( + orbit=SimpleNamespace(instellation_method='inst', star_planet_model='sp0d') + ) with pytest.raises(ValueError): instmethod_evolve(inst, None, None) - # Discrimination: dropping evolve to False clears the guard. A regression - # that always raised when instellation_method='inst' (ignoring evolve) - # would fail this second invocation. - inst.orbit.evolve = False + # Discrimination: dropping star_planet_model to None clears the guard. A + # regression that always raised when instellation_method='inst' (ignoring + # star_planet_model) would fail this second invocation. + inst.orbit.star_planet_model = None assert instmethod_evolve(inst, None, None) is None @pytest.mark.unit def test_satellite_evolve_rejects_combination(): - """Orbit configs cannot enable both satellite and evolve simultaneously.""" - inst = SimpleNamespace(orbit=SimpleNamespace(satellite=True, evolve=True)) + """Orbit configs cannot enable both a planet-satellite model and a + star-planet evolution model simultaneously.""" + inst = SimpleNamespace( + orbit=SimpleNamespace(planet_satellite_model='ps0d', star_planet_model='sp0d') + ) with pytest.raises(ValueError): satellite_evolve(inst, None, None) - # Discrimination: dropping evolve to False (satellite still True) is the - # canonical valid combo and must silent-pass. A regression that raised - # whenever satellite=True (ignoring evolve) would fail this second call. - inst.orbit.evolve = False + # Discrimination: dropping star_planet_model to None (planet_satellite_model + # still set) is the canonical valid combo and must silent-pass. A + # regression that raised whenever planet_satellite_model was set (ignoring + # star_planet_model) would fail this second call. + inst.orbit.star_planet_model = None assert satellite_evolve(inst, None, None) is None @@ -779,6 +822,33 @@ def test_tides_enabled_orbit_requires_orbit_module(): assert tides_enabled_orbit(inst, None, None) is None +@pytest.mark.unit +def test_obliqua_requires_perturber_rejects_unset_perturber(): + """``orbit.module = 'obliqua'`` with ``orbit.perturber`` left unset + (``None``, the schema default -- 'none' in TOML converts to this) + must be rejected here at config-load time, not left to fail later + with an unrelated-looking crash inside ``run_obliqua`` (which has + no branch for an unset perturber).""" + inst = SimpleNamespace(orbit=SimpleNamespace(module='obliqua', perturber=None)) + with pytest.raises(ValueError, match='perturber'): + obliqua_requires_perturber(inst, None, None) + + # Discrimination: setting perturber to either valid value clears the + # guard. A regression that always raised whenever module=='obliqua' + # (ignoring perturber) would fail both of these. + inst.orbit.perturber = 'star' + assert obliqua_requires_perturber(inst, None, None) is None + inst.orbit.perturber = 'satellite' + assert obliqua_requires_perturber(inst, None, None) is None + + # Edge case: an unset perturber with a DIFFERENT (or no) tidal module + # must not be flagged -- this check is specific to obliqua, not a + # blanket "perturber must always be set" rule. + inst.orbit.module = 'lovepy' + inst.orbit.perturber = None + assert obliqua_requires_perturber(inst, None, None) is None + + @pytest.mark.unit def test_observe_resolved_atmosphere_requires_non_dummy(): """Resolved spectra synthesis is invalid when the climate module is dummy.""" @@ -2311,17 +2381,18 @@ def test_config_instmethod_evolve_rejects_inst_with_orbit_evolution(): instance = SimpleNamespace( orbit=SimpleNamespace( instellation_method='inst', - evolve=True, # INVALID + star_planet_model='sp0d', # INVALID ), ) with pytest.raises(ValueError, match='not supported for `instellation_method'): instmethod_evolve(instance, SimpleNamespace(), None) - # Discrimination: dropping evolve to False (with instellation_method still - # 'inst') must take the validator to the silent-accept branch. A - # regression that always raised on instellation_method='inst' (ignoring - # evolve) would fail this second invocation. - instance.orbit.evolve = False + # Discrimination: dropping star_planet_model to None (with + # instellation_method still 'inst') must take the validator to the + # silent-accept branch. A regression that always raised on + # instellation_method='inst' (ignoring star_planet_model) would fail this + # second invocation. + instance.orbit.star_planet_model = None assert instmethod_evolve(instance, SimpleNamespace(), None) is None @@ -2353,17 +2424,18 @@ def test_config_satellite_evolve_rejects_both_satellite_and_evolution(): # Invalid: satellite + orbital evolution instance = SimpleNamespace( orbit=SimpleNamespace( - satellite=True, # Has satellite - evolve=True, # Also evolving - INVALID + planet_satellite_model='ps0d', # Has satellite + star_planet_model='sp0d', # Also evolving - INVALID ), ) with pytest.raises(ValueError, match='cannot be used simultaneously'): satellite_evolve(instance, SimpleNamespace(), None) - # Discrimination: dropping evolve (with satellite still True) must reach - # the silent-accept branch. A regression that always raised when - # satellite=True (ignoring evolve) would fail this second invocation. - instance.orbit.evolve = False + # Discrimination: dropping star_planet_model (with planet_satellite_model + # still set) must reach the silent-accept branch. A regression that + # always raised when planet_satellite_model was set (ignoring + # star_planet_model) would fail this second invocation. + instance.orbit.star_planet_model = None assert satellite_evolve(instance, SimpleNamespace(), None) is None @@ -2375,16 +2447,18 @@ def test_config_satellite_evolve_allows_satellite_without_evolution(): # Valid: satellite without evolution instance = SimpleNamespace( orbit=SimpleNamespace( - satellite=True, - evolve=False, + planet_satellite_model='ps0d', + star_planet_model=None, ), ) result = satellite_evolve(instance, SimpleNamespace(), None) - assert result is None # contract: satellite=True with evolve=False is the valid combo - # Discriminating check: satellite=True with evolve=True would have raised; only the - # evolve=False branch can produce a silent pass with satellite=True. - assert instance.orbit.satellite is True - assert instance.orbit.evolve is False + assert ( + result is None + ) # contract: satellite set with star_planet_model=None is the valid combo + # Discriminating check: both set would have raised; only star_planet_model=None + # can produce a silent pass with planet_satellite_model set. + assert instance.orbit.planet_satellite_model == 'ps0d' + assert instance.orbit.star_planet_model is None @pytest.mark.unit diff --git a/tests/config/test_config_schema_invariants.py b/tests/config/test_config_schema_invariants.py index f64f3e284..11b6d857d 100644 --- a/tests/config/test_config_schema_invariants.py +++ b/tests/config/test_config_schema_invariants.py @@ -43,10 +43,12 @@ boundary_requires_fixed_surface_state, check_module_dependencies, instmethod_evolve, + orbit_requires_tides, planet_fO2_source_compat, planet_mass_valid, planet_oxygen_mode_explicit, satellite_evolve, + sp0d_obliqua_degree_mismatch, ) pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] @@ -68,7 +70,7 @@ ATMOS_CHEM_BACKENDS = (None, 'vulcan', 'dummy') ESCAPE_BACKENDS = (None, 'dummy', 'zephyrus', 'boreas') STAR_BACKENDS = (None, 'mors', 'dummy') -ORBIT_BACKENDS = (None, 'dummy', 'lovepy') +ORBIT_BACKENDS = (None, 'dummy', 'lovepy', 'obliqua') INTERIOR_STRUCT_BACKENDS = (None, 'dummy', 'spider', 'zalmoxis') # Backends whose Python package is in the PROTEUS hard dependency set @@ -228,8 +230,8 @@ def _make_config_instance(**overrides): orbit=SimpleNamespace( module='dummy', instellation_method='separation', - evolve=False, - satellite=False, + star_planet_model=None, + planet_satellite_model=None, ), params=SimpleNamespace(stop=SimpleNamespace(escape=SimpleNamespace(enabled=True))), planet=SimpleNamespace( @@ -706,11 +708,12 @@ def test_boundary_requires_fixed_surface_state_passes_with_fixed(): # --------------------------------------------------------------------------- @pytest.mark.unit def test_instmethod_evolve_rejects_inst_with_orbit_evolve(): - """instellation_method='inst' is incompatible with orbit.evolve=True.""" + """instellation_method='inst' is incompatible with a star-planet + evolution model being enabled.""" instance = _make_config_instance( **{ 'orbit.instellation_method': 'inst', - 'orbit.evolve': True, + 'orbit.star_planet_model': 'sp0d', } ) with pytest.raises(ValueError, match=r"instellation_method='inst'") as excinfo: @@ -718,96 +721,256 @@ def test_instmethod_evolve_rejects_inst_with_orbit_evolve(): # Discrimination: the message must mention orbital evolution as the # incompatible-with feature, so the user knows which of the two settings # to change. A regression with just "instellation_method='inst' is bad" - # that did not name the conflicting evolve flag would fail. + # that did not name the conflicting evolution model would fail. msg = str(excinfo.value).lower() assert 'evolution' in msg or 'evolve' in msg @pytest.mark.unit def test_instmethod_evolve_passes_with_inst_and_no_evolve(): - """instellation_method='inst' is OK when orbit.evolve is False.""" + """instellation_method='inst' is OK when star_planet_model is None.""" instance = _make_config_instance( **{ 'orbit.instellation_method': 'inst', - 'orbit.evolve': False, + 'orbit.star_planet_model': None, } ) result = instmethod_evolve(instance, None, None) - assert result is None # contract: inst + evolve=False is the canonical compatible combo - # Discriminating check: evolve=True with inst would have raised; only the - # evolve=False branch can produce a silent pass under inst. + assert ( + result is None + ) # contract: inst + star_planet_model=None is the canonical compatible combo + # Discriminating check: star_planet_model='sp0d' with inst would have + # raised; only the None branch can produce a silent pass under inst. assert instance.orbit.instellation_method == 'inst' - assert instance.orbit.evolve is False + assert instance.orbit.star_planet_model is None @pytest.mark.unit def test_instmethod_evolve_passes_with_separation_and_evolve(): - """orbit.evolve is fine when instellation_method != 'inst'.""" + """A star-planet evolution model is fine when instellation_method != 'inst'.""" instance = _make_config_instance( **{ 'orbit.instellation_method': 'separation', - 'orbit.evolve': True, + 'orbit.star_planet_model': 'sp0d', } ) result = instmethod_evolve(instance, None, None) assert result is None # contract: non-inst method permits orbital evolution - # Discriminating check: evolve=True with inst would have raised; only the - # non-inst branch can produce a silent pass with evolve=True. + # Discriminating check: star_planet_model set with inst would have + # raised; only the non-inst branch can produce a silent pass with it set. assert instance.orbit.instellation_method != 'inst' - assert instance.orbit.evolve is True + assert instance.orbit.star_planet_model == 'sp0d' @pytest.mark.unit def test_satellite_evolve_rejects_satellite_with_evolve(): - """orbit.satellite=True with orbit.evolve=True must raise.""" + """A planet-satellite model with a star-planet evolution model + simultaneously enabled must raise.""" instance = _make_config_instance( **{ - 'orbit.satellite': True, - 'orbit.evolve': True, + 'orbit.planet_satellite_model': 'ps0d', + 'orbit.star_planet_model': 'sp0d', } ) with pytest.raises(ValueError, match=r'satellite') as excinfo: satellite_evolve(instance, None, None) # Discrimination: the message must also mention orbital evolution; the - # incompatibility is between satellite=True AND evolve=True. A regression - # that only named 'satellite' without naming the conflicting evolve flag - # would leave users guessing which side to flip. + # incompatibility is between planet_satellite_model AND star_planet_model + # both being set. A regression that only named 'satellite' without naming + # the conflicting evolution model would leave users guessing which side + # to flip. msg = str(excinfo.value).lower() assert 'evolution' in msg or 'evolve' in msg @pytest.mark.unit def test_satellite_evolve_passes_with_satellite_and_no_evolve(): - """A satellite with a fixed orbit is allowed.""" + """A satellite with a fixed star-planet orbit is allowed.""" instance = _make_config_instance( **{ - 'orbit.satellite': True, - 'orbit.evolve': False, + 'orbit.planet_satellite_model': 'ps0d', + 'orbit.star_planet_model': None, } ) result = satellite_evolve(instance, None, None) - assert result is None # contract: satellite=True + evolve=False is the accepted combo - # Discriminating check: satellite=True + evolve=True would have raised; only - # the evolve=False branch can produce a silent pass under satellite=True. - assert instance.orbit.satellite is True - assert instance.orbit.evolve is False + assert result is None # contract: satellite set + star_planet_model=None is accepted + # Discriminating check: both set would have raised; only star_planet_model=None + # can produce a silent pass with planet_satellite_model set. + assert instance.orbit.planet_satellite_model == 'ps0d' + assert instance.orbit.star_planet_model is None @pytest.mark.unit def test_satellite_evolve_passes_without_satellite(): - """orbit.evolve alone (no satellite) is allowed.""" + """A star-planet evolution model alone (no satellite) is allowed.""" instance = _make_config_instance( **{ - 'orbit.satellite': False, - 'orbit.evolve': True, + 'orbit.planet_satellite_model': None, + 'orbit.star_planet_model': 'sp0d', } ) result = satellite_evolve(instance, None, None) assert result is None # contract: no-satellite path accepts orbital evolution - # Discriminating check: satellite=False is the path that allows evolve=True; - # the validator's other branch (satellite=True + evolve=True) would have raised. - assert instance.orbit.satellite is False - assert instance.orbit.evolve is True + # Discriminating check: planet_satellite_model=None is the path that + # allows star_planet_model to be set; the validator's other branch (both + # set) would have raised. + assert instance.orbit.planet_satellite_model is None + assert instance.orbit.star_planet_model == 'sp0d' + + +# --------------------------------------------------------------------------- +# orbit_requires_tides: sp1d/ps1d/ps1d_evec consume a per-mode Love-number +# spectrum, which only lovepy or Obliqua can supply. +# --------------------------------------------------------------------------- +# model -> the orbit.* field it is actually assigned to: sp1d lives on +# star_planet_model, while ps1d/ps1d_evec live on planet_satellite_model -- +# the two are never set at the same time (see satellite_evolve above). +_TIDES_REQUIRED_MODELS = [ + ('sp1d', 'star_planet_model'), + ('ps1d', 'planet_satellite_model'), + ('ps1d_evec', 'planet_satellite_model'), +] + + +@pytest.mark.unit +@pytest.mark.parametrize('model,model_field', _TIDES_REQUIRED_MODELS) +@pytest.mark.parametrize('module', ['dummy', 'none']) +def test_orbit_requires_tides_rejects_non_tidal_module(model, model_field, module): + """sp1d/ps1d/ps1d_evec need a real tidal-response spectrum, so pairing + any of them with a non-tides module (dummy tides or tides disabled + entirely) must raise rather than silently running with no Love numbers. + + Regression guard: ps1d/ps1d_evec are ``orbit.planet_satellite_model`` + values, not ``orbit.star_planet_model`` values -- a validator that only + ever inspected ``star_planet_model`` (as this one once did) would never + fire for either, silently letting ``dummy`` + ps1d/ps1d_evec through. + """ + instance = _make_config_instance( + **{ + 'orbit.module': module, + f'orbit.{model_field}': model, + } + ) + with pytest.raises(ValueError, match=model) as excinfo: + orbit_requires_tides(instance, None, None) + msg = str(excinfo.value) + # Discrimination: the message must name both accepted modules, not just + # reject blindly, so the user knows what to switch to. + assert 'obliqua' in msg + assert 'lovepy' in msg + + +@pytest.mark.unit +@pytest.mark.parametrize('model,model_field', _TIDES_REQUIRED_MODELS) +@pytest.mark.parametrize('module', ['obliqua', 'lovepy']) +def test_orbit_requires_tides_passes_for_either_tidal_module(model, model_field, module): + """Either Obliqua or lovepy supplies a real per-mode spectrum, so both + are accepted for every model that requires one.""" + instance = _make_config_instance( + **{ + 'orbit.module': module, + f'orbit.{model_field}': model, + } + ) + orbit_requires_tides(instance, None, None) + assert instance.orbit.module == module # unmodified by the validator + + +@pytest.mark.unit +@pytest.mark.parametrize( + 'model,model_field', [('sp0d', 'star_planet_model'), ('ps0d', 'planet_satellite_model')] +) +def test_orbit_requires_tides_passes_for_0d_models_regardless_of_module(model, model_field): + """sp0d/ps0d read the scalar Imk2 rather than the per-mode tides_o + spectrum (dummy provides Imk2 too), so the restriction is specific to + the *1d models and must not fire for either 0d model even on dummy.""" + instance = _make_config_instance( + **{ + 'orbit.module': 'dummy', + f'orbit.{model_field}': model, + } + ) + orbit_requires_tides(instance, None, None) + + +# --------------------------------------------------------------------------- +# sp0d_obliqua_degree_mismatch: sp0d's scalar Imk2 is only ever meaningful +# for Obliqua's degree-2 output; run_orbit zeroes it for any other degree. +# --------------------------------------------------------------------------- +@pytest.mark.unit +def test_sp0d_obliqua_degree_mismatch_rejects_higher_degree(): + """sp0d + Obliqua configured for a non-degree-2 spectrum must raise: + run_orbit would silently zero hf_row['Imk2'] every iteration, freezing + sp0d's eccentricity evolution rather than reflecting the requested + degree(s).""" + instance = _make_config_instance( + **{ + 'orbit.module': 'obliqua', + 'orbit.star_planet_model': 'sp0d', + } + ) + instance.orbit.obliqua = SimpleNamespace(n=[2, 3]) + with pytest.raises(ValueError, match=r'sp0d') as excinfo: + sp0d_obliqua_degree_mismatch(instance, None, None) + # Discrimination: the message must name obliqua.n == [2] as the reason, + # not just "sp0d is bad", so the user knows what to change (n, or the + # model), and must name sp1d as the alternative to switch to. + msg = str(excinfo.value).lower() + assert 'n == [2]' in msg + assert 'sp1d' in msg # names the model to switch to + + +@pytest.mark.unit +def test_sp0d_obliqua_degree_mismatch_warns_but_passes_for_degree_two(caplog): + """sp0d + Obliqua configured for exactly n=[2] is accepted (Imk2 is a + real, non-zeroed value in this case), but still logs a warning: even at + n=[2], sp0d collapses Obliqua's per-mode spectrum to a single mean + scalar, an approximation sp1d avoids by consuming the per-mode data + directly.""" + instance = _make_config_instance( + **{ + 'orbit.module': 'obliqua', + 'orbit.star_planet_model': 'sp0d', + } + ) + instance.orbit.obliqua = SimpleNamespace(n=[2]) + with caplog.at_level('WARNING'): + sp0d_obliqua_degree_mismatch(instance, None, None) + assert any('sp1d' in rec.message.lower() for rec in caplog.records) + + +@pytest.mark.unit +def test_sp0d_obliqua_degree_mismatch_passes_for_sp1d_regardless_of_degree(): + """The restriction is sp0d-specific: sp1d consumes Obliqua's per-mode + Love-number spectrum directly (not a collapsed scalar Imk2), so a + non-degree-2 configuration is fine there. Discriminates that the + validator keys on star_planet_model=='sp0d', not merely on + module=='obliqua'.""" + instance = _make_config_instance( + **{ + 'orbit.module': 'obliqua', + 'orbit.star_planet_model': 'sp1d', + } + ) + instance.orbit.obliqua = SimpleNamespace(n=[2, 3]) + sp0d_obliqua_degree_mismatch(instance, None, None) + assert instance.orbit.star_planet_model == 'sp1d' + + +@pytest.mark.unit +def test_sp0d_obliqua_degree_mismatch_passes_when_module_is_not_obliqua(): + """sp0d with a non-Obliqua tides module (e.g. lovepy) never reads + orbit.obliqua.n at all -- must not raise regardless of its value, + confirming the check is gated on module=='obliqua' first.""" + instance = _make_config_instance( + **{ + 'orbit.module': 'lovepy', + 'orbit.star_planet_model': 'sp0d', + } + ) + instance.orbit.obliqua = SimpleNamespace(n=[2, 3]) # would fail if module were obliqua + sp0d_obliqua_degree_mismatch(instance, None, None) + assert instance.orbit.module == 'lovepy' # --------------------------------------------------------------------------- @@ -823,7 +986,7 @@ def test_satellite_evolve_passes_without_satellite(): HYPOTHESIS_ATMOS_CHEM = (None, 'dummy') # vulcan excluded (optional) HYPOTHESIS_ESCAPE = (None, 'dummy', 'zephyrus') # boreas excluded (optional) HYPOTHESIS_STAR = (None, 'mors', 'dummy') -HYPOTHESIS_ORBIT = (None, 'dummy', 'lovepy') +HYPOTHESIS_ORBIT = (None, 'dummy', 'lovepy', 'obliqua') HYPOTHESIS_INTERIOR_STRUCT = (None, 'dummy', 'spider', 'zalmoxis') diff --git a/tests/config/test_defaults.py b/tests/config/test_defaults.py index a124805da..6313fc3ef 100644 --- a/tests/config/test_defaults.py +++ b/tests/config/test_defaults.py @@ -74,6 +74,39 @@ def test_dt_params_defaults(): assert dt.atol == pytest.approx(0.02, rel=1e-12) assert dt.rtol == pytest.approx(0.10, rel=1e-12) + # Evection dt-cap trio: opt-in, disabled by default via None rather + # than a numeric 0 (which would be behaviourally indistinguishable + # from disabled but pass the >0 validator's exclusion silently). + assert dt.evection_maximum is None + assert dt.evection_growth_factor is None + assert dt.evection_cooldown_iters is None + + +@pytest.mark.unit +def test_dt_params_evection_trio_accepts_none_string_and_rejects_non_positive(): + """The evection dt-cap trio (evection_maximum/evection_growth_factor/ + evection_cooldown_iters) must accept the TOML string sentinel + ``'none'`` (structured to Python ``None`` by the ``none_if_none`` + converter, the same mechanism ``rot_period``/``phoenix_radius`` use) + and a strictly positive value, but reject both a zero and a negative + value -- 0 is deliberately NOT a valid opt-out spelling any more (it + was, before this test), only ``None``/``'none'`` disables the + mechanism. + """ + for field_name in ('evection_maximum', 'evection_growth_factor', 'evection_cooldown_iters'): + # 'none' string (TOML spelling) structures to Python None. + assert getattr(TimeStepParams(**{field_name: 'none'}), field_name) is None + # A genuine positive value is accepted and passed through untouched. + assert getattr(TimeStepParams(**{field_name: 5}), field_name) == 5 + + # Discrimination: 0 (the OLD opt-out spelling) and a negative + # value must both now be rejected, not silently accepted as + # another way to disable the mechanism. + with pytest.raises(ValueError): + TimeStepParams(**{field_name: 0}) + with pytest.raises(ValueError): + TimeStepParams(**{field_name: -1}) + @pytest.mark.unit def test_stop_params_defaults(): diff --git a/tests/data/integration/dummy/status b/tests/data/integration/dummy/status index d502b14a3..8e0add088 100644 --- a/tests/data/integration/dummy/status +++ b/tests/data/integration/dummy/status @@ -1,2 +1,2 @@ -13 -Completed (target time) +15 +Completed (volatiles escaped) diff --git a/tests/data/integration/zalmoxis_resume_mesh/init_coupler.toml b/tests/data/integration/zalmoxis_resume_mesh/init_coupler.toml index 929163a28..c0079170d 100644 --- a/tests/data/integration/zalmoxis_resume_mesh/init_coupler.toml +++ b/tests/data/integration/zalmoxis_resume_mesh/init_coupler.toml @@ -106,14 +106,15 @@ semimajoraxis = 1.0 eccentricity = 0.1 zenith_angle = 48.19 s0_factor = 0.375 -evolve = false axial_period = "none" -satellite = false -mass_sat = 7.347e+22 -semimajoraxis_sat = 300000000.0 instellation_method = "distance" instellationflux = 1.0 +[orbit.satellite] +include_satellite = false +mass_sat = 0.0123024 +semimajoraxis_sat = 47.3527 + [orbit.dummy] H_tide = 0.0 Phi_tide = "<0.3" diff --git a/tests/data/integration/zalmoxis_resume_mesh/runtime_helpfile.csv b/tests/data/integration/zalmoxis_resume_mesh/runtime_helpfile.csv index a02762105..450839ba3 100644 --- a/tests/data/integration/zalmoxis_resume_mesh/runtime_helpfile.csv +++ b/tests/data/integration/zalmoxis_resume_mesh/runtime_helpfile.csv @@ -1,2 +1,2 @@ -Time semimajorax separation perihelion orbital_period eccentricity Imk2 axial_period perigee semimajorax_sat M_sat plan_sat_am R_int M_int M_planet M_vaps R_core R_solvus P_solvus T_solvus P_center P_cmb core_density core_heatcap X_H2_int struct_mass_desync_frac T_surf T_magma T_cmb T_eqm T_skin T_surface_initial T_surf_accr T_cmb_initial DeltaT_accretion DeltaT_adiabat DeltaT_differentiation U_grav_diff U_grav_undiff F_int F_atm F_net F_olr F_sct F_ins F_xuv bol_scale tau_atm_TOA tau_atm_surface atm_Ra_max atm_t_conv_over_t_rad atm_converged atm_levels_stale F_tidal F_radio F_cmb gravity Phi_global Phi_global_vol RF_depth M_core M_mantle M_mantle_solid M_mantle_liquid T_pot boundary_layer_thickness E_th_mantle E_state_J E_state_cons_J Q_radio_W Q_tidal_W step_dE_F_int_J step_dE_F_cmb_J step_dE_Q_radio_J step_dE_Q_tidal_J step_dE_Q_radio_cons_J step_dE_Q_tidal_cons_J step_solver_residual_J step_dE_compression_J step_dE_state_heat_J step_dE_impact_J E_state_heat_cons_J dE_predicted_cons_J E_residual_cons_J E_residual_cons_frac solver_residual_J Cp_eff M_star R_star age_star T_star p_obs R_obs T_obs g_obs rho_obs transit_depth eclipse_depth albedo_pl bond_albedo M_ele M_atm P_surf P_vap P_vol atm_kg_per_mol M_vol_atm fO2_shift_IW_derived fO2_vapourise_derived fO2_vapourise_shift_IW_derived O_res O_vapourised_kg M_vol_initial esc_kg_cumulative M_accreted_rock H2O_mol_atm H2O_mol_solid H2O_mol_liquid H2O_mol_total H2O_kg_atm H2O_kg_solid H2O_kg_liquid H2O_kg_total H2O_vmr H2O_bar H2O_vmr_xuv CO2_mol_atm CO2_mol_solid CO2_mol_liquid CO2_mol_total CO2_kg_atm CO2_kg_solid CO2_kg_liquid CO2_kg_total CO2_vmr CO2_bar CO2_vmr_xuv O2_mol_atm O2_mol_solid O2_mol_liquid O2_mol_total O2_kg_atm O2_kg_solid O2_kg_liquid O2_kg_total O2_vmr O2_bar O2_vmr_xuv H2_mol_atm H2_mol_solid H2_mol_liquid H2_mol_total H2_kg_atm H2_kg_solid H2_kg_liquid H2_kg_total H2_vmr H2_bar H2_vmr_xuv CH4_mol_atm CH4_mol_solid CH4_mol_liquid CH4_mol_total CH4_kg_atm CH4_kg_solid CH4_kg_liquid CH4_kg_total CH4_vmr CH4_bar CH4_vmr_xuv CO_mol_atm CO_mol_solid CO_mol_liquid CO_mol_total CO_kg_atm CO_kg_solid CO_kg_liquid CO_kg_total CO_vmr CO_bar CO_vmr_xuv N2_mol_atm N2_mol_solid N2_mol_liquid N2_mol_total N2_kg_atm N2_kg_solid N2_kg_liquid N2_kg_total N2_vmr N2_bar N2_vmr_xuv NH3_mol_atm NH3_mol_solid NH3_mol_liquid NH3_mol_total NH3_kg_atm NH3_kg_solid NH3_kg_liquid NH3_kg_total NH3_vmr NH3_bar NH3_vmr_xuv S2_mol_atm S2_mol_solid S2_mol_liquid S2_mol_total S2_kg_atm S2_kg_solid S2_kg_liquid S2_kg_total S2_vmr S2_bar S2_vmr_xuv SO2_mol_atm SO2_mol_solid SO2_mol_liquid SO2_mol_total SO2_kg_atm SO2_kg_solid SO2_kg_liquid SO2_kg_total SO2_vmr SO2_bar SO2_vmr_xuv H2S_mol_atm H2S_mol_solid H2S_mol_liquid H2S_mol_total H2S_kg_atm H2S_kg_solid H2S_kg_liquid H2S_kg_total H2S_vmr H2S_bar H2S_vmr_xuv He_mol_atm He_mol_solid He_mol_liquid He_mol_total He_kg_atm He_kg_solid He_kg_liquid He_kg_total He_vmr He_bar He_vmr_xuv Ne_mol_atm Ne_mol_solid Ne_mol_liquid Ne_mol_total Ne_kg_atm Ne_kg_solid Ne_kg_liquid Ne_kg_total Ne_vmr Ne_bar Ne_vmr_xuv Ar_mol_atm Ar_mol_solid Ar_mol_liquid Ar_mol_total Ar_kg_atm Ar_kg_solid Ar_kg_liquid Ar_kg_total Ar_vmr Ar_bar Ar_vmr_xuv Kr_mol_atm Kr_mol_solid Kr_mol_liquid Kr_mol_total Kr_kg_atm Kr_kg_solid Kr_kg_liquid Kr_kg_total Kr_vmr Kr_bar Kr_vmr_xuv Xe_mol_atm Xe_mol_solid Xe_mol_liquid Xe_mol_total Xe_kg_atm Xe_kg_solid Xe_kg_liquid Xe_kg_total Xe_vmr Xe_bar Xe_vmr_xuv SiO_mol_atm SiO_mol_solid SiO_mol_liquid SiO_mol_total SiO_kg_atm SiO_kg_solid SiO_kg_liquid SiO_kg_total SiO_vmr SiO_bar SiO_vmr_xuv SiO2_mol_atm SiO2_mol_solid SiO2_mol_liquid SiO2_mol_total SiO2_kg_atm SiO2_kg_solid SiO2_kg_liquid SiO2_kg_total SiO2_vmr SiO2_bar SiO2_vmr_xuv Si_mol_atm Si_mol_solid Si_mol_liquid Si_mol_total Si_kg_atm Si_kg_solid Si_kg_liquid Si_kg_total Si_vmr Si_bar Si_vmr_xuv Na_mol_atm Na_mol_solid Na_mol_liquid Na_mol_total Na_kg_atm Na_kg_solid Na_kg_liquid Na_kg_total Na_vmr Na_bar Na_vmr_xuv K_mol_atm K_mol_solid K_mol_liquid K_mol_total K_kg_atm K_kg_solid K_kg_liquid K_kg_total K_vmr K_bar K_vmr_xuv Ti_mol_atm Ti_mol_solid Ti_mol_liquid Ti_mol_total Ti_kg_atm Ti_kg_solid Ti_kg_liquid Ti_kg_total Ti_vmr Ti_bar Ti_vmr_xuv TiO_mol_atm TiO_mol_solid TiO_mol_liquid TiO_mol_total TiO_kg_atm TiO_kg_solid TiO_kg_liquid TiO_kg_total TiO_vmr TiO_bar TiO_vmr_xuv TiO2_mol_atm TiO2_mol_solid TiO2_mol_liquid TiO2_mol_total TiO2_kg_atm TiO2_kg_solid TiO2_kg_liquid TiO2_kg_total TiO2_vmr TiO2_bar TiO2_vmr_xuv Mg_mol_atm Mg_mol_solid Mg_mol_liquid Mg_mol_total Mg_kg_atm Mg_kg_solid Mg_kg_liquid Mg_kg_total Mg_vmr Mg_bar Mg_vmr_xuv MgO_mol_atm MgO_mol_solid MgO_mol_liquid MgO_mol_total MgO_kg_atm MgO_kg_solid MgO_kg_liquid MgO_kg_total MgO_vmr MgO_bar MgO_vmr_xuv Al_mol_atm Al_mol_solid Al_mol_liquid Al_mol_total Al_kg_atm Al_kg_solid Al_kg_liquid Al_kg_total Al_vmr Al_bar Al_vmr_xuv HAlO2_mol_atm HAlO2_mol_solid HAlO2_mol_liquid HAlO2_mol_total HAlO2_kg_atm HAlO2_kg_solid HAlO2_kg_liquid HAlO2_kg_total HAlO2_vmr HAlO2_bar HAlO2_vmr_xuv SiH_mol_atm SiH_mol_solid SiH_mol_liquid SiH_mol_total SiH_kg_atm SiH_kg_solid SiH_kg_liquid SiH_kg_total SiH_vmr SiH_bar SiH_vmr_xuv SiH4_mol_atm SiH4_mol_solid SiH4_mol_liquid SiH4_mol_total SiH4_kg_atm SiH4_kg_solid SiH4_kg_liquid SiH4_kg_total SiH4_vmr SiH4_bar SiH4_vmr_xuv Fe_mol_atm Fe_mol_solid Fe_mol_liquid Fe_mol_total Fe_kg_atm Fe_kg_solid Fe_kg_liquid Fe_kg_total Fe_vmr Fe_bar Fe_vmr_xuv FeO_mol_atm FeO_mol_solid FeO_mol_liquid FeO_mol_total FeO_kg_atm FeO_kg_solid FeO_kg_liquid FeO_kg_total FeO_vmr FeO_bar FeO_vmr_xuv FeO2H2_mol_atm FeO2H2_mol_solid FeO2H2_mol_liquid FeO2H2_mol_total FeO2H2_kg_atm FeO2H2_kg_solid FeO2H2_kg_liquid FeO2H2_kg_total FeO2H2_vmr FeO2H2_bar FeO2H2_vmr_xuv CaO_mol_atm CaO_mol_solid CaO_mol_liquid CaO_mol_total CaO_kg_atm CaO_kg_solid CaO_kg_liquid CaO_kg_total CaO_vmr CaO_bar CaO_vmr_xuv NaOH_mol_atm NaOH_mol_solid NaOH_mol_liquid NaOH_mol_total NaOH_kg_atm NaOH_kg_solid NaOH_kg_liquid NaOH_kg_total NaOH_vmr NaOH_bar NaOH_vmr_xuv Ca_mol_atm Ca_mol_solid Ca_mol_liquid Ca_mol_total Ca_kg_atm Ca_kg_solid Ca_kg_liquid Ca_kg_total Ca_vmr Ca_bar Ca_vmr_xuv KOH_mol_atm KOH_mol_solid KOH_mol_liquid KOH_mol_total KOH_kg_atm KOH_kg_solid KOH_kg_liquid KOH_kg_total KOH_vmr KOH_bar KOH_vmr_xuv H_kg_atm H_kg_solid H_kg_liquid H_kg_total O_kg_atm O_kg_solid O_kg_liquid O_kg_total C_kg_atm C_kg_solid C_kg_liquid C_kg_total N_kg_atm N_kg_solid N_kg_liquid N_kg_total S_kg_atm S_kg_solid S_kg_liquid S_kg_total O/H_atm C/H_atm N/H_atm S/H_atm Si/H_atm Mg/H_atm Fe/H_atm Na/H_atm Al/H_atm Ti/H_atm Ca/H_atm K/H_atm He/H_atm Ne/H_atm Ar/H_atm Kr/H_atm Xe/H_atm C/O_atm N/O_atm S/O_atm Si/O_atm Mg/O_atm Fe/O_atm Na/O_atm Al/O_atm Ti/O_atm Ca/O_atm K/O_atm He/O_atm Ne/O_atm Ar/O_atm Kr/O_atm Xe/O_atm N/C_atm S/C_atm Si/C_atm Mg/C_atm Fe/C_atm Na/C_atm Al/C_atm Ti/C_atm Ca/C_atm K/C_atm He/C_atm Ne/C_atm Ar/C_atm Kr/C_atm Xe/C_atm S/N_atm Si/N_atm Mg/N_atm Fe/N_atm Na/N_atm Al/N_atm Ti/N_atm Ca/N_atm K/N_atm He/N_atm Ne/N_atm Ar/N_atm Kr/N_atm Xe/N_atm Si/S_atm Mg/S_atm Fe/S_atm Na/S_atm Al/S_atm Ti/S_atm Ca/S_atm K/S_atm He/S_atm Ne/S_atm Ar/S_atm Kr/S_atm Xe/S_atm Mg/Si_atm Fe/Si_atm Na/Si_atm Al/Si_atm Ti/Si_atm Ca/Si_atm K/Si_atm He/Si_atm Ne/Si_atm Ar/Si_atm Kr/Si_atm Xe/Si_atm Fe/Mg_atm Na/Mg_atm Al/Mg_atm Ti/Mg_atm Ca/Mg_atm K/Mg_atm He/Mg_atm Ne/Mg_atm Ar/Mg_atm Kr/Mg_atm Xe/Mg_atm Na/Fe_atm Al/Fe_atm Ti/Fe_atm Ca/Fe_atm K/Fe_atm He/Fe_atm Ne/Fe_atm Ar/Fe_atm Kr/Fe_atm Xe/Fe_atm Al/Na_atm Ti/Na_atm Ca/Na_atm K/Na_atm He/Na_atm Ne/Na_atm Ar/Na_atm Kr/Na_atm Xe/Na_atm Ti/Al_atm Ca/Al_atm K/Al_atm He/Al_atm Ne/Al_atm Ar/Al_atm Kr/Al_atm Xe/Al_atm Ca/Ti_atm K/Ti_atm He/Ti_atm Ne/Ti_atm Ar/Ti_atm Kr/Ti_atm Xe/Ti_atm K/Ca_atm He/Ca_atm Ne/Ca_atm Ar/Ca_atm Kr/Ca_atm Xe/Ca_atm He/K_atm Ne/K_atm Ar/K_atm Kr/K_atm Xe/K_atm Ne/He_atm Ar/He_atm Kr/He_atm Xe/He_atm Ar/Ne_atm Kr/Ne_atm Xe/Ne_atm Kr/Ar_atm Xe/Ar_atm Xe/Kr_atm p_xuv R_xuv T_xuv g_xuv cs_xuv esc_rate_total esc_rate_H esc_rate_O esc_rate_C esc_rate_N esc_rate_S esc_rate_Si esc_rate_Mg esc_rate_Fe esc_rate_Na esc_rate_Al esc_rate_Ti esc_rate_Ca esc_rate_K esc_rate_He esc_rate_Ne esc_rate_Ar esc_rate_Kr esc_rate_Xe P_surf_clim ocean_areacov ocean_maxdepth H2O_ocean CO2_ocean O2_ocean H2_ocean CH4_ocean CO_ocean N2_ocean NH3_ocean S2_ocean SO2_ocean H2S_ocean He_ocean Ne_ocean Ar_ocean Kr_ocean Xe_ocean SiO_ocean SiO2_ocean Si_ocean Na_ocean K_ocean Ti_ocean TiO_ocean TiO2_ocean Mg_ocean MgO_ocean Al_ocean HAlO2_ocean SiH_ocean SiH4_ocean Fe_ocean FeO_ocean FeO2H2_ocean CaO_ocean NaOH_ocean Ca_ocean KOH_ocean wtg_surf roche_limit breakup_period hill_radius runtime esc_clamp_frac esc_step_kg -500000.0 149588857530.0 150330837760.0 134689731790.0 31555318.91 0.099600504944 0.0 31555318.91 300000000.0 300000000.0 0.0 0.0 5851847.4239 2.9918200757e+24 2.9942670436e+24 0.0 2867012.4963 0.0 0.0 0.0 184117858870.0 54848888186.0 9964.7176939 450.0 0.0 0.0025316216401 2071.7525778 2083.9503773 5204.8837484 248.26038044 208.76126396 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 2144.2783518 2439.6502906 -295.37193884 2582.417861 0.83360985336 638.23138961 0.63118521704 1.0 0.0 0.39572373113 2.9838308301e+30 0.36630620001 1.0 0.0 0.0 0.15882111774 526101152.19 5.8312328304 0.45454915877 1.0 0.51006711409 9.8365400909e+23 2.0081660666e+24 4.9417898632e+21 2.0032242767e+24 4111.4605922 0.01 6.7751359511e+31 5.9537792533e+31 5.8673816178e+31 68195655722000.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 4.7300089493e+31 -3.6794441307e+28 -4.4253158672e+28 7.458717365e+27 0.20271315721 127535417750000.0 7392.0852458 1.988416e+30 805203223.72 10500003.0 4453.6896099 0.02 6279055.3057 374.54009896 5.0516264213 2887.4800746 6.0810364516e-05 7.0612402272e-09 0.1 0.0058050348317 2.446967954e+21 4.231338048e+20 57.384176268 0.0 57.384176268 0.029819752498 4.231338048e+20 1.0 0.0 0.0 0.0 0.0 2.5665374146e+21 2.7648834498e+19 8.0342665158e+21 0.0 0.0 0.0 0.0 4.5347472676e+19 0.0 1.8964641571e+21 1.9418116298e+21 0.17739370768 10.17959179 0.014186370399 0.0 0.0 0.0 0.0 1.9196786983e+20 0.0 7.3482452744e+18 1.993161151e+20 0.30740287592 17.640060817 0.36839244689 0.0 0.0 0.0 0.0 24737340022000.0 0.0 0.0 24737340022000.0 5.4481051287e-08 3.1263502503e-06 6.5290240806e-08 0.0 0.0 0.0 0.0 1.5202895508e+18 0.0 2.0789363419e+19 2.230965297e+19 0.053148071659 3.0498583123 0.063692794375 0.0 0.0 0.0 0.0 8551283306700.0 0.0 27108064837.0 8578391371500.0 3.7565177658e-08 2.1556467763e-06 4.5018211604e-08 0.0 0.0 0.0 0.0 1.7870156033e+20 0.0 7.8069308954e+17 1.7948225342e+20 0.44961345144 25.80069755 0.53881798938 0.0 0.0 0.0 0.0 3.7274829449e+18 0.0 2.1801514792e+16 3.7492844597e+18 0.0093772621614 0.53810646478 0.01123773661 0.0 0.0 0.0 0.0 291314008310000.0 0.0 0.0 291314008310000.0 1.2054789068e-06 6.9175414076e-05 1.4446492175e-06 0.0 0.0 0.0 0.0 1.0641139912e+17 0.0 9.8449968827e+19 9.8556380226e+19 0.00011693807241 0.0067103949597 0.00014013890567 0.0 0.0 0.0 0.0 7.2119172374e+17 0.0 0.0 7.2119172374e+17 0.00079335147763 0.045525821034 0.00095075457969 0.0 0.0 0.0 0.0 1.0412017454e+18 0.0 0.0 1.0412017454e+18 0.0021530440674 0.12355066028 0.0025802138965 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 6.6566309041e+18 0.0 2.3301649308e+20 2.3965693124e+20 2.822811178e+20 0.0 1.6900257022e+21 1.97230682e+21 1.2902137499e+20 1.7933351268e-06 2.3402636528e+18 1.3135771686e+20 3.7277225336e+18 0.0 2.1801514792e+16 3.74952405e+18 1.4469585714e+18 0.0 9.8449968827e+19 9.9896961894e+19 42.406004159 19.382383798 0.56000138618 0.21737100829 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.45706696924 0.013205709835 0.0051259488509 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.028892286522 0.011214874834 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.38816155398 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 5e-05 6347114.6903 199.32618547 4.9438715352 0.0 754471.30136 11480.842364 503579.64272 230554.76915 6666.2864207 2189.7607018 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 57.384176268 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 652.27950112 643418561.04 6294.3286092 1070130669.3 6981.347952 0.0 0.0 +Time semimajorax separation perihelion orbital_period eccentricity Imk2 axial_period perigee semimajorax_sat M_sat plan_sat_am R_int M_int M_planet M_vaps R_core R_solvus P_solvus T_solvus P_center P_cmb core_density core_heatcap X_H2_int struct_mass_desync_frac T_surf T_magma T_cmb T_eqm T_skin T_surface_initial T_surf_accr T_cmb_initial DeltaT_accretion DeltaT_adiabat DeltaT_differentiation U_grav_diff U_grav_undiff F_int F_atm F_net F_olr F_sct F_ins F_xuv bol_scale tau_atm_TOA tau_atm_surface atm_Ra_max atm_t_conv_over_t_rad atm_converged atm_levels_stale F_tidal F_radio F_cmb gravity Phi_global Phi_global_vol RF_depth M_core M_mantle M_mantle_solid M_mantle_liquid T_pot boundary_layer_thickness E_th_mantle E_state_J E_state_cons_J Q_radio_W Q_tidal_W step_dE_F_int_J step_dE_F_cmb_J step_dE_Q_radio_J step_dE_Q_tidal_J step_dE_Q_radio_cons_J step_dE_Q_tidal_cons_J step_solver_residual_J step_dE_compression_J step_dE_state_heat_J step_dE_impact_J E_state_heat_cons_J dE_predicted_cons_J E_residual_cons_J E_residual_cons_frac solver_residual_J Cp_eff M_star R_star age_star T_star p_obs R_obs T_obs g_obs rho_obs transit_depth eclipse_depth albedo_pl bond_albedo M_ele M_atm P_surf P_vap P_vol atm_kg_per_mol M_vol_atm fO2_shift_IW_derived fO2_vapourise_derived fO2_vapourise_shift_IW_derived O_res O_vapourised_kg M_vol_initial esc_kg_cumulative M_accreted_rock H2O_mol_atm H2O_mol_solid H2O_mol_liquid H2O_mol_total H2O_kg_atm H2O_kg_solid H2O_kg_liquid H2O_kg_total H2O_vmr H2O_bar H2O_vmr_xuv CO2_mol_atm CO2_mol_solid CO2_mol_liquid CO2_mol_total CO2_kg_atm CO2_kg_solid CO2_kg_liquid CO2_kg_total CO2_vmr CO2_bar CO2_vmr_xuv O2_mol_atm O2_mol_solid O2_mol_liquid O2_mol_total O2_kg_atm O2_kg_solid O2_kg_liquid O2_kg_total O2_vmr O2_bar O2_vmr_xuv H2_mol_atm H2_mol_solid H2_mol_liquid H2_mol_total H2_kg_atm H2_kg_solid H2_kg_liquid H2_kg_total H2_vmr H2_bar H2_vmr_xuv CH4_mol_atm CH4_mol_solid CH4_mol_liquid CH4_mol_total CH4_kg_atm CH4_kg_solid CH4_kg_liquid CH4_kg_total CH4_vmr CH4_bar CH4_vmr_xuv CO_mol_atm CO_mol_solid CO_mol_liquid CO_mol_total CO_kg_atm CO_kg_solid CO_kg_liquid CO_kg_total CO_vmr CO_bar CO_vmr_xuv N2_mol_atm N2_mol_solid N2_mol_liquid N2_mol_total N2_kg_atm N2_kg_solid N2_kg_liquid N2_kg_total N2_vmr N2_bar N2_vmr_xuv NH3_mol_atm NH3_mol_solid NH3_mol_liquid NH3_mol_total NH3_kg_atm NH3_kg_solid NH3_kg_liquid NH3_kg_total NH3_vmr NH3_bar NH3_vmr_xuv S2_mol_atm S2_mol_solid S2_mol_liquid S2_mol_total S2_kg_atm S2_kg_solid S2_kg_liquid S2_kg_total S2_vmr S2_bar S2_vmr_xuv SO2_mol_atm SO2_mol_solid SO2_mol_liquid SO2_mol_total SO2_kg_atm SO2_kg_solid SO2_kg_liquid SO2_kg_total SO2_vmr SO2_bar SO2_vmr_xuv H2S_mol_atm H2S_mol_solid H2S_mol_liquid H2S_mol_total H2S_kg_atm H2S_kg_solid H2S_kg_liquid H2S_kg_total H2S_vmr H2S_bar H2S_vmr_xuv He_mol_atm He_mol_solid He_mol_liquid He_mol_total He_kg_atm He_kg_solid He_kg_liquid He_kg_total He_vmr He_bar He_vmr_xuv Ne_mol_atm Ne_mol_solid Ne_mol_liquid Ne_mol_total Ne_kg_atm Ne_kg_solid Ne_kg_liquid Ne_kg_total Ne_vmr Ne_bar Ne_vmr_xuv Ar_mol_atm Ar_mol_solid Ar_mol_liquid Ar_mol_total Ar_kg_atm Ar_kg_solid Ar_kg_liquid Ar_kg_total Ar_vmr Ar_bar Ar_vmr_xuv Kr_mol_atm Kr_mol_solid Kr_mol_liquid Kr_mol_total Kr_kg_atm Kr_kg_solid Kr_kg_liquid Kr_kg_total Kr_vmr Kr_bar Kr_vmr_xuv Xe_mol_atm Xe_mol_solid Xe_mol_liquid Xe_mol_total Xe_kg_atm Xe_kg_solid Xe_kg_liquid Xe_kg_total Xe_vmr Xe_bar Xe_vmr_xuv SiO_mol_atm SiO_mol_solid SiO_mol_liquid SiO_mol_total SiO_kg_atm SiO_kg_solid SiO_kg_liquid SiO_kg_total SiO_vmr SiO_bar SiO_vmr_xuv SiO2_mol_atm SiO2_mol_solid SiO2_mol_liquid SiO2_mol_total SiO2_kg_atm SiO2_kg_solid SiO2_kg_liquid SiO2_kg_total SiO2_vmr SiO2_bar SiO2_vmr_xuv Si_mol_atm Si_mol_solid Si_mol_liquid Si_mol_total Si_kg_atm Si_kg_solid Si_kg_liquid Si_kg_total Si_vmr Si_bar Si_vmr_xuv Na_mol_atm Na_mol_solid Na_mol_liquid Na_mol_total Na_kg_atm Na_kg_solid Na_kg_liquid Na_kg_total Na_vmr Na_bar Na_vmr_xuv K_mol_atm K_mol_solid K_mol_liquid K_mol_total K_kg_atm K_kg_solid K_kg_liquid K_kg_total K_vmr K_bar K_vmr_xuv Ti_mol_atm Ti_mol_solid Ti_mol_liquid Ti_mol_total Ti_kg_atm Ti_kg_solid Ti_kg_liquid Ti_kg_total Ti_vmr Ti_bar Ti_vmr_xuv TiO_mol_atm TiO_mol_solid TiO_mol_liquid TiO_mol_total TiO_kg_atm TiO_kg_solid TiO_kg_liquid TiO_kg_total TiO_vmr TiO_bar TiO_vmr_xuv TiO2_mol_atm TiO2_mol_solid TiO2_mol_liquid TiO2_mol_total TiO2_kg_atm TiO2_kg_solid TiO2_kg_liquid TiO2_kg_total TiO2_vmr TiO2_bar TiO2_vmr_xuv Mg_mol_atm Mg_mol_solid Mg_mol_liquid Mg_mol_total Mg_kg_atm Mg_kg_solid Mg_kg_liquid Mg_kg_total Mg_vmr Mg_bar Mg_vmr_xuv MgO_mol_atm MgO_mol_solid MgO_mol_liquid MgO_mol_total MgO_kg_atm MgO_kg_solid MgO_kg_liquid MgO_kg_total MgO_vmr MgO_bar MgO_vmr_xuv Al_mol_atm Al_mol_solid Al_mol_liquid Al_mol_total Al_kg_atm Al_kg_solid Al_kg_liquid Al_kg_total Al_vmr Al_bar Al_vmr_xuv HAlO2_mol_atm HAlO2_mol_solid HAlO2_mol_liquid HAlO2_mol_total HAlO2_kg_atm HAlO2_kg_solid HAlO2_kg_liquid HAlO2_kg_total HAlO2_vmr HAlO2_bar HAlO2_vmr_xuv SiH_mol_atm SiH_mol_solid SiH_mol_liquid SiH_mol_total SiH_kg_atm SiH_kg_solid SiH_kg_liquid SiH_kg_total SiH_vmr SiH_bar SiH_vmr_xuv SiH4_mol_atm SiH4_mol_solid SiH4_mol_liquid SiH4_mol_total SiH4_kg_atm SiH4_kg_solid SiH4_kg_liquid SiH4_kg_total SiH4_vmr SiH4_bar SiH4_vmr_xuv Fe_mol_atm Fe_mol_solid Fe_mol_liquid Fe_mol_total Fe_kg_atm Fe_kg_solid Fe_kg_liquid Fe_kg_total Fe_vmr Fe_bar Fe_vmr_xuv FeO_mol_atm FeO_mol_solid FeO_mol_liquid FeO_mol_total FeO_kg_atm FeO_kg_solid FeO_kg_liquid FeO_kg_total FeO_vmr FeO_bar FeO_vmr_xuv FeO2H2_mol_atm FeO2H2_mol_solid FeO2H2_mol_liquid FeO2H2_mol_total FeO2H2_kg_atm FeO2H2_kg_solid FeO2H2_kg_liquid FeO2H2_kg_total FeO2H2_vmr FeO2H2_bar FeO2H2_vmr_xuv CaO_mol_atm CaO_mol_solid CaO_mol_liquid CaO_mol_total CaO_kg_atm CaO_kg_solid CaO_kg_liquid CaO_kg_total CaO_vmr CaO_bar CaO_vmr_xuv NaOH_mol_atm NaOH_mol_solid NaOH_mol_liquid NaOH_mol_total NaOH_kg_atm NaOH_kg_solid NaOH_kg_liquid NaOH_kg_total NaOH_vmr NaOH_bar NaOH_vmr_xuv Ca_mol_atm Ca_mol_solid Ca_mol_liquid Ca_mol_total Ca_kg_atm Ca_kg_solid Ca_kg_liquid Ca_kg_total Ca_vmr Ca_bar Ca_vmr_xuv KOH_mol_atm KOH_mol_solid KOH_mol_liquid KOH_mol_total KOH_kg_atm KOH_kg_solid KOH_kg_liquid KOH_kg_total KOH_vmr KOH_bar KOH_vmr_xuv H_kg_atm H_kg_solid H_kg_liquid H_kg_total O_kg_atm O_kg_solid O_kg_liquid O_kg_total C_kg_atm C_kg_solid C_kg_liquid C_kg_total N_kg_atm N_kg_solid N_kg_liquid N_kg_total S_kg_atm S_kg_solid S_kg_liquid S_kg_total O/H_atm C/H_atm N/H_atm S/H_atm Si/H_atm Mg/H_atm Fe/H_atm Na/H_atm Al/H_atm Ti/H_atm Ca/H_atm K/H_atm He/H_atm Ne/H_atm Ar/H_atm Kr/H_atm Xe/H_atm C/O_atm N/O_atm S/O_atm Si/O_atm Mg/O_atm Fe/O_atm Na/O_atm Al/O_atm Ti/O_atm Ca/O_atm K/O_atm He/O_atm Ne/O_atm Ar/O_atm Kr/O_atm Xe/O_atm N/C_atm S/C_atm Si/C_atm Mg/C_atm Fe/C_atm Na/C_atm Al/C_atm Ti/C_atm Ca/C_atm K/C_atm He/C_atm Ne/C_atm Ar/C_atm Kr/C_atm Xe/C_atm S/N_atm Si/N_atm Mg/N_atm Fe/N_atm Na/N_atm Al/N_atm Ti/N_atm Ca/N_atm K/N_atm He/N_atm Ne/N_atm Ar/N_atm Kr/N_atm Xe/N_atm Si/S_atm Mg/S_atm Fe/S_atm Na/S_atm Al/S_atm Ti/S_atm Ca/S_atm K/S_atm He/S_atm Ne/S_atm Ar/S_atm Kr/S_atm Xe/S_atm Mg/Si_atm Fe/Si_atm Na/Si_atm Al/Si_atm Ti/Si_atm Ca/Si_atm K/Si_atm He/Si_atm Ne/Si_atm Ar/Si_atm Kr/Si_atm Xe/Si_atm Fe/Mg_atm Na/Mg_atm Al/Mg_atm Ti/Mg_atm Ca/Mg_atm K/Mg_atm He/Mg_atm Ne/Mg_atm Ar/Mg_atm Kr/Mg_atm Xe/Mg_atm Na/Fe_atm Al/Fe_atm Ti/Fe_atm Ca/Fe_atm K/Fe_atm He/Fe_atm Ne/Fe_atm Ar/Fe_atm Kr/Fe_atm Xe/Fe_atm Al/Na_atm Ti/Na_atm Ca/Na_atm K/Na_atm He/Na_atm Ne/Na_atm Ar/Na_atm Kr/Na_atm Xe/Na_atm Ti/Al_atm Ca/Al_atm K/Al_atm He/Al_atm Ne/Al_atm Ar/Al_atm Kr/Al_atm Xe/Al_atm Ca/Ti_atm K/Ti_atm He/Ti_atm Ne/Ti_atm Ar/Ti_atm Kr/Ti_atm Xe/Ti_atm K/Ca_atm He/Ca_atm Ne/Ca_atm Ar/Ca_atm Kr/Ca_atm Xe/Ca_atm He/K_atm Ne/K_atm Ar/K_atm Kr/K_atm Xe/K_atm Ne/He_atm Ar/He_atm Kr/He_atm Xe/He_atm Ar/Ne_atm Kr/Ne_atm Xe/Ne_atm Kr/Ar_atm Xe/Ar_atm Xe/Kr_atm p_xuv R_xuv T_xuv g_xuv cs_xuv esc_rate_total esc_rate_H esc_rate_O esc_rate_C esc_rate_N esc_rate_S esc_rate_Si esc_rate_Mg esc_rate_Fe esc_rate_Na esc_rate_Al esc_rate_Ti esc_rate_Ca esc_rate_K esc_rate_He esc_rate_Ne esc_rate_Ar esc_rate_Kr esc_rate_Xe P_surf_clim ocean_areacov ocean_maxdepth H2O_ocean CO2_ocean O2_ocean H2_ocean CH4_ocean CO_ocean N2_ocean NH3_ocean S2_ocean SO2_ocean H2S_ocean He_ocean Ne_ocean Ar_ocean Kr_ocean Xe_ocean SiO_ocean SiO2_ocean Si_ocean Na_ocean K_ocean Ti_ocean TiO_ocean TiO2_ocean Mg_ocean MgO_ocean Al_ocean HAlO2_ocean SiH_ocean SiH4_ocean Fe_ocean FeO_ocean FeO2H2_ocean CaO_ocean NaOH_ocean Ca_ocean KOH_ocean wtg_surf roche_limit breakup_period hill_radius runtime esc_clamp_frac esc_step_kg C_int C_sat R_sat axial_period_sat breakup_period_sat ecc_dot_planet ecc_dot_sat eccentricity_sat evection_angle evection_dt_cap_yr latitude longitude orbital_period_sat plan_star_am roche_limit_sat separation_sat sma_dot_planet sma_dot_sat +500000.0 149588857530.0 150330837760.0 134689731790.0 31555318.91 0.099600504944 0.0 31555318.91 300000000.0 300000000.0 0.0 0.0 5851847.4239 2.9918200757e+24 2.9942670436e+24 0.0 2867012.4963 0.0 0.0 0.0 184117858870.0 54848888186.0 9964.7176939 450.0 0.0 0.0025316216401 2071.7525778 2083.9503773 5204.8837484 248.26038044 208.76126396 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 2144.2783518 2439.6502906 -295.37193884 2582.417861 0.83360985336 638.23138961 0.63118521704 1.0 0.0 0.39572373113 2.9838308301e+30 0.36630620001 1.0 0.0 0.0 0.15882111774 526101152.19 5.8312328304 0.45454915877 1.0 0.51006711409 9.8365400909e+23 2.0081660666e+24 4.9417898632e+21 2.0032242767e+24 4111.4605922 0.01 6.7751359511e+31 5.9537792533e+31 5.8673816178e+31 68195655722000.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 4.7300089493e+31 -3.6794441307e+28 -4.4253158672e+28 7.458717365e+27 0.20271315721 127535417750000.0 7392.0852458 1.988416e+30 805203223.72 10500003.0 4453.6896099 0.02 6279055.3057 374.54009896 5.0516264213 2887.4800746 6.0810364516e-05 7.0612402272e-09 0.1 0.0058050348317 2.446967954e+21 4.231338048e+20 57.384176268 0.0 57.384176268 0.029819752498 4.231338048e+20 1.0 0.0 0.0 0.0 0.0 2.5665374146e+21 2.7648834498e+19 8.0342665158e+21 0.0 0.0 0.0 0.0 4.5347472676e+19 0.0 1.8964641571e+21 1.9418116298e+21 0.17739370768 10.17959179 0.014186370399 0.0 0.0 0.0 0.0 1.9196786983e+20 0.0 7.3482452744e+18 1.993161151e+20 0.30740287592 17.640060817 0.36839244689 0.0 0.0 0.0 0.0 24737340022000.0 0.0 0.0 24737340022000.0 5.4481051287e-08 3.1263502503e-06 6.5290240806e-08 0.0 0.0 0.0 0.0 1.5202895508e+18 0.0 2.0789363419e+19 2.230965297e+19 0.053148071659 3.0498583123 0.063692794375 0.0 0.0 0.0 0.0 8551283306700.0 0.0 27108064837.0 8578391371500.0 3.7565177658e-08 2.1556467763e-06 4.5018211604e-08 0.0 0.0 0.0 0.0 1.7870156033e+20 0.0 7.8069308954e+17 1.7948225342e+20 0.44961345144 25.80069755 0.53881798938 0.0 0.0 0.0 0.0 3.7274829449e+18 0.0 2.1801514792e+16 3.7492844597e+18 0.0093772621614 0.53810646478 0.01123773661 0.0 0.0 0.0 0.0 291314008310000.0 0.0 0.0 291314008310000.0 1.2054789068e-06 6.9175414076e-05 1.4446492175e-06 0.0 0.0 0.0 0.0 1.0641139912e+17 0.0 9.8449968827e+19 9.8556380226e+19 0.00011693807241 0.0067103949597 0.00014013890567 0.0 0.0 0.0 0.0 7.2119172374e+17 0.0 0.0 7.2119172374e+17 0.00079335147763 0.045525821034 0.00095075457969 0.0 0.0 0.0 0.0 1.0412017454e+18 0.0 0.0 1.0412017454e+18 0.0021530440674 0.12355066028 0.0025802138965 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 6.6566309041e+18 0.0 2.3301649308e+20 2.3965693124e+20 2.822811178e+20 0.0 1.6900257022e+21 1.97230682e+21 1.2902137499e+20 1.7933351268e-06 2.3402636528e+18 1.3135771686e+20 3.7277225336e+18 0.0 2.1801514792e+16 3.74952405e+18 1.4469585714e+18 0.0 9.8449968827e+19 9.9896961894e+19 42.406004159 19.382383798 0.56000138618 0.21737100829 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.45706696924 0.013205709835 0.0051259488509 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.028892286522 0.011214874834 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.38816155398 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 5e-05 6347114.6903 199.32618547 4.9438715352 0.0 754471.30136 11480.842364 503579.64272 230554.76915 6666.2864207 2189.7607018 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 57.384176268 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 652.27950112 643418561.04 6294.3286092 1070130669.3 6981.347952 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 diff --git a/tests/integration/dummy.toml b/tests/integration/dummy.toml index a3d995a63..e37cd3d0c 100644 --- a/tests/integration/dummy.toml +++ b/tests/integration/dummy.toml @@ -81,7 +81,7 @@ config_version = "3.0" zenith_angle = 48.19 # degrees s0_factor = 0.375 # dimensionless - evolve = true + star_planet_model = "sp0d" module = "dummy" diff --git a/tests/integration/golden_run.tsv b/tests/integration/golden_run.tsv index cb33a9822..9ee34bf97 100644 --- a/tests/integration/golden_run.tsv +++ b/tests/integration/golden_run.tsv @@ -11,27 +11,41 @@ # storing those once is most of the difference in file size. # # rows = 56 -# columns = 764 +# columns = 780 # config_digest = 8a747319ccc1f94d1ea3043a040c56a013f5be35860a783726e5db23f326d98a Time series 0.0 0.0 0.0 1.0 2.0 22.365174359386017 44.453182559019396 68.42270480764319 94.44852427876276 122.7233441587675 153.45983413030177 186.89293884956194 223.28248618547008 262.91613913992376 306.1127426542948 353.2261251654001 404.64942509788676 460.82002483423685 522.2251895441276 589.4085261538343 662.9773994160411 743.6114684179313 832.0725391123349 929.2159680792423 1029.225260238923 1129.2355524915254 1229.2468448470504 1329.259137315499 1429.272429906872 1529.2867226311712 1629.3020154983974 1729.3183085185524 1829.3356017016376 1929.3538950576547 2029.3731885966051 2129.3934823284912 2229.4147762633147 2329.4370704110775 2429.4603647817817 2529.4846593854295 2629.509954232023 2729.5362493315656 2829.563544694059 2929.591840329506 3029.6211362479094 3129.6514324592717 3229.6827289735966 3329.7150258008865 3429.7483229511445 3529.782620434374 3629.8179182605786 3729.8542164397613 3829.891514981926 3929.9298138970757 4029.9691131952145 4130.009412886347 semimajorax const 74798935350.0 +sma_dot_planet const 0.0 separation const 75172930026.74998 perihelion const 67319041815.0 orbital_period series 11157489.839964336 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635793 11157488.949635793 11157488.949635793 11157488.949635793 11157488.949635794 11157488.949635794 11157488.949635794 11157488.949635796 11157488.949635796 11157488.949635798 11157488.949635798 11157488.949635798 11157488.9496358 11157488.9496358 11157488.949635802 11157488.949635802 11157488.949635804 11157488.949635804 11157488.949635806 11157488.949635806 11157488.949635807 11157488.94963581 11157488.94963581 11157488.94963581 11157488.949635811 11157488.949635811 11157488.949635813 11157488.949635813 11157488.949635815 11157488.949635815 11157488.949635817 11157488.949635817 11157488.949635819 11157488.949635819 11157488.949635819 11157488.94963582 11157488.949635822 11157488.949635822 11157488.949635822 eccentricity const 0.1 +ecc_dot_planet const 0.0 +plan_star_am const 0.0 +axial_period series 11157489.839964336 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 Imk2 const -0.01 -axial_period series 11157489.839964336 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635789 11157488.949635793 11157488.949635793 11157488.949635793 11157488.949635793 11157488.949635794 11157488.949635794 11157488.949635794 11157488.949635796 11157488.949635796 11157488.949635798 11157488.949635798 11157488.949635798 11157488.9496358 11157488.9496358 11157488.949635802 11157488.949635802 11157488.949635804 11157488.949635804 11157488.949635806 11157488.949635806 11157488.949635807 11157488.94963581 11157488.94963581 11157488.94963581 11157488.949635811 11157488.949635811 11157488.949635813 11157488.949635813 11157488.949635815 11157488.949635815 11157488.949635817 11157488.949635817 11157488.949635819 11157488.949635819 11157488.949635819 11157488.94963582 11157488.949635822 11157488.949635822 11157488.949635822 longitude const 0.0 latitude const 0.0 -perigee series 0.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 300000000.0 -semimajorax_sat const 300000000.0 -M_sat const 0.0 +semimajorax_sat const 0.0 +sma_dot_sat const 0.0 +separation_sat const 0.0 +perigee const 0.0 +orbital_period_sat const 0.0 +eccentricity_sat const 0.0 +ecc_dot_sat const 0.0 plan_sat_am const 0.0 +axial_period_sat const 0.0 +R_sat const 0.0 +M_sat const 0.0 +C_sat const 0.0 +evection_angle const 0.0 +evection_dt_cap_yr const 0.0 R_int const 6284776.941068927 M_int const 5.972e+24 M_planet series 6.335483548518791e+24 6.335483548518791e+24 6.335483548518791e+24 6.335483548518791e+24 6.335483548518791e+24 6.335483548518791e+24 6.335483548449087e+24 6.335483548373445e+24 6.335483548291313e+24 6.335483548202084e+24 6.335483548105088e+24 6.335483547999581e+24 6.335483547884744e+24 6.33548354775967e+24 6.335483547623352e+24 6.335483547474673e+24 6.335483547312393e+24 6.335483547135133e+24 6.335483546941352e+24 6.335483546729338e+24 6.335483546497172e+24 6.33548354624271e+24 6.335483545963549e+24 6.335483545656987e+24 6.335483545341382e+24 6.335483545025773e+24 6.335483544710162e+24 6.335483544394547e+24 6.335483544078929e+24 6.335483543763308e+24 6.335483543447684e+24 6.335483543132057e+24 6.335483542816426e+24 6.335483542500792e+24 6.335483542185155e+24 6.335483541869516e+24 6.335483541553872e+24 6.335483541238225e+24 6.335483540922576e+24 6.335483540606923e+24 6.335483540291268e+24 6.335483539975609e+24 6.335483539659946e+24 6.335483539344281e+24 6.335483539028613e+24 6.335483538712941e+24 6.335483538397266e+24 6.335483538081588e+24 6.335483537765907e+24 6.335483537450223e+24 6.335483537134535e+24 6.335483536818846e+24 6.335483536503151e+24 6.335483536187454e+24 6.335483535871755e+24 6.335483535556051e+24 M_vaps const 0.0 R_core const 3456627.3175879098 +C_int const 0.0 R_solvus const 0.0 P_solvus const 0.0 T_solvus const 0.0 @@ -773,7 +787,9 @@ CaO_ocean const 0.0 NaOH_ocean const 0.0 Ca_ocean const 0.0 KOH_ocean const 0.0 -wtg_surf series 368.41352798329905 368.413498585192 368.413498585192 368.413498585192 368.18640363394155 363.5537934539797 358.955834317238 354.391961873965 349.8616110097839 345.36421556307084 340.8992080277783 336.4660192405589 332.0640780509351 327.69281097314644 323.35164181818175 319.0399913043575 314.7572766446487 310.50291110880426 306.27630355808174 302.07685795022314 297.90397281204355 293.75704067674013 289.6354474827174 285.5385719303869 281.7260656486105 278.2609951350835 275.08791991336886 272.1636035308729 269.45367841821053 266.9303784138071 264.5709528663665 262.35653010904787 260.27128555945706 258.30182138473464 256.43669626330103 254.66606366438674 252.98138991931253 251.37523185923865 249.84105953463143 248.37311348169902 246.96628876561735 245.6160399954926 244.31830292280583 243.06942926995225 241.86613220050907 240.70544041472692 239.58465928563407 238.5013377804772 237.45324016560173 236.4383216894608 235.4547075921586 234.50067491102448 233.5746366477614 232.6751279393984 231.80079393688374 230.95037914493247 +wtg_surf series 368.41352798329905 368.413498585192 368.413498585192 368.413498585192 368.18640363394155 363.5537934539797 358.955834317238 354.391961873965 349.8616110097839 345.36421556307084 340.8992080277783 336.4660192405589 332.0640780509351 327.69281097314644 323.35164181818175 319.0399913043575 314.7572766446487 310.50291110880414 306.2763035580816 302.07685795022303 297.90397281204343 293.75704067673996 289.6354474827172 285.53857193038675 281.7260656486103 278.2609951350833 275.08791991336864 272.16360353087265 269.4536784182103 266.93037841380686 264.5709528663662 262.3565301090476 260.27128555945677 258.3018213847343 256.4366962633007 254.66606366438634 252.98138991931214 251.37523185923823 249.84105953463094 248.37311348169857 246.9662887656169 245.6160399954921 244.3183029228053 243.0694292699517 241.86613220050856 240.70544041472638 239.58465928563353 238.5013377804766 237.45324016560113 236.43832168946017 235.45470759215797 234.50067491102385 233.5746366477607 232.6751279393977 231.80079393688303 230.9503791449318 roche_limit const 548818846.7892936 breakup_period const 4958.525605148829 hill_radius const 673444314.709134 +roche_limit_sat const 0.0 +breakup_period_sat const 0.0 diff --git a/tests/integration/test_slow_orbit_evection_ctl.py b/tests/integration/test_slow_orbit_evection_ctl.py new file mode 100644 index 000000000..b4eb0b4dd --- /dev/null +++ b/tests/integration/test_slow_orbit_evection_ctl.py @@ -0,0 +1,331 @@ +"""Slow-tier literature-comparison test: real orbit.satellite.ps1d_evec +driven by a Mignard constant-time-lag (CTL) tidal spectrum, reproducing +the evection-resonance-capture case from Rufu & Canup (2020) Figure 3. + +Match against the reference case +--------------------------------- +A standalone run of the driver logic below (see +``output_files/evection_reference/run_ctl_reference.py``, gitignored) +covers t=0-5.9e4 yr of physical time (~55 min wall-clock). Compared +against the reference figure's t=0-1e5 yr run (capture ~2.4e4 yr; peak +e~0.72 at a'~11.89 R_earth at t~5.2e4 yr; then contraction to a'~10.2 +R_earth, e~0.67 by t=1e5 yr): + +- Pre-resonance phase matches: e stays below 0.01 while a' rises from + 3.5 to ~7.6 R_earth over the first ~2e4 yr, same as the reference. +- Resonance capture timing matches closely: e crosses 0.02 at t=2.5e4 yr, + a'=7.86 R_earth (reference: ~2.4e4 yr, a'~7.7-8). +- Peak eccentricity and its location match closely: e_peak=0.755 at + t=5.12e4 yr, a'=11.88 R_earth (reference: e~0.72-0.724 at a'~11.89 + R_earth, t~5.2e4 yr) -- the peak a' in particular matches to <0.1%. +- The post-peak CONTRACTION phase was NOT reproduced: by t=5.92e4 yr + (~8000 yr past the peak, where the observed run's wall-clock budget + was exhausted), a' was still climbing (13.5 R_earth and rising) + instead of turning over. This is NOT a truncation artifact -- the + internal `filter` flag (real per-substep data from + fine_evection_data.csv) never switched off, but the diagnostic + (a'-a'_res)/a'_res was tracked directly and shows the satellite + escaping evection proper onto the a' > a'_res side of the turnaround, + rather than the a' < a'_res side. Rufu & Canup (2020) Section 3.1 + describe exactly this bifurcation for their own A=10 reference case + (the same tidal-strength ratio used here): escape to the high-e side + of the separatrix enters the quasi-resonance (QR) regime they + describe, with the orbit interior to a'_res and genuine tidally-driven + contraction; escape to the low-e side leaves the orbit EXTERIOR to + a'_res with no further resonant regulation and elevated AM -- and they + report this split occurred in 2 of 10 of their own simulations that + varied only the initial resonance angle phi(0), everything else held + fixed. Which branch is realized is therefore expected to be sensitive + to phi(0) (0.3 rad here, an arbitrary choice inherited from the + reference notebook), not a discrepancy in the physics being tested. + This test makes NO assertion about the contraction phase for that + reason. + +Because of the ~1 hour runtime of the full case, this test targets only +the resonance-entry and peak-eccentricity portion (through t=5.2e4 yr). +Reaching that target took ~1364 s in the calibration run; a 2200 s +internal cutoff leaves a ~1.6x margin over that measurement, plus +further headroom under the 3600 s pytest-timeout ceiling for slower CI +hardware. If the internal cutoff is hit well short of the target, the +driver raises RuntimeError rather than returning a truncated trajectory +silently, so that failure mode is distinguishable from an actual +physics regression. + +Invariants asserted: + +- Pre-resonance: eccentricity stays below 0.02 while a' < 7.5 R_earth + (an edge case away from the interesting dynamics, and a discrimination + check that the driver isn't spuriously exciting e from the start). +- Resonance capture occurs within a physically reasonable window + (e crosses 0.1 between t=2e4 and t=4e4 yr), not immediately and not + never. +- Peak eccentricity reaches at least 0.6 (comfortably below the observed + 0.755, allowing for run-to-run solver variance) at a semimajor axis + within [10, 13] R_earth, matching the reference's peak location. +- The 2-body (planet spin + satellite spin + orbital) angular momentum + diagnostic ``hf_row['plan_sat_am']`` stays finite, positive, and within + +/-10% of its initial value throughout -- a boundedness check, not an + exact-conservation one: ps1d_evec's in-band evection coupling + physically exchanges angular momentum with the star, so this quantity + is expected to drift (observed drift in the reference run was <1% + over the run), not be exactly conserved. + +See also: +- docs/How-to/test_infrastructure.md +- docs/How-to/test_categorization.md +- docs/How-to/test_building.md +""" + +from __future__ import annotations + +import time +from types import SimpleNamespace + +import numpy as np +import pytest + +import proteus.orbit.hansen as hansen_mod +from proteus.config._orbit import OrbitSolver +from proteus.interior_energetics.common import get_C_planet +from proteus.orbit.common import Tides_t +from proteus.orbit.satellite import evolve_orbit_satellite + +pytestmark = [pytest.mark.slow, pytest.mark.timeout(3600)] + +# --------------------------------------------------------------------------- +# Body/constant values copied verbatim from the reference notebook (NOT +# proteus.utils.constants, whose R_earth/const_G/M_sun differ slightly) -- +# exact fidelity to the calibrated reference case matters more here than +# consistency with PROTEUS's own body-parameter defaults. +# --------------------------------------------------------------------------- +_CONST_G = 6.67430e-11 +_M_EARTH, _R_EARTH = 5.972e24, 6.371e6 +_M_MOON, _R_MOON = 7.342e22, 1.737e6 +_M_SUN, _AU = 1.989e30, 1.496e11 + +# Mignard CTL parameters, Rufu & Canup (2020) Figure-3-calibrated (see the +# reference notebook's make_initial_hf_row docstring for the derivation). +_K2_P, _DT_P = 0.3, 5.98 +_K2_S, _DT_S = 1.5, 1.20 +_KMIN, _KMAX = -50, 200 + +# Target: through the peak (observed at t=5.12e4 yr), not the full 1e5 yr +# case, to keep the test inside the slow-tier timeout budget. Reaching +# this target took ~1364 s in the calibration run; 2200 s leaves a ~1.6x +# margin against that measurement plus roughly 1400 s of additional +# headroom under the 3600 s pytest-timeout ceiling (which also covers +# Hansen-table construction, ~10 s) for slower CI hardware. +_T_TARGET_YR = 52000.0 +_DT_OUTER_YR = 200.0 +_MAX_WALL_SECONDS = 2200.0 + + +def _make_initial_hf_row() -> dict: + return { + 'Time': 0.0, + 'R_int': _R_EARTH, + 'M_int': _M_EARTH, + 'M_planet': _M_EARTH, + 'R_sat': _R_MOON, + 'M_sat': _M_MOON, + 'semimajorax_sat': 3.5 * _R_EARTH, + 'eccentricity_sat': 0.01, + 'axial_period': 2 * 3600.0, + 'axial_period_sat': 24 * 3600.0, + 'evection_angle': 0.3, + 'plan_sat_am': 0.0, + 'M_star': _M_SUN, + 'semimajorax': 1.0 * _AU, + 'C_sat': 0.4 * _M_MOON * _R_MOON**2, + 'C_planet': 0.4 * _M_EARTH * _R_EARTH**2, + } + + +def _refresh_ctl_tides(hf_row: dict) -> Tides_t: + """Rebuild tides_o from the current hf_row state under the Mignard + CTL model (Re[k]=k2, Im[k]=-k2*sigma*dt_lag), held fixed for the next + evolve_orbit_satellite call -- mirroring how a real external + tidal-response module (Obliqua/LovePy) would populate tides_o once + per PROTEUS coupling step. sigma(m, k) matches exactly the internal + formula ps1d/ps1d_evec use in their own orbitals() closures at + src/proteus/orbit/satellite.py (sigma_0=-k*n_mm, sigma_2=2*Omega-k*n_mm). + """ + a = hf_row['semimajorax_sat'] + Mp, Ms = hf_row['M_planet'], hf_row['M_sat'] + n_mm = np.sqrt(_CONST_G * (Mp + Ms) / a**3) + Omega_p = 2 * np.pi / hf_row['axial_period'] + Omega_s = 2 * np.pi / hf_row['axial_period_sat'] + + tides_o = Tides_t() + s_vals = np.arange(_KMIN, _KMAX + 1) + for primary, k2, dt_lag, Omega in ( + ('planet', _K2_P, _DT_P, Omega_p), + ('satellite', _K2_S, _DT_S, Omega_s), + ): + perturber = 'satellite' if primary == 'planet' else 'planet' + entry = tides_o.add(primary=primary, perturber=perturber) + nmk, lnk = [], [] + for s in s_vals: + sigma0 = -s * n_mm + sigma2 = 2 * Omega - s * n_mm + nmk.append((2, 0, s)) + lnk.append(complex(k2, -k2 * sigma0 * dt_lag)) + nmk.append((2, 2, s)) + lnk.append(complex(k2, -k2 * sigma2 * dt_lag)) + entry.nmk = np.array(nmk, dtype=int) + entry.LNk = np.array(lnk, dtype=complex) + return tides_o + + +def _run_ctl_reference( + t_target_yr: float, max_wall_seconds: float, data_dir: str +) -> list[dict]: + """Drive the real evolve_orbit_satellite(model='ps1d_evec') under the + CTL spectrum above; returns the (Time, a', e, plan_sat_am) trajectory + as a list of dicts, one per outer step actually completed. + + Raises RuntimeError (rather than silently returning a short + trajectory) if the wall-clock budget is exhausted well short of + ``t_target_yr``, so a slow-CI truncation surfaces as a distinct + failure instead of masquerading as a physics assertion failure in + one of the tests below. + """ + hf_row = _make_initial_hf_row() + config = SimpleNamespace( + orbit=SimpleNamespace( + planet_satellite_model='ps1d_evec', + solver=OrbitSolver(dt0_yr=0.2, dt_max_yr=2.0), + ), + interior_energetics=SimpleNamespace(module='aragog'), + # evection_maximum=0.0 disables _estimate_evection_dt_cap_yr's cap + # (read directly, no getattr fallback, by evolve_orbit_satellite on + # every call) -- this test isn't exercising the dt-cap feature. + params=SimpleNamespace(dt=SimpleNamespace(evection_maximum=0.0)), + ) + n_shells = 50 + rho_uniform = _M_EARTH / (4.0 / 3.0 * np.pi * _R_EARTH**3) + interior_o = SimpleNamespace( + radius=np.linspace(0.0, _R_EARTH, n_shells), + density=np.full(n_shells - 1, rho_uniform), + dt=_DT_OUTER_YR, + ) + dirs: dict = {'output/data': data_dir} + + get_C_planet(hf_row, config, interior_o) + + trajectory = [] + t_start = time.time() + n_outer = int(np.ceil(t_target_yr / _DT_OUTER_YR)) + for i in range(n_outer): + tides_o = _refresh_ctl_tides(hf_row) + evolve_orbit_satellite( + hf_row, + config, + dirs, + tides_o, + interior_o, + ) + hf_row['Time'] = (i + 1) * _DT_OUTER_YR + trajectory.append( + { + 'Time': hf_row['Time'], + 'a_prime': hf_row['semimajorax_sat'] / _R_EARTH, + 'ecc': hf_row['eccentricity_sat'], + 'plan_sat_am': hf_row.get('plan_sat_am', float('nan')), + } + ) + if time.time() - t_start > max_wall_seconds: + break + + t_reached = trajectory[-1]['Time'] if trajectory else 0.0 + if t_reached < t_target_yr - 2 * _DT_OUTER_YR: + raise RuntimeError( + f'CTL reference driver exhausted its {max_wall_seconds:.0f} s wall-clock ' + f'budget at t={t_reached:.0f} yr, short of the t={t_target_yr:.0f} yr ' + 'target -- this run is too slow to draw a physics conclusion from ' + '(likely slower-than-calibration hardware), not a failed physics check.' + ) + return trajectory + + +@pytest.fixture(scope='module') +def _ctl_reference_trajectory(tmp_path_factory): + """Builds the real, wide Hansen-coefficient table once (needed for + eccentricities up to ~0.8; the [-50, 200] k-window is the notebook's + own choice, verified there to suffice up to e~0.755) and runs the + driver once, shared by every assertion in this file so the ~25+ + minute cost is paid a single time per test session. + """ + e_grid = np.concatenate([np.arange(0.0, 0.1, 0.005), np.arange(0.1, 0.86, 0.01)]) + data_dir = str(tmp_path_factory.mktemp('evection_ctl')) + with pytest.MonkeyPatch.context() as mp: + mp.setattr(hansen_mod, '_hansen_table', None) + hansen_mod.init_hansen_table(e_grid=e_grid, kmin=_KMIN, kmax=_KMAX, n_deg=2, force=True) + yield _run_ctl_reference(_T_TARGET_YR, _MAX_WALL_SECONDS, data_dir) + + +@pytest.mark.physics_invariant +def test_pre_resonance_eccentricity_stays_flat(_ctl_reference_trajectory): + """Before the satellite migrates into the evection band, purely + planet-raised/satellite-raised circularizing tides should keep e + small -- edge case away from the resonance dynamics this test is + mostly about, and a discrimination check that the CTL driver isn't + spuriously exciting e from t=0. + """ + pre_resonance = [row for row in _ctl_reference_trajectory if row['a_prime'] < 7.5] + assert len(pre_resonance) > 10 # comfortably exercised, not a 1-row fluke + max_e_pre = max(row['ecc'] for row in pre_resonance) + assert max_e_pre < 0.02 + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_resonance_capture_occurs_within_expected_time_window(_ctl_reference_trajectory): + """Pins the resonance-capture timing against Rufu & Canup (2020) + Figure 3 / the reference notebook's real-Hansen run (capture at + t~2.4e4 yr): e must cross 0.1 somewhere in [2e4, 4e4] yr, not + immediately (which would indicate the CTL spectrum or Hansen table + is wrong) and not never (which would indicate no capture at all). + """ + crossing = next((row for row in _ctl_reference_trajectory if row['ecc'] > 0.1), None) + assert crossing is not None, 'eccentricity never exceeded 0.1 -- no resonance capture' + assert 2.0e4 <= crossing['Time'] <= 4.0e4 + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_peak_eccentricity_matches_reference_location(_ctl_reference_trajectory): + """Pins the peak-eccentricity magnitude and location against the + reference (e_peak~0.72-0.724 at a'~11.89 R_earth, t~5.2e4 yr). + Tolerance is loose (e >= 0.6, a' in [10, 13]) relative to the actual + observed match (e_peak=0.755 at a'=11.88 R_earth) to absorb run-to- + run Radau solver variance without becoming a trivial pass -- 0.6 is + still far above the ~0.02-0.06 eccentricity anywhere outside the + resonance, so this discriminates a genuine capture-and-pump event + from a marginal or failed one. + """ + ecc_values = [row['ecc'] for row in _ctl_reference_trajectory] + i_peak = int(np.argmax(ecc_values)) + peak = _ctl_reference_trajectory[i_peak] + assert peak['ecc'] >= 0.6 + assert 10.0 <= peak['a_prime'] <= 13.0 + + +@pytest.mark.physics_invariant +def test_two_body_angular_momentum_diagnostic_stays_bounded(_ctl_reference_trajectory): + """``plan_sat_am`` (ps1d_evec's own planet-spin + satellite-spin + + orbital angular momentum diagnostic) must stay finite and positive + throughout, and within +/-10% of its initial value. This is a + boundedness check, not exact conservation: the evection coupling to + the star (in-band) physically exchanges angular momentum with the + star's orbit, so ``plan_sat_am`` alone is NOT a conserved quantity + here -- observed drift in the reference run was <1% over the full + stretch, so +/-10% is a generous bound that would still catch a + gross bug (e.g. a sign error blowing the diagnostic up or negative) + without asserting a conservation law this quantity doesn't obey. + """ + am_values = np.array([row['plan_sat_am'] for row in _ctl_reference_trajectory]) + assert np.all(np.isfinite(am_values)) + assert np.all(am_values > 0.0) + am0 = am_values[0] + assert np.all(np.abs(am_values - am0) / am0 < 0.10) diff --git a/tests/interior_energetics/test_common.py b/tests/interior_energetics/test_common.py index 5a63fe3a6..2f2da2839 100644 --- a/tests/interior_energetics/test_common.py +++ b/tests/interior_energetics/test_common.py @@ -12,6 +12,9 @@ Functions tested: - Interior_t._load_ps_table(): Load arbitrary P-S table with path fallback - Interior_t.__init__(): Wires lookup_rho_melt + lookup_cp_solid + lookup_cp_melt +- get_C_planet(): Planet's principal moment of inertia from the interior + density/radius profile, pinned against the uniform-density-sphere analytic + value and the SPIDER surface-first array-reversal contract. """ from __future__ import annotations @@ -1175,3 +1178,192 @@ def test_compute_initial_entropy_ps_inversion_value_error_falls_through_to_paleo assert any('P-S inversion failed' in r.message for r in caplog.records) # Discrimination: the fallback wins over the function default (3200). assert S != pytest.approx(3200.0, rel=1e-4) + + +# ============================================================================ +# get_C_planet: planet's principal moment of inertia from the interior +# density/radius profile. Moved here from proteus.orbit.common, whose +# adaptive-substep controller (run_adaptive_orbit_substeps) is still the +# only caller; see tests/orbit/test_common.py for that controller's own +# (mocked) coverage of the refresh call. +# ============================================================================ + + +def _uniform_sphere_interior(R: float, rho0: float, nlev_b: int): + interior = Interior_t(nlev_b=nlev_b) + interior.radius = np.linspace(0.0, R, nlev_b) + interior.density = np.full(nlev_b - 1, rho0) + M = (4.0 / 3.0) * np.pi * R**3 * rho0 + return interior, M + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_get_c_planet_matches_uniform_density_sphere_analytic_value(): + """For a spatially uniform density, the shell-sum in ``get_C_planet`` + reduces to the exact textbook moment of inertia of a solid sphere, + ``C = (2/5) M R^2`` -- exactly, not just approximately, because the + per-shell integral ``rho * (r1^5 - r0^5) / 5`` is exact for constant + rho regardless of how finely the shells are spaced. + """ + from types import SimpleNamespace + + from proteus.interior_energetics.common import get_C_planet + + R, rho0 = 6.371e6, 5500.0 + interior, M = _uniform_sphere_interior(R, rho0, nlev_b=8) + # interior.radius already starts at r=0 (a whole uniform sphere, no + # separate core), so the [0.0, radius[0]=0.0] segment get_C_planet + # injects for the core is zero-width and core_density's value is + # inert here -- set regardless so the core_density fallback path + # (which needs config.interior_struct, absent from this minimal cfg) + # is never reached. + hf_row: dict = {'M_int': M, 'R_int': R, 'core_density': 0.0} + cfg = SimpleNamespace(interior_energetics=SimpleNamespace(module='aragog')) + + get_C_planet(hf_row, cfg, interior) + + expected = (2.0 / 5.0) * M * R**2 + assert hf_row['C_int'] == pytest.approx(expected, rel=1e-12) + # Discrimination guard: the classic wrong-prefactor bugs for a solid + # sphere are 1/3 (thin shell) and 1/2 (disk); both are far outside a + # 1e-6 relative window around 2/5. + assert abs(hf_row['C_int'] / (M * R**2) - 1.0 / 3.0) > 0.05 + assert abs(hf_row['C_int'] / (M * R**2) - 1.0 / 2.0) > 0.1 + # Sanity/scale guard: C_factor for a uniform sphere is exactly 0.4, + # comfortably inside the physically reasonable [0.2, 0.4] range for + # real (centrally condensed) planets quoted in the source's own log + # message. + assert 0.2 < hf_row['C_int'] / (M * R**2) <= 0.4 + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_get_c_planet_spider_reversal_recovers_cmb_first_ordering(): + """SPIDER emits interior arrays surface-first; ``get_C_planet`` + reverses them when ``config.interior_energetics.module == 'spider'`` + so that index 0 lines up with the CMB, matching the ordering every + other caller uses. This test pins that the reversal branch produces + the SAME value as directly supplying CMB-first arrays, using a + non-uniform (core-mantle-crust) density profile so that reversal + order actually matters (a uniform profile would pass even with a + silently broken reversal). + """ + from types import SimpleNamespace + + from proteus.interior_energetics.common import get_C_planet + + R = 6.371e6 + r_edges_cmb_first = np.array([0.0, 0.5 * R, 0.8 * R, R]) + rho_cmb_first = np.array([9000.0, 5000.0, 3000.0]) # core -> mantle -> crust + + r0, r1 = r_edges_cmb_first[:-1], r_edges_cmb_first[1:] + expected_C = (8 * np.pi / 3.0) * np.sum(rho_cmb_first * (r1**5 - r0**5) / 5.0) + M = np.sum(rho_cmb_first * (4.0 / 3.0 * np.pi * (r1**3 - r0**3))) + + def run(radius, density, module): + interior = Interior_t(nlev_b=4) + interior.radius = radius.copy() + interior.density = density.copy() + # r_edges_cmb_first starts at r=0, so the core segment + # get_C_planet injects is zero-width; core_density's value is + # inert here, just needs to be set to avoid the config.interior_struct + # fallback (absent from this minimal cfg). + hf_row: dict = {'M_int': M, 'R_int': R, 'core_density': 0.0} + cfg = SimpleNamespace(interior_energetics=SimpleNamespace(module=module)) + get_C_planet(hf_row, cfg, interior) + return hf_row['C_int'] + + # Non-SPIDER caller supplying already CMB-first arrays: no reversal + # needed, must match the independently hand-summed expected value. + c_direct = run(r_edges_cmb_first, rho_cmb_first, module='aragog') + assert c_direct == pytest.approx(expected_C, rel=1e-12) + + # SPIDER caller supplying surface-first arrays: the reversal branch + # must recover the identical physical answer. + c_spider = run(r_edges_cmb_first[::-1], rho_cmb_first[::-1], module='spider') + assert c_spider == pytest.approx(expected_C, rel=1e-12) + + # Sign guard: a positive density profile must give a positive moment + # of inertia under the correct (CMB-first) pairing. + assert expected_C > 0.0 + + +@pytest.mark.physics_invariant +def test_get_c_planet_without_reversal_flag_flips_sign_on_surface_first_input(): + """Discriminating negative case for the reversal branch above: if + surface-first arrays are supplied WITHOUT setting + ``module == 'spider'``, the shell pairing ``(r0, r1) = (r_edges[:-1], + r_edges[1:])`` sees a descending radius array, so ``r1 < r0`` for + every shell and ``(r1**5 - r0**5)`` is negative throughout. The + result is not a small numerical drift -- it is the exact negative of + the physically correct value, which is what makes this bug loud + rather than silent, and is the reason a caller mismatching the + module flag would be caught immediately rather than producing a + plausible-looking wrong answer. + """ + from types import SimpleNamespace + + from proteus.interior_energetics.common import get_C_planet + + R = 6.371e6 + r_edges_cmb_first = np.array([0.0, 0.5 * R, 0.8 * R, R]) + rho_cmb_first = np.array([9000.0, 5000.0, 3000.0]) + r0, r1 = r_edges_cmb_first[:-1], r_edges_cmb_first[1:] + expected_C = (8 * np.pi / 3.0) * np.sum(rho_cmb_first * (r1**5 - r0**5) / 5.0) + M = np.sum(rho_cmb_first * (4.0 / 3.0 * np.pi * (r1**3 - r0**3))) + + interior = Interior_t(nlev_b=4) + interior.radius = r_edges_cmb_first[::-1].copy() + interior.density = rho_cmb_first[::-1].copy() + # Zero-width injected core segment (see the two tests above); set so + # the config.interior_struct fallback is never reached. + hf_row: dict = {'M_int': M, 'R_int': R, 'core_density': 0.0} + cfg = SimpleNamespace(interior_energetics=SimpleNamespace(module='dummy')) + + get_C_planet(hf_row, cfg, interior) + + assert hf_row['C_int'] == pytest.approx(-expected_C, rel=1e-12) + assert hf_row['C_int'] < 0.0 + + +@pytest.mark.physics_invariant +def test_get_c_planet_falls_back_to_config_core_density_when_hf_row_lacks_it(caplog): + """When hf_row carries no ``'core_density'`` key at all (e.g. no + interior_struct backend has written one yet), ``get_C_planet`` falls + back to ``config.interior_struct.core_density`` and logs a warning + naming the substitution, rather than raising a ``KeyError`` or silently + treating the core as massless. + + Here the injected core segment has real width (``interior.radius`` + starts above 0), so the fallback density actually enters the integral + and is not the inert edge case the other ``get_C_planet`` tests pin to. + """ + from types import SimpleNamespace + + from proteus.interior_energetics.common import get_C_planet + + R_core, R = 3.0e6, 6.371e6 + rho_core, rho_mantle = 9000.0, 4500.0 + interior = Interior_t(nlev_b=2) + interior.radius = np.array([R_core, R]) + interior.density = np.array([rho_mantle]) + + M_core = (4.0 / 3.0) * np.pi * R_core**3 * rho_core + M_mantle = (4.0 / 3.0) * np.pi * (R**3 - R_core**3) * rho_mantle + hf_row: dict = {'M_int': M_core + M_mantle, 'R_int': R} # no 'core_density' key + cfg = SimpleNamespace( + interior_energetics=SimpleNamespace(module='aragog'), + interior_struct=SimpleNamespace(core_density=rho_core), + ) + + with caplog.at_level('WARNING'): + get_C_planet(hf_row, cfg, interior) + + expected = (8.0 * np.pi / 15.0) * (rho_core * R_core**5 + rho_mantle * (R**5 - R_core**5)) + assert hf_row['C_int'] == pytest.approx(expected, rel=1e-12) + assert any('core_density not found' in rec.message for rec in caplog.records) + # Discrimination: treating the core as massless (rho_core = 0) instead + # of using the config fallback moves C_int well outside tolerance. + massless_core = (8.0 * np.pi / 15.0) * (0.0 + rho_mantle * (R**5 - R_core**5)) + assert abs(hf_row['C_int'] - massless_core) > 1e-6 * expected diff --git a/tests/interior_energetics/test_spider.py b/tests/interior_energetics/test_spider.py index 080b3b8e8..50e85b12e 100644 --- a/tests/interior_energetics/test_spider.py +++ b/tests/interior_energetics/test_spider.py @@ -1061,7 +1061,6 @@ def test_try_spider_init_with_mesh(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, mesh_file=mesh_path, ) @@ -1134,7 +1133,6 @@ def test_try_spider_rho_core_from_zalmoxis(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, mesh_file=mesh_path, ) @@ -1181,7 +1179,6 @@ def test_try_spider_zalmoxis_eos_dir_logs_at_debug(tmp_path, caplog): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, mesh_file=mesh_path, ) @@ -1220,7 +1217,6 @@ def test_try_spider_init_aw(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, ) assert result is True @@ -1258,7 +1254,6 @@ def test_try_spider_missing_eos_dir(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, ) # Discrimination: the EOS lookup must fail BEFORE SPIDER is # spawned. A regression that built a degenerate call sequence @@ -1293,7 +1288,6 @@ def test_try_spider_missing_melting_curves(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, ) # Discrimination: the melting-curves check must fail BEFORE # the subprocess is spawned. A regression that deferred the @@ -1334,7 +1328,6 @@ def test_try_spider_eos_fallback_to_local(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, ) assert result is True @@ -1373,7 +1366,6 @@ def test_try_spider_subprocess_timeout(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, ) assert result is False @@ -1724,35 +1716,96 @@ def test_run_spider_all_attempts_fail(): @pytest.mark.unit -def test_run_spider_heat_tidal_active(): - """RunSPIDER limits dT_max when tidal heating is active.""" - from proteus.interior_energetics.spider import RunSPIDER +def test_try_spider_heat_tidal_active_limits_poststep_change(tmp_path): + """_try_spider tightens -tsurf_poststep_change when tidal heating is active.""" + from proteus.interior_energetics.spider import _try_spider + + dirs, config, hf_row, eos_base, mc_base, _ = _setup_spider_env(tmp_path) interior_o = MagicMock() interior_o.ic = 1 - interior_o.tides = np.array([1e-5] * 10) # > 1e-10 + interior_o.tides = np.array([1e-5] * 10) # > 1e-10 tidal-active threshold - config = MagicMock() config.interior_energetics.heat_tidal = True + # Set well below tmagma_atol (100.0, see _setup_spider_env) so the tidal + # cap is the binding constraint, not the baseline poststep tolerance. + config.interior_energetics.tmagma_tides_step = 5.0 - with patch('proteus.interior_energetics.spider._try_spider', return_value=True) as mock_try: - RunSPIDER( - dirs={'output': '/tmp', 'output/data': '/tmp/data', 'spider': '/tmp'}, - config=config, + with ( + patch('proteus.interior_energetics.spider.EOS_DYNAMIC_DIR', eos_base), + patch('proteus.interior_energetics.spider.MELTING_CURVES_DIR', mc_base), + patch('proteus.interior_energetics.spider.sp.run') as mock_run, + patch( + 'proteus.interior_energetics.common.compute_initial_entropy', + return_value=3000.0, + ), + ): + mock_run.return_value = MagicMock(returncode=0) + result = _try_spider( + dirs, + config, + IC_INTERIOR=1, hf_all=None, - hf_row={'F_atm': 100.0, 'T_eqm': 255.0}, + hf_row=hf_row, + step_sf=1.0, + atol_sf=1.0, + interior_o=interior_o, + ) + + assert result is True + call_args = mock_run.call_args[0][0] + idx = call_args.index('-tsurf_poststep_change') + poststep_passed = float(call_args[idx + 1]) + assert poststep_passed == pytest.approx(5.0) + # Discrimination: the tidal limit (5.0) must win over the untidal + # baseline (tmagma_atol=100.0). A regression that dropped the min() + # and always used dT_poststep would pass any "< 1000" style check but + # fail this tight pin against the baseline value. + assert poststep_passed < config.interior_energetics.tmagma_atol + + +@pytest.mark.unit +def test_try_spider_heat_tidal_inactive_uses_baseline_poststep_change(tmp_path): + """_try_spider leaves -tsurf_poststep_change untouched when tides are quiescent.""" + from proteus.interior_energetics.spider import _try_spider + + dirs, config, hf_row, eos_base, mc_base, _ = _setup_spider_env(tmp_path) + + interior_o = MagicMock() + interior_o.ic = 1 + interior_o.tides = np.zeros(10) # below the 1e-10 tidal-active threshold + + config.interior_energetics.heat_tidal = True + config.interior_energetics.tmagma_tides_step = 5.0 + + with ( + patch('proteus.interior_energetics.spider.EOS_DYNAMIC_DIR', eos_base), + patch('proteus.interior_energetics.spider.MELTING_CURVES_DIR', mc_base), + patch('proteus.interior_energetics.spider.sp.run') as mock_run, + patch( + 'proteus.interior_energetics.common.compute_initial_entropy', + return_value=3000.0, + ), + ): + mock_run.return_value = MagicMock(returncode=0) + result = _try_spider( + dirs, + config, + IC_INTERIOR=1, + hf_all=None, + hf_row=hf_row, + step_sf=1.0, + atol_sf=1.0, interior_o=interior_o, ) - # dT_max should be 4.0 (tidal heating limit) - call_kwargs = mock_try.call_args - dT_max_passed = call_kwargs[1].get('dT_max', call_kwargs[0][7]) - assert dT_max_passed == pytest.approx(4.0) - # Discrimination: the tidal limit must be strictly tighter than the - # default 1000 K cap used by callers without active tides. A - # regression that left dT_max at the default would pass any - # "dT_max <= 1000" check but fail the < 1000 distinction here. - assert dT_max_passed < 1000.0 + assert result is True + call_args = mock_run.call_args[0][0] + idx = call_args.index('-tsurf_poststep_change') + poststep_passed = float(call_args[idx + 1]) + # hf_row['Time'] == 0.0 in _setup_spider_env, so the baseline value is + # tmagma_atol (100.0), unaffected by the unused 5.0 K tidal cap. + assert poststep_passed == pytest.approx(config.interior_energetics.tmagma_atol) # ============================================================================ @@ -2134,7 +2187,6 @@ def test_try_spider_resume_ic2(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, ) assert result is True @@ -2193,7 +2245,6 @@ def test_try_spider_heat_radiogen(tmp_path): hf_row=hf_row, step_sf=1.0, atol_sf=1.0, - dT_max=1000.0, ) assert result is True diff --git a/tests/interior_energetics/test_timestep.py b/tests/interior_energetics/test_timestep.py index 593806e1e..a96657c46 100644 --- a/tests/interior_energetics/test_timestep.py +++ b/tests/interior_energetics/test_timestep.py @@ -31,6 +31,7 @@ def _make_config( mushy_maximum: float = 0.0, mushy_upper: float = 0.99, + evection_maximum: float = 0.0, hysteresis_iters: int = 0, hysteresis_sfinc: float = 1.1, dt_max: float = 1.0e7, @@ -60,6 +61,7 @@ def _make_config( initial=10.0, mushy_maximum=mushy_maximum, mushy_upper=mushy_upper, + evection_maximum=evection_maximum, hysteresis_iters=hysteresis_iters, hysteresis_sfinc=hysteresis_sfinc, max_growth_factor=max_growth_factor, @@ -234,6 +236,102 @@ def test_cap_inactive_when_phi_below_phi_crit(self): assert dt > 0.0 +# --------------------------------------------------------------------------- +# Evection-resonance automatic dt cap +# --------------------------------------------------------------------------- + + +class TestEvectionCap: + """Verify ``next_step`` folds a precomputed ``hf_row['evection_dt_cap_yr']`` + into ``dtswitch`` via ``min()``. + + The cap VALUE itself -- both the secular |de/dt| rate cap and the + evection-scoped growth limiter (with its cooldown counter, now on + ``tides_o`` rather than ``interior_o``) -- is computed by + ``proteus.orbit.satellite._estimate_evection_dt_cap_yr`` and exported + into ``hf_row`` by ``evolve_orbit_satellite`` -- see + ``tests/orbit/test_satellite.py`` for those cases. + ``next_step`` has no evection-specific logic left at all: no + ``in_evection_band``/``near_evection_band`` (removed from the + helpfile entirely), no ``config.params.dt.evection_maximum`` read, + no growth-limiter block -- just this one column folded into + ``min()`` like any other dt cap. + """ + + @pytest.mark.physics_invariant + def test_cap_folds_precomputed_value_into_dtswitch(self): + """A finite ``evection_dt_cap_yr`` below the controller's own + choice wins via ``min()``.""" + from proteus.interior_energetics.timestep import next_step + + config = _make_config() + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + hf_row = { + 'Time': 1e5, + 'F_atm': 1.0e4, + 'Phi_global': 1.0, + 'evection_dt_cap_yr': 50.0, + } + dt = next_step(config, {}, hf_row, hf_all, 1.0, interior_o=_make_interior_o()) + # 1.6 * 5e3 = 8e3 would be chosen; cap to 50. + assert dt == pytest.approx(50.0, rel=1e-6), f'Expected 50 (evection cap), got {dt}' + # Discrimination: with the cap active, dt must be strictly below + # the uncapped 8e3 controller choice. + assert dt < 8.0e3 + + @pytest.mark.physics_invariant + def test_cap_inactive_when_value_is_inf_absent_or_zero(self): + """Three equivalent "no cap" states must all leave ``dtswitch`` + unmodified: an explicit ``np.inf`` (orbit computed "not + applicable"), the key entirely absent (star-planet-only runs, or + a satellite model other than ps1d_evec, never write it), and a + bare ``0.0`` -- the value ``ZeroHelpfileRow()`` initialises every + registered helpfile column to before orbit's first call, which + must NOT be read as a genuine (and nonsensical) zero-length dt + cap. + """ + from proteus.interior_energetics.timestep import next_step + + config = _make_config() + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + base = {'Time': 1e5, 'F_atm': 1.0e4, 'Phi_global': 1.0} + + for label, hf_row in ( + ('inf', {**base, 'evection_dt_cap_yr': np.inf}), + ('absent', dict(base)), + ('zero_helpfile_row_sentinel', {**base, 'evection_dt_cap_yr': 0.0}), + ): + dt = next_step(config, {}, dict(hf_row), hf_all, 1.0, interior_o=_make_interior_o()) + assert dt == pytest.approx(8.0e3, rel=1e-6), f'{label}: expected 8e3, got {dt}' + # Discrimination: the zero-sentinel case in particular must not + # collapse dt to (approximately) zero. + assert dt > 1.0, label + + @pytest.mark.physics_invariant + def test_cap_not_needed_when_already_below_cap_value(self): + """A large ``evection_dt_cap_yr`` that the controller's own + choice is already comfortably below: the cap's inner comparison + must not fire (dt passes through unmodified), the counterpart to + ``test_cap_folds_precomputed_value_into_dtswitch`` where it + does.""" + from proteus.interior_energetics.timestep import next_step + + config = _make_config() + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + hf_row = { + 'Time': 1e5, + 'F_atm': 1.0e4, + 'Phi_global': 1.0, + 'evection_dt_cap_yr': 1.0e6, + } + dt = next_step(config, {}, hf_row, hf_all, 1.0, interior_o=_make_interior_o()) + # 1.6 * 5e3 = 8e3, well below the 1e6 cap: passes through uncapped. + assert dt == pytest.approx(8.0e3, rel=1e-6), f'Expected 8e3 (cap not needed), got {dt}' + # Discrimination: strictly below the 1e6 cap value itself, i.e. + # genuinely uncapped rather than coincidentally clamped to it. + assert dt < 1.0e6 + + # --------------------------------------------------------------------------- # Hysteresis counter # --------------------------------------------------------------------------- @@ -430,6 +528,7 @@ def _make_overshoot_config(*, dt_maximum, stop_time_enabled, stop_time_maximum): initial=1.0, mushy_maximum=0.0, mushy_upper=0.99, + evection_maximum=0.0, hysteresis_iters=0, hysteresis_sfinc=1.1, max_growth_factor=0.0, diff --git a/tests/interior_energetics/test_timestep_bolscale_event.py b/tests/interior_energetics/test_timestep_bolscale_event.py index ad35780f3..e206512d9 100644 --- a/tests/interior_energetics/test_timestep_bolscale_event.py +++ b/tests/interior_energetics/test_timestep_bolscale_event.py @@ -183,6 +183,7 @@ def _next_step_config(dt_maximum, bol_scale, bol_scale_start, bol_scale_duration initial=1.0, mushy_maximum=0.0, mushy_upper=0.99, + evection_maximum=0.0, hysteresis_iters=0, hysteresis_sfinc=1.1, max_growth_factor=0.0, diff --git a/tests/interior_energetics/test_timestep_branches.py b/tests/interior_energetics/test_timestep_branches.py index 13f103f50..f17c99e09 100644 --- a/tests/interior_energetics/test_timestep_branches.py +++ b/tests/interior_energetics/test_timestep_branches.py @@ -63,6 +63,7 @@ def _build_config(method='adaptive', dt_initial=10.0, dt_max=1.0e7, propconst=50 initial=dt_initial, mushy_maximum=0.0, mushy_upper=0.99, + evection_maximum=0.0, hysteresis_iters=0, hysteresis_sfinc=1.1, max_growth_factor=0.0, diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index b80010404..9b3778907 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -2588,7 +2588,8 @@ def test_update_gravity_matches_newton_inverse_square(): @pytest.mark.unit @pytest.mark.physics_invariant def test_calculate_core_mass_matches_rho_v_for_known_rho_and_radius(): - """calculate_core_mass writes hf_row['M_core'] = rho_core * (4/3) * pi * (R_int * core_frac)^3. + """calculate_core_mass writes hf_row['M_core'] = rho_core * (4/3) * pi * (R_int * core_frac)^3, + and persists the resolved core density to hf_row['core_density']. Physics invariant: mass is strictly positive given positive density and radius, and the closed-form pin is matched to 12 digits. @@ -2608,7 +2609,10 @@ def test_calculate_core_mass_matches_rho_v_for_known_rho_and_radius(): core_frac_mode='radius', ) ) - hf_row = {'R_int': R_int} + # hf_row starts with core_density absent from an implicit stale + # ZeroHelpfileRow() default of 0.0, matching a live run before this + # function has ever been called for it. + hf_row = {'R_int': R_int, 'core_density': 0.0} calculate_core_mass(hf_row, config) expected = rho_core * (4.0 / 3.0) * np.pi * (R_int * core_frac) ** 3 assert hf_row['M_core'] == pytest.approx(expected, rel=1e-12) @@ -2618,6 +2622,9 @@ def test_calculate_core_mass_matches_rho_v_for_known_rho_and_radius(): # instead of R**3 would give a number ~6 orders of magnitude smaller. wrong_square = rho_core * (4.0 / 3.0) * np.pi * (R_int * core_frac) ** 2 assert abs(hf_row['M_core'] - wrong_square) > 1e15 + # core_density must be overwritten with the resolved value, not left at + # its stale zero default. + assert hf_row['core_density'] == pytest.approx(rho_core, rel=1e-12) @pytest.mark.unit diff --git a/tests/orbit/test_common.py b/tests/orbit/test_common.py new file mode 100644 index 000000000..18203defb --- /dev/null +++ b/tests/orbit/test_common.py @@ -0,0 +1,443 @@ +"""Unit tests for ``proteus.orbit.common``: the ``Tides_t`` container and +the adaptive substep controller shared by the star-planet and +planet-satellite tidal evolution models. + +Exercises: + +- ``Tides_t``: interaction lookup/registration contract. +- ``run_adaptive_orbit_substeps``: the shared accept/reject controller, + including its ``get_C_planet`` refresh call (mocked here; see + ``tests/interior_energetics/test_common.py`` for ``get_C_planet``'s + own physics tests since it now lives in + ``proteus.interior_energetics.common``). + +Anti-happy-path coverage: + +- ``Tides_t.get`` on an unregistered (primary, perturber) pair must + raise ``KeyError`` rather than returning a default. + +The Kepler solver and Hansen-coefficient machinery previously covered +here now live in ``proteus.orbit.hansen``; see ``test_hansen.py``. +""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import patch + +import numpy as np +import pytest + +from proteus.config._orbit import OrbitSolver +from proteus.orbit.common import Tides_t, kmin_kmax_for_m0_mirror, run_adaptive_orbit_substeps + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +# --------------------------------------------------------------------------- +# Tides_t +# --------------------------------------------------------------------------- + + +def test_tides_t_get_raises_keyerror_for_unregistered_interaction(): + """Error-contract case: querying an interaction that was never + ``add``-ed must raise ``KeyError`` rather than returning ``None`` or + a default, since callers (``sp1d``) index straight into ``.nmk`` + without a None-check. + """ + tides = Tides_t() + with pytest.raises(KeyError): + tides.get(primary='planet', perturber='star') + # No side effect: a failed lookup must not have registered anything. + assert tides.interactions == [] + + +def test_tides_t_add_from_file_populates_interaction_from_a_real_netcdf(tmp_path): + """``add_from_file`` must register a (primary, perturber) interaction + (same object ``add`` would return) and populate its ``nmk``/``sigma``/ + ``LNk`` arrays directly from a real netCDF lookup file, with the + complex Love number correctly reassembled from its real/imaginary + parts. + """ + import netCDF4 as nc + + nmk_rows = np.array([[2, 0, -1], [2, 2, 3]], dtype=np.int64) + sigma = np.array([0.1, 0.2], dtype=np.float64) + lnk = np.array([0.3 - 0.05j, 0.4 - 0.06j], dtype=np.complex128) + + path = tmp_path / 'lookup.nc' + with nc.Dataset(path, 'w', format='NETCDF4') as ds: + ds.createDimension('mode', len(sigma)) + ds.createVariable('n', 'i4', ('mode',))[:] = nmk_rows[:, 0] + ds.createVariable('m', 'i4', ('mode',))[:] = nmk_rows[:, 1] + ds.createVariable('k', 'i4', ('mode',))[:] = nmk_rows[:, 2] + ds.createVariable('sigma', 'f8', ('mode',))[:] = sigma + ds.createVariable('LNk_real', 'f8', ('mode',))[:] = np.real(lnk) + ds.createVariable('LNk_imag', 'f8', ('mode',))[:] = np.imag(lnk) + + tides = Tides_t() + interaction = tides.add_from_file('planet', 'satellite', str(path)) + + # Same registered object `add`/`get` would return, not a detached copy. + assert interaction is tides.get(primary='planet', perturber='satellite') + np.testing.assert_array_equal(interaction.nmk, nmk_rows) + np.testing.assert_allclose(interaction.sigma, sigma) + np.testing.assert_allclose(interaction.LNk, lnk) + + +def test_tides_t_add_is_idempotent_for_the_same_pair(): + """Calling ``add`` twice for the same (primary, perturber) pair must + return the SAME object both times, not create a duplicate + interaction (``sp1d`` calls ``.get`` repeatedly assuming a single + canonical entry per pair). + """ + tides = Tides_t() + first = tides.add(primary='planet', perturber='star') + second = tides.add(primary='planet', perturber='star') + assert first is second + assert len(tides.interactions) == 1 + # A different perturber must create a genuinely distinct entry. + third = tides.add(primary='planet', perturber='moon') + assert third is not first + assert len(tides.interactions) == 2 + + +# --------------------------------------------------------------------------- +# kmin_kmax_for_m0_mirror +# +# _dense_love (in orbit.py's sp1d and satellite.py's ps1d/ps1d_evec) +# reconstructs the m=0 branch's negative-k content via conjugation, using +# index `-s_pos - kmin`. Real Obliqua output only ever supplies k>=0 for +# every m branch, so kmin computed straight from that raw data is always +# 0 (or positive), and the reconstruction index is always negative -- +# discarded by _dense_love's own validity check, silently dropping the +# m=0 branch's negative-k content. kmin_kmax_for_m0_mirror exists +# specifically to widen kmin so that mirror has somewhere to land. +# --------------------------------------------------------------------------- + + +def test_kmin_kmax_for_m0_mirror_widens_kmin_for_real_obliqua_shaped_data(): + """Obliqua's real output convention: k>=0 for both the m=0 and m=2 + branches (verified directly against a real run's netCDF output). + Naively taking kmin from this raw data gives kmin=0, which then + defeats _dense_love's own m=0 negative-k mirror reconstruction + (`-s_pos - kmin` is always negative when kmin=0). kmin must be + widened to `-max(k where m==0)` so that mirror has valid array + slots to write into. + """ + # m=0: k=0..8: m=2: k=1..13 (mirrors the real 17890_obliqua.nc shape + # inspected directly against a real tutorial_earth_star run). + nmk = np.array( + [(2, 0, k) for k in range(0, 9)] + [(2, 2, k) for k in range(1, 14)], dtype=int + ) + kmin, kmax = kmin_kmax_for_m0_mirror(nmk) + + assert kmin == -8 + assert kmax == 13 + # Discrimination: the naive (buggy) computation straight from the raw + # data gives kmin=0, not -8 -- confirms this isn't a coincidental + # match to the raw range's own minimum. + naive_kmin = int(np.min(nmk[:, 2])) + assert naive_kmin == 0 + assert kmin != naive_kmin + + +def test_kmin_kmax_for_m0_mirror_is_a_no_op_when_raw_data_already_spans_negative_k(): + """Synthetic/test fixtures that already supply explicit negative-k + rows for m=0 (unlike real Obliqua output) must not be needlessly + widened further -- kmin should stay exactly at the raw minimum in + that case, not get pushed more negative than necessary. + """ + nmk = np.array( + [(2, 0, k) for k in range(-6, 7)] + [(2, 2, k) for k in range(-6, 7)], dtype=int + ) + kmin, kmax = kmin_kmax_for_m0_mirror(nmk) + + assert kmin == -6 + assert kmax == 6 + + +def test_kmin_kmax_for_m0_mirror_handles_no_m0_modes_present(): + """A tidal-mode table with only the m=2 branch (no m=0 modes at all) + must not raise (the m0_k selection is empty) and must fall back to + the raw min/max unchanged. + """ + nmk = np.array([(2, 2, k) for k in range(1, 14)], dtype=int) + kmin, kmax = kmin_kmax_for_m0_mirror(nmk) + + assert kmin == 1 + assert kmax == 13 + + +# --------------------------------------------------------------------------- +# run_adaptive_orbit_substeps +# +# Every production caller (sp1d, ps0d, ps1d, ps1d_evec) currently passes +# needs_c_planet=True, so several of this function's own branches +# (needs_c_planet=False, a degenerate/raising C_planet refresh, a +# rejected substep's diagnostic log, the every-5000-steps progress log) +# are not reachable through any current indirect caller. Exercised here +# directly against the function's own documented contract instead. +# --------------------------------------------------------------------------- + + +def _make_solver_config(**overrides) -> Any: + return cast( + Any, + SimpleNamespace( + orbit=SimpleNamespace(solver=OrbitSolver(**overrides)), + interior_energetics=SimpleNamespace(module='aragog'), + ), + ) + + +def _make_interior_o(dt: float) -> Any: + return cast( + Any, + SimpleNamespace( + radius=np.array([0.0, 3.0e6, 6.371e6]), density=np.array([5500.0, 5000.0]), dt=dt + ), + ) + + +def test_run_adaptive_orbit_substeps_skips_c_planet_refresh_when_not_needed(): + """``needs_c_planet=False`` must never call ``get_C_planet`` nor + write ``hf_row['C_int']`` -- the branch every current production + caller (sp1d/ps1d/ps1d_evec) skips by always passing True.""" + config = _make_solver_config(dt0_yr=1.0, dt_max_yr=10.0, max_rel_da=1.0) + interior_o = _make_interior_o(dt=5.0) + hf_row: dict = {'Time': 0.0, 'x': 1.0} + + def step_fn(hf_row, dt_yr, t_elapsed_yr): + hf_row['x'] += dt_yr + return None + + with patch('proteus.orbit.common.get_C_planet') as mock_get_c: + run_adaptive_orbit_substeps( + hf_row, + config, + {}, # dirs (unused by this test) + Tides_t(), + interior_o, + 'testmodel', + step_fn, + lambda hf_row: True, + lambda hf_row, snapshot: {}, + {}, + needs_c_planet=False, + ) + mock_get_c.assert_not_called() + assert 'C_int' not in hf_row + + +def test_run_adaptive_orbit_substeps_logs_error_on_degenerate_c_planet_result(caplog): + """A ``get_C_planet`` result that is zero or non-finite must be + logged as an error and the structural update skipped, rather than + propagating a degenerate value into ``hf_row['C_int']`` silently + (which would divide-by-zero the very next AM-conserving rescale).""" + config = _make_solver_config(dt0_yr=1.0, dt_max_yr=10.0) + interior_o = _make_interior_o(dt=5.0) + hf_row: dict = {'Time': 0.0, 'C_int': 1.0e37, 'axial_period': 86400.0} + + def fake_get_c_planet(hf_row, config, interior_o): + hf_row['C_int'] = 0.0 # degenerate + + with ( + patch('proteus.orbit.common.get_C_planet', side_effect=fake_get_c_planet), + caplog.at_level(logging.ERROR, logger='fwl.proteus.orbit.common'), + ): + run_adaptive_orbit_substeps( + hf_row, + config, + {}, # dirs (unused by this test) + Tides_t(), + interior_o, + 'testmodel', + lambda hf_row, dt_yr, t_elapsed_yr: None, + lambda hf_row: True, + lambda hf_row, snapshot: {}, + {}, + needs_c_planet=True, + ) + assert any('structural update will be skipped' in rec.message for rec in caplog.records) + # Discrimination: despite the degenerate target, axial_period must + # NOT have been rescaled (the guard inside _rescale_c_planet_to + # skips the rescale when target_c_p == 0) -- a broken guard would + # divide by zero here instead of leaving spin untouched. + assert hf_row['axial_period'] == pytest.approx(86400.0, rel=1e-12) + + +def test_run_adaptive_orbit_substeps_reraises_when_c_planet_refresh_raises(caplog): + """An exception from ``get_C_planet`` itself must be logged and + re-raised (wrapped in a RuntimeError naming the model/time context, + chained via ``from err`` so the original cause is preserved), not + swallowed -- a genuinely broken interior state should stop the run, + not silently continue with a stale C_planet. Also confirms the + status file is updated (code 26) before the re-raise, so a crashed + run is recorded as such rather than left unexplained.""" + config = _make_solver_config(dt0_yr=1.0, dt_max_yr=10.0) + interior_o = _make_interior_o(dt=5.0) + hf_row: dict = {'Time': 0.0, 'C_int': 1.0e37, 'axial_period': 86400.0} + dirs = {'output': '/tmp/unused'} + original_err = RuntimeError('boom') + + with ( + patch('proteus.orbit.common.get_C_planet', side_effect=original_err), + patch('proteus.orbit.common.UpdateStatusfile') as mock_update_status, + caplog.at_level(logging.ERROR, logger='fwl.proteus.orbit.common'), + pytest.raises(RuntimeError, match='C_planet update failed') as excinfo, + ): + run_adaptive_orbit_substeps( + hf_row, + config, + dirs, + Tides_t(), + interior_o, + 'testmodel', + lambda hf_row, dt_yr, t_elapsed_yr: None, + lambda hf_row: True, + lambda hf_row, snapshot: {}, + {}, + needs_c_planet=True, + ) + assert any('C_planet update RAISED' in rec.message for rec in caplog.records) + mock_update_status.assert_called_once_with(dirs, 26) + # Discrimination: the original exception must still be reachable via + # exception chaining, not discarded when wrapped into the clearer + # top-level RuntimeError. + assert excinfo.value.__cause__ is original_err + + +def test_run_adaptive_orbit_substeps_logs_nonfinite_fields_on_rejected_substep(caplog): + """A rejected substep (``state_is_valid_fn`` returns False) must log + which fields were actually non-finite, not just that a rejection + happened -- the diagnostic this controller relies on to distinguish + a genuine solver blow-up from a spuriously tight tolerance.""" + config = _make_solver_config(dt0_yr=1.0, dt_max_yr=10.0, shrink=0.5, max_substeps=5) + interior_o = _make_interior_o(dt=5.0) + hf_row: dict = {'Time': 0.0, 'x': 1.0} + + def step_fn(hf_row, dt_yr, t_elapsed_yr): + hf_row['x'] = float('nan') + return None + + with caplog.at_level(logging.WARNING, logger='fwl.proteus.orbit.common'): + run_adaptive_orbit_substeps( + hf_row, + config, + {}, # dirs (unused by this test) + Tides_t(), + interior_o, + 'testmodel', + step_fn, + lambda hf_row: np.isfinite(hf_row['x']), + lambda hf_row, snapshot: {}, + {}, + needs_c_planet=False, + ) + reject_records = [ + rec.message for rec in caplog.records if 'state_is_valid_fn rejected' in rec.message + ] + assert len(reject_records) > 0 + # Discrimination: the logged diagnostic must actually name the bad + # field, not just report a generic rejection. + assert any("'x'" in msg for msg in reject_records) + + +def test_run_adaptive_orbit_substeps_logs_progress_every_5000_accepted_steps(caplog): + """Every 5000th ACCEPTED substep must emit a progress log line -- + otherwise a long-running call (millions of substeps for a gentle + ODE forced through a small dt_max_yr) gives no feedback at all + until it either completes or exhausts max_substeps. + + dt_yr is held exactly constant across every substep by having + ``rel_change_fn`` return a value pinned at exactly the no-growth + threshold (0.3 * limit): the growth condition is strict (`<`), so + equality never grows dt_yr. With dt0_yr=dt_max_yr=1.0 yr and + t_total_yr=5000 yr, this takes EXACTLY 5000 accepted substeps to + complete, hitting the modulo check exactly once, cheaply (no real + physics in step_fn). + """ + config = _make_solver_config( + dt0_yr=1.0, dt_max_yr=1.0, growth=1.5, shrink=0.5, max_rel_da=1.0, max_substeps=6000 + ) + interior_o = _make_interior_o(dt=5000.0) + hf_row: dict = {'Time': 0.0, 'x': 0.0} + + def step_fn(hf_row, dt_yr, t_elapsed_yr): + hf_row['x'] += dt_yr + return None + + def rel_change_fn(hf_row, snapshot): + return {'dx': 0.3} # exactly the no-growth threshold at limit=1.0 + + with caplog.at_level(logging.DEBUG, logger='fwl.proteus.orbit.common'): + run_adaptive_orbit_substeps( + hf_row, + config, + {}, # dirs (unused by this test) + Tides_t(), + interior_o, + 'testmodel', + step_fn, + lambda hf_row: True, + rel_change_fn, + {'dx': 1.0}, + needs_c_planet=False, + ) + assert hf_row['x'] == pytest.approx(5000.0, rel=1e-9) + progress_records = [ + rec.message for rec in caplog.records if 'progress t_elapsed=' in rec.message + ] + assert len(progress_records) > 0 + assert any('n_steps=5000' in msg for msg in progress_records) + + +def test_run_adaptive_orbit_substeps_cumulative_cap_gated_to_ps0d(): + """The cumulative drift cap must fire for ``model='ps0d'`` but be a + complete no-op for every other model (sp1d/ps1d/ps1d_evec), which + integrate eccentricity/spin directly and rely on the per-substep + check alone (see "Adaptive substep controller" in + docs/Explanations/orbit.md). Regression guard for a change that + accidentally re-widens the cap to non-ps0d models, which would + silently truncate their per-call progress and desync the main loop's + ``Time`` advance from what the orbit module actually achieved. + """ + config = _make_solver_config(dt0_yr=1.0, dt_max_yr=1.0, max_rel_da=5.0) + interior_o = _make_interior_o(dt=20.0) + + def step_fn(hf_row, dt_yr, t_elapsed_yr): + hf_row['x'] += dt_yr + return None + + def rel_change_fn(a, b): + return {'da': abs(a['x'] - b['x'])} + + def run(model): + hf_row: dict = {'Time': 0.0, 'x': 0.0} + run_adaptive_orbit_substeps( + hf_row, + config, + {}, # dirs (unused by this test) + Tides_t(), + interior_o, + model, + step_fn, + lambda hf_row: True, + rel_change_fn, + {'da': 5.0}, + needs_c_planet=False, + ) + return hf_row['x'] + + # ps0d: the cumulative cap trips once accumulated |dx| exceeds 5.0, + # i.e. after 6 accepted 1.0-yr substeps (5.0 itself is not > 5.0). + assert run('ps0d') == pytest.approx(6.0, rel=1e-9) + + # Every other model: no cumulative cap, so the full requested 20 yr + # is advanced despite the identical (and identically "tripping" once + # accumulated) rel_change_fn. + for other_model in ('sp1d', 'ps1d', 'ps1d_evec'): + assert run(other_model) == pytest.approx(20.0, rel=1e-9) diff --git a/tests/orbit/test_dummy.py b/tests/orbit/test_dummy.py new file mode 100644 index 000000000..d9cd6ef38 --- /dev/null +++ b/tests/orbit/test_dummy.py @@ -0,0 +1,183 @@ +"""Unit tests for the dummy orbit module.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import numpy as np +import pytest + +from proteus.interior_energetics.common import Interior_t +from proteus.orbit.dummy import run_dummy_tides + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +def _make_config(phi_tide: str, h_tide: float, imk2: float) -> Any: + dummy = SimpleNamespace(Phi_tide=phi_tide, H_tide=h_tide, Imk2=imk2) + orbit = SimpleNamespace(dummy=dummy) + return cast(Any, SimpleNamespace(orbit=orbit)) + + +@pytest.mark.unit +def test_less_than_threshold_heating_scales_with_melt_fraction(): + """Heating applies where phi < threshold and scales linearly with melt fraction.""" + config = _make_config('<0.5', h_tide=10.0, imk2=1e-2) + interior = Interior_t(nlev_b=4) + interior.phi = np.array([0.1, 0.4, 0.6]) + + result = run_dummy_tides(config, interior) + + assert result == pytest.approx(1e-2) + assert interior.tides == pytest.approx( + [10.0 * (1 - 0.1 / 0.5), 10.0 * (1 - 0.4 / 0.5), 0.0] + ) + + +@pytest.mark.unit +def test_greater_than_threshold_heating_scales_linearly(): + """Heating applies where phi > threshold and increases toward fully liquid.""" + config = _make_config('>0.25', h_tide=5.0, imk2=2e-3) + interior = Interior_t(nlev_b=5) + interior.phi = np.array([0.1, 0.25, 0.75, 1.0]) + + result = run_dummy_tides(config, interior) + + assert result == pytest.approx(2e-3) + expected = [0.0, 0.0, 5.0 * (0.75 - 0.25) / (1 - 0.25), 5.0 * (1.0 - 0.25) / (1 - 0.25)] + assert interior.tides == pytest.approx(expected) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_equal_to_threshold_produces_zero_heating(): + """Boundary equality does not trigger heating (strict inequality). + + Boundedness invariant at the comparator boundary: phi == threshold + falls outside the strict-less-than condition and so the tidal + heating remains exactly zero. Discriminates the strict-vs-non-strict + comparator regression. + """ + config = _make_config('<0.3', h_tide=7.5, imk2=0.0) + interior = Interior_t(nlev_b=3) + interior.phi = np.array([0.3, 0.3]) + + run_dummy_tides(config, interior) + + assert interior.tides == pytest.approx([0.0, 0.0]) + # Discrimination guard: shifting phi just below the threshold must + # produce strictly positive heating (h_tide=7.5 at phi=0.299 with + # threshold=0.3 gives 7.5 * (1 - 0.299/0.3) ~ 0.025). A regression + # that used <= instead of < would have produced 0.0 at phi=0.3 + # above AND would also produce > 0 here, so the difference between + # the boundary case and the just-below case is what discriminates + # the strict comparator. + interior_below = Interior_t(nlev_b=3) + interior_below.phi = np.array([0.299, 0.299]) + run_dummy_tides(config, interior_below) + assert interior_below.tides[0] > 0.0 + assert interior_below.tides[0] < 7.5 # bounded by h_tide + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_no_cells_meet_condition_keeps_zero_heating(): + """If no layer meets inequality, tides remain zero everywhere. + + Limit-input invariant: with phi values all above the < 0.1 threshold, + no cells qualify and the tidal heating array stays at the zero IC. + """ + config = _make_config('<0.1', h_tide=9.0, imk2=0.5) + interior = Interior_t(nlev_b=3) + interior.phi = np.array([0.5, 0.9]) + + run_dummy_tides(config, interior) + + assert interior.tides == pytest.approx([0.0, 0.0]) + # Sign / positivity guard on the unrelated return value: the + # function still returns Imk2 from config even when no cells heat. + # A regression that returned 0.0 (e.g. short-circuited on the + # empty mask) would still satisfy the zero-tides equality above + # but break the Imk2 contract documented in test_returns_imk2_from_config. + result = run_dummy_tides(config, interior) + assert result == pytest.approx(0.5) + # Discrimination guard: with one phi shifted into the < 0.1 region, + # the corresponding tides entry must become strictly positive while + # the others stay zero. This separates "no heating because no cells + # qualify" from "no heating because the formula is broken". + interior_mixed = Interior_t(nlev_b=3) + interior_mixed.phi = np.array([0.05, 0.9]) + run_dummy_tides(config, interior_mixed) + assert interior_mixed.tides[0] > 0.0 + assert interior_mixed.tides[1] == pytest.approx(0.0) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_phi_array_unchanged_by_heating_calculation(): + """run_dummy_tides updates tides but leaves melt fractions untouched. + + Input-immutability invariant: melt fraction is the input state; the + dummy orbit applies tidal heating WITHOUT mutating phi. A regression + that modified phi in place would corrupt the interior solver's + state across iterations. + """ + config = _make_config('>0.2', h_tide=4.0, imk2=1.0) + interior = Interior_t(nlev_b=4) + interior.phi = np.array([0.1, 0.2, 0.3]) + phi_before = interior.phi.copy() + + run_dummy_tides(config, interior) + + assert interior.phi == pytest.approx(phi_before) + # Discrimination guard: tides must still have been UPDATED for the + # cell that qualifies (phi=0.3 > 0.2). A regression that did nothing + # (returned without touching either array) would satisfy the phi + # equality but leave tides at the IC zero. + assert interior.tides[2] > 0.0 + # Sign / scale guard: the active cell at phi=0.3, threshold=0.2, + # h_tide=4.0 must land at 4.0 * (0.3 - 0.2) / (1 - 0.2) = 0.5. + # Pins both the formula and the magnitude. + assert interior.tides[2] == pytest.approx(0.5) + + +@pytest.mark.unit +def test_returns_imk2_from_config(): + """Function returns Imk2 value provided in config without modification. + + Pass-through contract: the dummy orbit relays Imk2 from config to + the caller verbatim, independent of phi or heating state. + """ + config = _make_config('<0.9', h_tide=1.0, imk2=3.21) + interior = Interior_t(nlev_b=2) + interior.phi = np.array([0.5]) + + result = run_dummy_tides(config, interior) + + assert result == pytest.approx(3.21) + # Discrimination guard: pick a different Imk2 value and confirm the + # return tracks it. A regression that hardcoded 3.21 or returned a + # constant (h_tide, 0, NaN) would pass the first equality but fail + # this second call. + config2 = _make_config('<0.9', h_tide=1.0, imk2=7.65) + interior2 = Interior_t(nlev_b=2) + interior2.phi = np.array([0.5]) + result2 = run_dummy_tides(config2, interior2) + assert result2 == pytest.approx(7.65) + # The two return values must differ (rules out a regression that + # ignored the config and always returned the same constant). + assert result != pytest.approx(result2) + + +@pytest.mark.unit +def test_single_level_interpolates_correctly(): + """Single-layer interior still applies heating logic and preserves array shapes.""" + config = _make_config('>0.5', h_tide=2.0, imk2=0.7) + interior = Interior_t(nlev_b=2) + interior.phi = np.array([0.8]) + + run_dummy_tides(config, interior) + + assert interior.tides.shape == (1,) + assert interior.tides == pytest.approx([2.0 * (0.8 - 0.5) / (1 - 0.5)]) diff --git a/tests/orbit/test_hansen.py b/tests/orbit/test_hansen.py new file mode 100644 index 000000000..a375ccddc --- /dev/null +++ b/tests/orbit/test_hansen.py @@ -0,0 +1,577 @@ +"""Unit tests for ``proteus.orbit.hansen``: the Kepler solver and the +cached, FFT-backed Hansen coefficients shared by the star-planet and +planet-satellite tidal evolution models (``sp1d``/``ps1d``/``ps1d_evec``). + +Exercises: + +- ``kepler_newton``: reduction to the mean anomaly at zero eccentricity, + a forward-map round trip (Kepler's equation solved for E, then + independently re-evaluated to check M is recovered), and the equation + residual at several (M, e) pairs. +- ``hansen_fft`` / ``get_all_m_hansen``: the exact Kronecker-delta limit + at zero eccentricity (an orbit at e=0 has zero radial variation, so + ``(r/a)^n`` is unity and the Fourier decomposition of + ``exp(i m v) = exp(i m M)`` collapses to a single mode at k=m for any + n), and the classical closed-form time average + ``<(a/r)^3> = (1-e^2)^(-3/2)``, cross-checked against an independent + numerical quadrature (not the analytic formula alone). +- ``nextpow2_int``: the FFT-size rounding helper used internally by + ``hansen_fft``. +- ``padded_k_range_for_evection``: the look-ahead eccentricity padding + used by ``orbit/obliqua.py`` while the evection resonance band is + active, including its reduction to the unpadded ``kmin_kmax_for_e`` + result at zero rate and its clip to the table's own domain. + +Anti-happy-path coverage: + +- Kepler solver exercised at e=0 (degenerate/edge case) and at e up to + 0.8 (near-parabolic regime for this application). +- ``nextpow2_int`` pinned at exact powers of two and their neighbours. + +See also: +- docs/How-to/test_infrastructure.md +- docs/How-to/test_building.md +- docs/How-to/test_categorization.md +""" + +from __future__ import annotations + +import logging + +import numpy as np +import pytest +from scipy import integrate + +import proteus.orbit.hansen as hansen_mod +from proteus.orbit.hansen import ( + _HansenTable, + _KRangeTable, + _select_k_range, + get_all_m_hansen, + hansen_fft, + init_hansen_table, + init_k_range_table, + kepler_newton, + kmin_kmax_for_e, + nextpow2_int, + padded_k_range_for_evection, +) + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +# --------------------------------------------------------------------------- +# kepler_newton +# --------------------------------------------------------------------------- + + +@pytest.mark.physics_invariant +def test_kepler_newton_reduces_to_mean_anomaly_at_zero_eccentricity(): + """At e=0 the orbit is circular: Kepler's equation E - e sin(E) = M + degenerates to E = M identically, for any M. + + Limit-input invariant, checked at several M values so a regression + that added a stray e-independent offset would not go unnoticed at + a single accidentally-zero M. + """ + M = np.array([0.0, 0.3, 1.5, 3.0, 5.5]) + E = kepler_newton(M, 0.0) + np.testing.assert_allclose(E, np.mod(M, 2 * np.pi), atol=1e-12) + # Boundedness guard: eccentric anomaly must stay within [0, 2*pi) per + # the function's own np.mod wrap-around convention. + assert np.all(E >= 0.0) and np.all(E < 2 * np.pi) + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +@pytest.mark.parametrize('E0,e', [(1.234, 0.3), (0.5, 0.05), (5.9, 0.8), (2.9, 0.65)]) +def test_kepler_newton_recovers_eccentric_anomaly_via_forward_map(E0, e): + """Round-trip check against the forward Kepler map, not the solver + under test. + + Given a chosen eccentric anomaly ``E0``, the mean anomaly it + generates is ``M0 = E0 - e sin(E0)`` -- this is Kepler's equation + itself, evaluated in the forward (non-iterative) direction and + computed independently of ``kepler_newton``. Feeding ``M0`` back + into ``kepler_newton`` must recover ``E0`` to within the solver's + documented Newton-iteration tolerance (1e-13 on the step size). + """ + M0 = E0 - e * np.sin(E0) + E_back = float(kepler_newton(np.array([M0]), e)[0]) + assert E_back == pytest.approx(E0, abs=1e-10) + # Scale/sanity guard: for e < 1 the eccentric and mean anomalies at + # the same point on the orbit cannot differ by more than ~e radians + # (E - M = e sin E). A regression that returned M unchanged (e.g. a + # disabled Newton loop) would still coincidentally pass at small e, + # so also assert the *equation* is satisfied, independent of E0. + assert abs(E_back - e * np.sin(E_back) - M0) < 1e-10 + + +@pytest.mark.physics_invariant +@pytest.mark.parametrize( + 'M,e', + [(0.1, 0.01), (2.0, 0.4), (4.5, 0.7), (0.9, 0.8), (6.0, 0.2)], +) +def test_kepler_newton_satisfies_equation_residual(M, e): + """The returned E must satisfy E - e sin(E) = M (mod 2*pi) to near + machine precision, independent of whether E itself is "correct" in + an absolute sense -- this is the defining equation the Newton loop + is supposed to converge on. + """ + E = float(kepler_newton(np.array([M]), e)[0]) + residual = E - e * np.sin(E) - np.mod(M, 2 * np.pi) + # Newton's method here is documented to stop once |dE| < 1e-13; + # the equation residual itself is well within 1e-10 for all tested + # (M, e). A regression that broke convergence (e.g. wrong fp + # derivative sign) would blow this up to O(1). + assert abs(np.mod(residual + np.pi, 2 * np.pi) - np.pi) < 1e-9 + + +def test_kepler_newton_returns_finite_result_when_iteration_cap_is_hit(): + """At extreme eccentricity near periapsis (e=0.999, small M), the + fixed 10-iteration cap is exhausted without reaching the 1e-13 + convergence threshold -- this is well beyond the e<=0.95 range the + module's own docstring says the application targets, but the + solver must still return a finite value (its best estimate after + 10 iterations) rather than exiting the loop with a stale/undefined + E, silently truncating, or raising. + """ + M = np.array([0.001]) + e = 0.999 + E = kepler_newton(M, e) + assert np.all(np.isfinite(E)) + # The residual is not machine-precision here (that's the point -- + # the cap was hit before full convergence), but it must still be + # much smaller than a non-iterating guess (E=M) would leave: a + # regression that broke the Newton step entirely (e.g. returned M + # unchanged) would leave a residual of order e ~ 1, not the ~1e-2 + # this partially-converged case actually achieves. + residual = abs(E[0] - e * np.sin(E[0]) - M[0]) + assert residual < 1e-1 + + +# --------------------------------------------------------------------------- +# hansen_fft / get_all_m_hansen +# --------------------------------------------------------------------------- + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +@pytest.mark.parametrize('n,m', [(-3, 0), (-3, 2), (-3, -2), (-4, 1)]) +def test_hansen_fft_reduces_to_kronecker_delta_at_zero_eccentricity(n, m): + """At e=0, r/a = 1 and the true anomaly equals the mean anomaly + (v = M), so the Hansen integrand ``(r/a)^n exp(i m v)`` collapses to + the pure tone ``exp(i m M))`` for ANY degree n. Its Fourier + decomposition in M is therefore the Kronecker delta X_k^{n,m}(0) = + 1 if k=m else 0 -- independent of n, which is what makes this a + genuine test of the FFT/indexing machinery rather than of the + integrand itself. + """ + k, X = hansen_fft(n=n, m=m, e=0.0, kmin=-4, kmax=4) + expected = np.where(k == m, 1.0, 0.0) + np.testing.assert_allclose(X, expected, atol=1e-9) + # Discrimination guard: an off-by-one in the k-index bookkeeping + # (e.g. an fftshift/index convention bug) would place the spike at + # k = m +/- 1 instead, which the elementwise comparison above + # already catches; this restates the peak location explicitly so a + # reader sees the failure mode without re-deriving it. + assert X[list(k).index(m)] == pytest.approx(1.0, abs=1e-9) + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +@pytest.mark.parametrize('e', [0.1, 0.3, 0.6, 0.8]) +def test_hansen_fft_dc_term_matches_kepler_orbit_time_average(e): + """The k=0 (DC) component of the n=-3, m=0 Hansen coefficient is the + orbit-averaged ``<(a/r)^3>``, which has the classical closed form + ``(1 - e^2)^(-3/2)`` (the identity underlying the eccentricity-tide + torque in tidal theory). Rather than trust that formula on its own, + this test re-derives it independently via the standard Kepler + change of variables ``dM = (1 - e cos E) dE = (r/a) dE``: + + <(a/r)^3>_M = (1/2*pi) integral_0^{2*pi} (a/r)^3 dM + = (1/2*pi) integral_0^{2*pi} (1 - e cos E)^(-2) dE + + evaluated here by numerical quadrature (``scipy.integrate.quad``), + which never calls ``hansen_fft`` or the closed-form expression. All + three (analytic formula, independent quadrature, and the function + under test) are compared. + """ + analytic = (1.0 - e**2) ** -1.5 + quad_val, quad_err = integrate.quad(lambda E: (1.0 - e * np.cos(E)) ** -2, 0.0, 2 * np.pi) + quad_avg = quad_val / (2 * np.pi) + assert quad_avg == pytest.approx(analytic, rel=1e-8) + # quad's absolute error estimate scales with the integral's raw + # magnitude (~2*pi * analytic, up to ~30 at e=0.8); compare it in + # relative terms instead of a fixed absolute bound. + assert quad_err / abs(quad_val) < 1e-6 + + k, X = hansen_fft(n=-3, m=0, e=e, kmin=-1, kmax=1) + dc = X[list(k).index(0)] + assert dc == pytest.approx(analytic, rel=1e-6) + assert dc == pytest.approx(quad_avg, rel=1e-6) + # Sign/scale guard: <(a/r)^3> >= 1 always (r <= a on average weighted + # this way is not the claim; the claim is the prefactor grows with e). + # A regression that dropped the exponent (e.g. computed (1-e^2)^-0.5, + # the n=-1 average) would give 1.0206 instead of 1.0152 at e=0.1 -- + # close enough to require the tighter e=0.8 point to discriminate, + # where (1-e^2)^-0.5 = 1.667 vs the correct (1-e^2)^-1.5 = 4.630. + assert dc > 1.0 + + +def test_hansen_fft_explicit_n_calls_nextpow2_int_to_round_up(monkeypatch): + """When ``N`` is given explicitly, ``hansen_fft`` must round it up to + the next power of two via ``nextpow2_int`` before running the FFT. + Spies on ``nextpow2_int`` directly (rather than comparing FFT outputs, + which converge to the same value regardless of N at these low k's, + so would not actually discriminate a broken/bypassed rounding step) + to pin the exact code path: called once with the raw N on the + explicit-N branch, and never on the adaptive (N=None) branch. + """ + calls = [] + original = hansen_mod.nextpow2_int + + def spy(x): + calls.append(x) + return original(x) + + monkeypatch.setattr(hansen_mod, 'nextpow2_int', spy) + + hansen_fft(n=-3, m=0, e=0.3, kmin=-3, kmax=3, N=100) + assert calls == [100] + + calls.clear() + hansen_fft(n=-3, m=0, e=0.3, kmin=-3, kmax=3) # N=None: adaptive branch + assert calls == [] + + +# --------------------------------------------------------------------------- +# _select_k_range / init_k_range_table / kmin_kmax_for_e: the eccentricity -> +# [kmin, kmax] window logic, independent of the Hansen-value table above. +# --------------------------------------------------------------------------- + + +@pytest.mark.physics_invariant +def test_select_k_range_widens_with_eccentricity(): + """The mode window must always cover at least the [-2, 4] floor (padded), + and must widen at higher eccentricity -- pins the module docstring's + claim that the number of non-negligible modes 'grows sharply with e'. + """ + kmin_lo, kmax_lo = _select_k_range(0.05, k_search_max=80) + kmin_hi, kmax_hi = _select_k_range(0.6, k_search_max=80) + assert kmin_lo <= -2 and kmax_lo >= 4 + assert kmin_hi <= -2 and kmax_hi >= 4 + # Discriminating: the e=0.6 window must be strictly wider than e=0.05's, + # not merely equal to the floor at both. + assert (kmax_hi - kmin_hi) > (kmax_lo - kmin_lo) + + +def test_select_k_range_falls_back_to_padded_default_when_threshold_unreachable(): + """An unreachably high threshold means no k in the search window has + |X_k| >= threshold for either the m=0 or m=2 branch -- the function + must fall back to its documented default window ([-2, 4]) plus pad, + not an empty or undefined range. + """ + kmin, kmax = _select_k_range(0.3, threshold=2.0, k_search_max=50, pad=2) + assert kmin == -2 - 2 + assert kmax == 4 + 2 + + +def test_init_k_range_table_builds_once_and_is_a_noop_on_repeat_call(monkeypatch): + """Mirrors ``init_hansen_table``'s own force/no-op contract: a repeat + call without ``force=True`` must leave the existing table (same + object) untouched, even if given different arguments. + """ + monkeypatch.setattr(hansen_mod, '_k_range_table', None) + e_grid = np.array([0.0, 0.3]) + init_k_range_table(e_grid=e_grid, force=True) + table_after_first = hansen_mod._k_range_table + assert table_after_first is not None + np.testing.assert_allclose(table_after_first.e_grid, e_grid) + assert table_after_first.kmin.shape == (2,) + assert table_after_first.kmax.shape == (2,) + # Every entry must cover the [-2, 4] floor (see _select_k_range). + assert np.all(table_after_first.kmin <= -2) + assert np.all(table_after_first.kmax >= 4) + + # Repeat call without force=True, with different arguments: no-op. + init_k_range_table(e_grid=np.array([0.5, 0.9])) + assert hansen_mod._k_range_table is table_after_first + + +def test_kmin_kmax_for_e_lazily_builds_table_and_clamps_out_of_range_e(monkeypatch): + """``kmin_kmax_for_e`` must build the table on first use if absent + (patched here to a small fake table, since the real default sweep + takes on the order of a minute), and must clamp an eccentricity + above the grid's maximum to the last grid point rather than + extrapolating or raising. + """ + monkeypatch.setattr(hansen_mod, '_k_range_table', None) + built = {'n_calls': 0} + + def fake_init(e_grid=None, force=False): + built['n_calls'] += 1 + hansen_mod._k_range_table = _KRangeTable( + e_grid=np.array([0.0, 0.5]), kmin=np.array([-6, -10]), kmax=np.array([6, 12]) + ) + + monkeypatch.setattr(hansen_mod, 'init_k_range_table', fake_init) + + kmin, kmax = kmin_kmax_for_e(0.05) + assert built['n_calls'] == 1 + assert (kmin, kmax) == (-6, 6) + + # Table now exists: a second call must NOT rebuild it. + kmin_hi, kmax_hi = kmin_kmax_for_e(10.0) # far above the grid's max (0.5) + assert built['n_calls'] == 1 + # Clamped to the last grid point's window, not extrapolated. + assert (kmin_hi, kmax_hi) == (-10, 12) + + +def test_init_hansen_table_is_a_noop_on_repeat_call_without_force(monkeypatch): + """A second call without ``force=True`` must leave the existing table + (same object, same window) untouched, even when given a completely + different e_grid/kmin/kmax -- discriminates a regression that + rebuilt on every call regardless of the guard. + """ + monkeypatch.setattr(hansen_mod, '_hansen_table', None) + init_hansen_table(e_grid=np.array([0.0, 0.1]), kmin=-3, kmax=3, n_deg=1, force=True) + table_after_first = hansen_mod._hansen_table + assert table_after_first.kmin == -3 + assert table_after_first.kmax == 3 + + init_hansen_table(e_grid=np.array([0.5]), kmin=-1, kmax=1, n_deg=1) # no force + assert hansen_mod._hansen_table is table_after_first + # Discrimination: a broken no-op guard would have rebuilt with the + # second call's kmin=-1/kmax=1 window instead of keeping the first. + assert hansen_mod._hansen_table.kmin == -3 + assert hansen_mod._hansen_table.kmax == 3 + + +def test_init_hansen_table_derives_kmin_kmax_from_k_range_table_when_omitted(monkeypatch): + """When ``kmin``/``kmax`` are not given, ``init_hansen_table`` must + derive them from the (existing) k-range table's own realized bounds + -- the overall min of its kmin column and max of its kmax column -- + rather than requiring the caller to hand-pick a window. + """ + monkeypatch.setattr(hansen_mod, '_hansen_table', None) + monkeypatch.setattr( + hansen_mod, + '_k_range_table', + _KRangeTable( + e_grid=np.array([0.0, 0.5]), kmin=np.array([-6, -10]), kmax=np.array([6, 12]) + ), + ) + init_hansen_table(e_grid=np.array([0.0, 0.2]), n_deg=1, force=True) + table = hansen_mod._hansen_table + assert table.kmin == -10 + assert table.kmax == 12 + + +def test_get_all_m_hansen_lazily_builds_table_when_absent(monkeypatch): + """The hot-path entry point must build the table itself on first use + if no setup call happened first (patched to a fast fake here; the + real default sweep is a one-time ~minute cost, out of the unit + tier's budget). + """ + monkeypatch.setattr(hansen_mod, '_hansen_table', None) + + def fake_init(n_deg=2, **_kw): + hansen_mod._hansen_table = _HansenTable( + e_grid=np.array([0.0, 0.5]), + kmin=-3, + kmax=3, + n_deg=n_deg, + values={m: np.zeros((2, 7)) for m in range(-n_deg, n_deg + 1)}, + ) + + monkeypatch.setattr(hansen_mod, 'init_hansen_table', fake_init) + + k_range, results = get_all_m_hansen(e=0.1, n_deg=2, kmin=-3, kmax=3) + assert hansen_mod._hansen_table is not None + assert set(results.keys()) == {-2, -1, 0, 1, 2} + np.testing.assert_array_equal(k_range, np.arange(-3, 4)) + + +def test_get_all_m_hansen_truncates_and_warns_when_requested_k_range_exceeds_table_window( + monkeypatch, caplog +): + """Requesting a wider [kmin, kmax] than the table was built with must + not crash the caller (a padded, forward-looking request -- see + orbit/hansen.py's padded_k_range_for_evection -- can legitimately ask + for more than a table built for a narrower eccentricity range + covers): it is clipped to the table's actual [kmin, kmax] and a + warning is logged, rather than silently returning zeros or raising. + """ + monkeypatch.setattr(hansen_mod, '_hansen_table', None) + init_hansen_table(e_grid=np.array([0.0, 0.1]), kmin=-4, kmax=4, n_deg=2, force=True) + + with caplog.at_level(logging.WARNING, logger='fwl.proteus.orbit.hansen'): + k_range, results = get_all_m_hansen(e=0.05, n_deg=2, kmin=-10, kmax=10) + + # Clipped to the table's own [-4, 4] window, not the requested [-10, 10]. + np.testing.assert_array_equal(k_range, np.arange(-4, 5)) + assert set(results.keys()) == {-2, -1, 0, 1, 2} + assert any('exceeds' in rec.message for rec in caplog.records) + + # Discrimination: a request WITHIN the table's window must produce the + # identical result without any truncation warning -- confirms this is + # specifically an out-of-window clip, not something that always fires. + caplog.clear() + with caplog.at_level(logging.WARNING, logger='fwl.proteus.orbit.hansen'): + k_range_in, _ = get_all_m_hansen(e=0.05, n_deg=2, kmin=-4, kmax=4) + assert len(k_range_in) == 9 + assert not any('exceeds' in rec.message for rec in caplog.records) + + +def test_get_all_m_hansen_all_m_are_delta_functions_at_zero_eccentricity(monkeypatch): + """``get_all_m_hansen`` must reproduce the e=0 Kronecker-delta limit + (see the ``hansen_fft`` test above) for every m from -n to n + simultaneously, confirming the dictionary assembly loop does not + mix up m indices. + + A minimal 2-point e-grid is force-built here instead of relying + on the lazy default: ``init_hansen_table``'s one-time FFT sweep + over the production ~100-point ``_DEFAULT_E_GRID`` takes on the + order of a minute of wall time, which the unit tier's budget + cannot absorb, and the table it builds is cached in a + process-global (``proteus.orbit.hansen._hansen_table``) -- + building it here with a narrow ad hoc range would silently limit + or corrupt interpolation for any other test in the same pytest + process that calls ``get_all_m_hansen`` afterward. Resetting the + global via ``monkeypatch`` (auto-restored after this test) keeps + the fast, minimal table scoped to this test only. + """ + + n = 2 + monkeypatch.setattr('proteus.orbit.hansen._hansen_table', None) + init_hansen_table(e_grid=np.array([0.0, 0.1]), kmin=-4, kmax=4, n_deg=n, force=True) + + k_range, results = get_all_m_hansen(e=0.0, n_deg=n, kmin=-4, kmax=4) + assert set(results.keys()) == {-2, -1, 0, 1, 2} + for m in range(-n, n + 1): + expected = np.where(k_range == m, 1.0, 0.0) + np.testing.assert_allclose(results[m], expected, atol=1e-9) + + +# --------------------------------------------------------------------------- +# nextpow2_int +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + 'x,expected', + [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 3), (1023, 10), (1024, 10), (1025, 11)], +) +def test_nextpow2_int_matches_smallest_covering_power(x, expected): + """``2**nextpow2_int(x) >= x`` and no smaller power of two covers x; + pinned at exact powers of two and their neighbours, where off-by-one + boundary bugs in ``ceil(log2(x))`` are most likely to surface. + """ + p = nextpow2_int(x) + assert p == expected + if x > 0: + assert 2**p >= x + assert 2 ** (p - 1) < x or p == 0 + + +# --------------------------------------------------------------------------- +# padded_k_range_for_evection +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _fast_k_range_table(monkeypatch): + """A narrow, fast eccentricity grid for the [kmin, kmax] table, reset + after the test via monkeypatch. Building the default table + (``init_k_range_table()`` with no arguments) costs on the order of a + minute of wall time -- a real FFT-based search at every one of ~100+ + grid points -- far outside the unit tier's budget. Mirrors the + ``_hansen_table`` reset pattern used above for + ``test_get_all_m_hansen_matches_hand_built_delta_at_e_zero``. + """ + monkeypatch.setattr('proteus.orbit.hansen._k_range_table', None) + init_k_range_table(e_grid=np.array([0.0, 0.2, 0.4, 0.6, 0.8]), force=True) + + +@pytest.mark.physics_invariant +def test_padded_k_range_for_evection_reduces_to_unpadded_at_zero_rate(_fast_k_range_table): + """At de_dt_yr=0 (or padding_factor=0), the padded lookup must be + IDENTICAL to calling ``kmin_kmax_for_e(e_now)`` directly: the padding + is meant to vanish once outside the regime it exists for (rate small + relative to eccentricity), and this is the exact-zero limit of that. + """ + e_now = 0.4 + unpadded = kmin_kmax_for_e(e_now) + + assert ( + padded_k_range_for_evection(e_now, de_dt_yr=0.0, dt_next_yr=100.0, padding_factor=2.0) + == unpadded + ) + assert ( + padded_k_range_for_evection(e_now, de_dt_yr=0.05, dt_next_yr=100.0, padding_factor=0.0) + == unpadded + ) + + +@pytest.mark.physics_invariant +def test_padded_k_range_for_evection_widens_with_faster_expected_growth(_fast_k_range_table): + """A nonzero de/dt over a real look-ahead window must widen the window + relative to the unpadded (current-e) one -- the entire point of the + padding: cover where e is headed over the upcoming step, not just + where it is now. + """ + e_now = 0.2 + unpadded_kmin, unpadded_kmax = kmin_kmax_for_e(e_now) + + # de/dt=0.01/yr over a 20 yr look-ahead pads e by 2*0.01*20=0.4, + # landing near e=0.6 -- comfortably into a wider table bucket. + kmin_pad, kmax_pad = padded_k_range_for_evection( + e_now, de_dt_yr=0.01, dt_next_yr=20.0, padding_factor=2.0 + ) + assert kmax_pad >= unpadded_kmax + assert kmin_pad <= unpadded_kmin + # Discrimination: the padded window must be a genuinely DIFFERENT + # (strictly wider) bucket, not merely the same one by coincidence -- + # otherwise this test would pass even if the padding were a no-op. + assert (kmin_pad, kmax_pad) != (unpadded_kmin, unpadded_kmax) + + +@pytest.mark.physics_invariant +def test_padded_k_range_for_evection_ignores_the_sign_of_de_dt(_fast_k_range_table): + """A DECREASING eccentricity (post-peak decay, de_dt_yr < 0) must pad + outward exactly as a growing one would: the failure mode this padding + guards against is only ever "needed modes excluded", so erring wide is + the deliberately safe direction regardless of which way e is moving. + """ + e_now = 0.2 + growing = padded_k_range_for_evection( + e_now, de_dt_yr=0.01, dt_next_yr=20.0, padding_factor=2.0 + ) + decaying = padded_k_range_for_evection( + e_now, de_dt_yr=-0.01, dt_next_yr=20.0, padding_factor=2.0 + ) + assert growing == decaying + # Discrimination: this is not simply because both are no-ops -- the + # padded window is still strictly wider than the unpadded one. + assert growing != kmin_kmax_for_e(e_now) + + +@pytest.mark.physics_invariant +def test_padded_k_range_for_evection_clips_to_e_cap(_fast_k_range_table): + """An extreme rate/look-ahead combination must clip the padded + eccentricity to the explicit ``e_cap`` argument rather than + extrapolating past it. ``e_cap=0.5`` is deliberately set BELOW the + fixture table's own top grid point (0.8), so a pass here cannot be + explained by ``kmin_kmax_for_e``'s own internal saturation at the + table's edge -- it must come from this function's own clip. + """ + e_now = 0.2 + huge_pad = padded_k_range_for_evection( + e_now, de_dt_yr=10.0, dt_next_yr=1000.0, padding_factor=1.0, e_cap=0.5 + ) + assert huge_pad == kmin_kmax_for_e(0.5) + # Discrimination: if the clip were silently absorbed by the table's + # own top-of-domain saturation instead of this function's e_cap, the + # result would equal kmin_kmax_for_e(0.8), not kmin_kmax_for_e(0.5). + assert huge_pad != kmin_kmax_for_e(0.8) diff --git a/tests/orbit/test_lovepy_mocked.py b/tests/orbit/test_lovepy_mocked.py index 1e6d29f83..901c8e866 100644 --- a/tests/orbit/test_lovepy_mocked.py +++ b/tests/orbit/test_lovepy_mocked.py @@ -31,6 +31,11 @@ and returns Imk2. SPIDER ordering gets reversed so i=0 sits at the CMB. - ``juliacall.JuliaError`` is wrapped into ``RuntimeError``. + - on the heated (non-early-return) paths, the hardcoded ``nmk`` + mode table, ``sigma`` (the orbital forcing frequency), and + ``LNk`` (0 + Imk2*1j for all three modes) are stored into + ``tides_o`` under ``primary='planet', perturber='star'``; the + early-return and error paths leave ``tides_o`` untouched. See also: - docs/How-to/testing.md @@ -47,6 +52,8 @@ pytest.importorskip('juliacall') +from proteus.orbit.common import Tides_t + pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] @@ -67,11 +74,14 @@ def _make_interior_t(module: str, nlev_s: int = 5): return interior_o -def _make_config(module: str, visc_thresh: float = 1e9, ncalc: int = 1000): +def _make_config( + module: str, visc_thresh: float = 1e9, ncalc: int = 1000, perturber: str = 'star' +): cfg = types.SimpleNamespace() cfg.interior_energetics = types.SimpleNamespace() cfg.interior_energetics.module = module cfg.orbit = types.SimpleNamespace() + cfg.orbit.perturber = perturber cfg.orbit.lovepy = types.SimpleNamespace() cfg.orbit.lovepy.visc_thresh = visc_thresh cfg.orbit.lovepy.ncalc = ncalc @@ -108,6 +118,11 @@ def test_jlarr_converts_to_julia_array_with_documented_element_type(monkeypatch) ``jl.Array[jl.LovePy.prec, 1]`` Julia type. Pin the call signature so a regression that broadened the dim from 1 to 2 or swapped the element type surfaces. + + ``_jlarr`` is bound (via ``make_julia_converters('LovePy')``) from + ``proteus.utils.julia_common``, so ``jl``/``juliacall`` are patched + there, not on ``lovepy_mod`` -- the converter closures resolve + those names in the module they were defined in, not the caller's. """ from proteus.orbit import lovepy as lovepy_mod @@ -118,8 +133,8 @@ def test_jlarr_converts_to_julia_array_with_documented_element_type(monkeypatch) fake_jl.LovePy = MagicMock() fake_jl.LovePy.prec = 'prec_sentinel' fake_jl.Array.__getitem__ = MagicMock(return_value='destination_type') - monkeypatch.setattr(lovepy_mod, 'juliacall', fake_juliacall) - monkeypatch.setattr(lovepy_mod, 'jl', fake_jl) + monkeypatch.setattr('proteus.utils.julia_common.juliacall', fake_juliacall) + monkeypatch.setattr('proteus.utils.julia_common.jl', fake_jl) arr = np.array([1.0, 2.0, 3.0]) out = lovepy_mod._jlarr(arr) @@ -135,6 +150,10 @@ def test_jlsca_converts_to_julia_prec_scalar(monkeypatch): """``_jlsca`` converts a python float to the LovePy precision type via ``juliacall.convert(jl.LovePy.prec, sca)``. Pin the destination-type argument. + + See ``test_jlarr_converts_to_julia_array_with_documented_element_type`` + for why ``jl``/``juliacall`` are patched on + ``proteus.utils.julia_common``. """ from proteus.orbit import lovepy as lovepy_mod @@ -143,8 +162,8 @@ def test_jlsca_converts_to_julia_prec_scalar(monkeypatch): fake_jl = MagicMock(name='jl') fake_jl.LovePy = MagicMock() fake_jl.LovePy.prec = 'prec_sentinel' - monkeypatch.setattr(lovepy_mod, 'juliacall', fake_juliacall) - monkeypatch.setattr(lovepy_mod, 'jl', fake_jl) + monkeypatch.setattr('proteus.utils.julia_common.juliacall', fake_juliacall) + monkeypatch.setattr('proteus.utils.julia_common.jl', fake_jl) out = lovepy_mod._jlsca(0.5) fake_juliacall.convert.assert_called_once_with('prec_sentinel', 0.5) @@ -164,6 +183,12 @@ def test_run_lovepy_dummy_returns_zero_when_top_cell_below_visc_thresh(monkeypat Discrimination: the Julia ``calc_lovepy_tides`` is not called on the early-return path; pin the call count at 0. + + The early-return path still populates a zero-heating tides_o + entry (``store_lovepy_tides(omega, 0.0, tides_o)``) so a later + ``tides_o.get('planet', 'star')`` in the sp1d path does not raise + ``KeyError`` for a fully-liquid mantle -- confirmed here rather + than assuming the entry stays absent. """ from proteus.orbit import lovepy as lovepy_mod @@ -179,11 +204,18 @@ def test_run_lovepy_dummy_returns_zero_when_top_cell_below_visc_thresh(monkeypat interior_o.visc[0] = 1.0 cfg = _make_config(module='dummy', visc_thresh=1e9) hf_row = {'orbital_period': 86400.0 * 365.0, 'eccentricity': 0.1} + tides_o = Tides_t() - out = lovepy_mod.run_lovepy(hf_row, dirs={}, interior_o=interior_o, config=cfg) + out = lovepy_mod.run_lovepy( + hf_row, dirs={}, interior_o=interior_o, tides_o=tides_o, config=cfg + ) assert out == pytest.approx(0.0, abs=1e-12) fake_jl.calc_lovepy_tides.assert_not_called() + # A zero-heating entry must still be registered on this early-return + # path, so a downstream tides_o.get('planet', 'star') never raises. + storage = tides_o.get(primary='planet', perturber='star') + assert np.all(storage.LNk == 0.0 + 0.0j) # --------------------------------------------------------------------------- @@ -197,6 +229,11 @@ def test_run_lovepy_aragog_returns_zero_when_full_mantle_below_visc_thresh(monke region of high-viscosity cells from the bottom up). Discrimination: the Julia tides call does not fire. + + A zero-heating tides_o entry must still be registered on this + fully-liquid early-return path (matching the dummy/boundary case + above), so a downstream ``tides_o.get('planet', 'star')`` never + raises ``KeyError`` during the early magma-ocean phase. """ from proteus.orbit import lovepy as lovepy_mod @@ -210,10 +247,15 @@ def test_run_lovepy_aragog_returns_zero_when_full_mantle_below_visc_thresh(monke interior_o.visc[:] = 1.0 cfg = _make_config(module='aragog', visc_thresh=1e9) hf_row = {'orbital_period': 1e7, 'eccentricity': 0.0} + tides_o = Tides_t() - out = lovepy_mod.run_lovepy(hf_row, dirs={}, interior_o=interior_o, config=cfg) + out = lovepy_mod.run_lovepy( + hf_row, dirs={}, interior_o=interior_o, tides_o=tides_o, config=cfg + ) assert out == pytest.approx(0.0, abs=1e-12) fake_jl.calc_lovepy_tides.assert_not_called() + storage = tides_o.get(primary='planet', perturber='star') + assert np.all(storage.LNk == 0.0 + 0.0j) # --------------------------------------------------------------------------- @@ -228,7 +270,12 @@ def test_run_lovepy_dummy_heated_branch_writes_tides_and_returns_imk2(monkeypatc ``float(Imk2)``. Discrimination: per-cell tides slot is populated; return value - matches the Imk2 mock; calc_lovepy_tides called once. + matches the Imk2 mock; calc_lovepy_tides called once. Also + covers the ``tides_o`` storage block reached on this path: the + hardcoded mode table, and the per-mode reality-condition mirror + for ``sigma``/``LNk`` -- (2,0,1) and (2,2,3) share the same sign + (forcing frequency -omega), (2,2,1) gets the opposite sign + (+omega) -- get written under ``primary='planet', perturber='star'``. """ from proteus.orbit import lovepy as lovepy_mod @@ -241,8 +288,11 @@ def test_run_lovepy_dummy_heated_branch_writes_tides_and_returns_imk2(monkeypatc interior_o = _make_interior_t(module='dummy', nlev_s=3) cfg = _make_config(module='dummy', visc_thresh=1e9, ncalc=1000) hf_row = {'orbital_period': 1e7, 'eccentricity': 0.1} + tides_o = Tides_t() - out = lovepy_mod.run_lovepy(hf_row, dirs={}, interior_o=interior_o, config=cfg) + out = lovepy_mod.run_lovepy( + hf_row, dirs={}, interior_o=interior_o, tides_o=tides_o, config=cfg + ) assert fake_jl.calc_lovepy_tides.call_count == 1 # Tides slot 0 populated from power_prf[1]. assert interior_o.tides[0] == pytest.approx(1.5e-6, rel=1e-12) @@ -250,6 +300,29 @@ def test_run_lovepy_dummy_heated_branch_writes_tides_and_returns_imk2(monkeypatc assert isinstance(out, float) assert out == pytest.approx(-0.0125, rel=1e-12) + storage = tides_o.get(primary='planet', perturber='star') + np.testing.assert_array_equal(storage.nmk, [[2, 0, 1], [2, 2, 1], [2, 2, 3]]) + expected_omega = 2 * np.pi / hf_row['orbital_period'] + # Flat (3,) shape, not (3, 1): nested-bracket construction previously + # left this array 2-D, which breaks _dense_love's boolean-mask + # assignment (dense[indices] = LNk[mask]) for more than one masked + # mode. + assert storage.sigma.shape == (3,) + assert storage.LNk.shape == (3,) + # (2,0,1) and (2,2,3) sit at forcing frequency -omega; (2,2,1) at + # +omega -- the reality condition Im(k2(-omega)) = -Im(k2(omega)) + # then flips the sign of LNk between them (see store_lovepy_tides). + np.testing.assert_allclose( + storage.sigma, [-expected_omega, expected_omega, -expected_omega], rtol=1e-12 + ) + np.testing.assert_allclose( + storage.LNk, [0.0 + 0.0125j, 0.0 - 0.0125j, 0.0 + 0.0125j], rtol=1e-12 + ) + # Discrimination: the real part must stay exactly zero (only + # Imk2 is known; a regression that leaked omega or Imk2 into the + # real part would fail this). + assert np.all(np.real(storage.LNk) == 0.0) + # --------------------------------------------------------------------------- # run_lovepy: spider / aragog heated branch. @@ -281,8 +354,11 @@ def test_run_lovepy_aragog_heated_branch_writes_per_cell_tides(monkeypatch): monkeypatch.setattr(lovepy_mod, 'jl', fake_jl) monkeypatch.setattr(lovepy_mod, '_jlarr', lambda a: a) monkeypatch.setattr(lovepy_mod, '_jlsca', lambda s: s) + tides_o = Tides_t() - out = lovepy_mod.run_lovepy(hf_row, dirs={}, interior_o=interior_o, config=cfg) + out = lovepy_mod.run_lovepy( + hf_row, dirs={}, interior_o=interior_o, tides_o=tides_o, config=cfg + ) # tides[0:i_top] holds the power, with tides[0] duplicated from tides[1]. assert interior_o.tides[1] == pytest.approx(2e-6, rel=1e-12) assert interior_o.tides[2] == pytest.approx(3e-6, rel=1e-12) @@ -294,6 +370,17 @@ def test_run_lovepy_aragog_heated_branch_writes_per_cell_tides(monkeypatch): assert isinstance(out, float) assert out == pytest.approx(-0.025, rel=1e-12) + storage = tides_o.get(primary='planet', perturber='star') + expected_omega = 2 * np.pi / hf_row['orbital_period'] + assert storage.sigma.shape == (3,) + assert storage.LNk.shape == (3,) + np.testing.assert_allclose( + storage.sigma, [-expected_omega, expected_omega, -expected_omega], rtol=1e-12 + ) + np.testing.assert_allclose( + storage.LNk, [0.0 + 0.025j, 0.0 - 0.025j, 0.0 + 0.025j], rtol=1e-12 + ) + def test_run_lovepy_spider_heated_branch_reverses_order(monkeypatch): """Heated SPIDER path: arrays are reversed at entry so i=0 sits @@ -318,8 +405,11 @@ def test_run_lovepy_spider_heated_branch_reverses_order(monkeypatch): monkeypatch.setattr(lovepy_mod, 'jl', fake_jl) monkeypatch.setattr(lovepy_mod, '_jlarr', lambda a: a) monkeypatch.setattr(lovepy_mod, '_jlsca', lambda s: s) + tides_o = Tides_t() - out = lovepy_mod.run_lovepy(hf_row, dirs={}, interior_o=interior_o, config=cfg) + out = lovepy_mod.run_lovepy( + hf_row, dirs={}, interior_o=interior_o, tides_o=tides_o, config=cfg + ) # Under SPIDER ordering, the tides array is reversed on write # so the entry that was at index 1 (post-duplication) lands at # ``-2`` in the surface-to-CMB array. tides[-1] holds the prefix @@ -332,6 +422,78 @@ def test_run_lovepy_spider_heated_branch_reverses_order(monkeypatch): assert interior_o.tides[0] == pytest.approx(0.0, abs=1e-30) assert out == pytest.approx(-0.030, rel=1e-12) + storage = tides_o.get(primary='planet', perturber='star') + assert storage.LNk.shape == (3,) + np.testing.assert_allclose( + storage.LNk, [0.0 + 0.030j, 0.0 - 0.030j, 0.0 + 0.030j], rtol=1e-12 + ) + + +# --------------------------------------------------------------------------- +# run_lovepy: satellite perturber reads the satellite-side orbital state. +# --------------------------------------------------------------------------- + + +def test_run_lovepy_satellite_perturber_reads_satellite_orbital_state(monkeypatch): + """``config.orbit.perturber == 'satellite'`` reads + ``orbital_period_sat``/``eccentricity_sat`` instead of the star-planet + fields, and tags the ``tides_o`` entry ``perturber='satellite'``.""" + from proteus.orbit import lovepy as lovepy_mod + + fake_jl = MagicMock(name='jl') + fake_jl.calc_lovepy_tides = MagicMock(return_value=(np.array([0.0, 1.5e-6]), 0.05, -0.0125)) + monkeypatch.setattr(lovepy_mod, 'jl', fake_jl) + monkeypatch.setattr(lovepy_mod, '_jlarr', lambda a: a) + monkeypatch.setattr(lovepy_mod, '_jlsca', lambda s: s) + + interior_o = _make_interior_t(module='dummy', nlev_s=3) + cfg = _make_config(module='dummy', visc_thresh=1e9, perturber='satellite') + hf_row = { + 'orbital_period': 1e9, + 'eccentricity': 0.9, + 'orbital_period_sat': 2.36e6, + 'eccentricity_sat': 0.02, + } + tides_o = Tides_t() + + out = lovepy_mod.run_lovepy( + hf_row, dirs={}, interior_o=interior_o, tides_o=tides_o, config=cfg + ) + assert out == pytest.approx(-0.0125, rel=1e-12) + storage = tides_o.get(primary='planet', perturber='satellite') + expected_omega = 2 * np.pi / hf_row['orbital_period_sat'] + np.testing.assert_allclose( + storage.sigma, [-expected_omega, expected_omega, -expected_omega], rtol=1e-12 + ) + + +def test_run_lovepy_rejects_an_unrecognized_perturber(monkeypatch): + """Neither 'star' nor 'satellite': must raise a clear ``ValueError`` + up front, not silently fall through and fail later with an + ``UnboundLocalError`` on ``omega``/``ecc`` (only ever assigned inside + the 'star'/'satellite' branches). Mirrors + ``test_run_obliqua_rejects_an_unrecognized_perturber``. + """ + from proteus.orbit import lovepy as lovepy_mod + + updates: list[tuple] = [] + monkeypatch.setattr( + lovepy_mod, 'UpdateStatusfile', lambda dirs, code: updates.append((dirs, code)) + ) + + interior_o = _make_interior_t(module='dummy', nlev_s=3) + cfg = _make_config(module='dummy', perturber=None) + hf_row = {'orbital_period': 1e7, 'eccentricity': 0.1} + tides_o = Tides_t() + + with pytest.raises(ValueError, match='perturber'): + lovepy_mod.run_lovepy( + hf_row, dirs={'output': '/tmp'}, interior_o=interior_o, tides_o=tides_o, config=cfg + ) + assert updates == [({'output': '/tmp'}, 26)] + # The error path exits before the tides_o storage block. + assert tides_o.interactions == [] + # --------------------------------------------------------------------------- # run_lovepy: JuliaError wrapped into RuntimeError. @@ -365,11 +527,14 @@ def fake_update_statusfile(dirs, code): interior_o = _make_interior_t(module='dummy', nlev_s=3) cfg = _make_config(module='dummy', visc_thresh=1e9, ncalc=1000) hf_row = {'orbital_period': 1e7, 'eccentricity': 0.1} + tides_o = Tides_t() with pytest.raises(RuntimeError, match=r'(?i)lovepy'): lovepy_mod.run_lovepy( - hf_row, dirs={'output': '/tmp'}, interior_o=interior_o, config=cfg + hf_row, dirs={'output': '/tmp'}, interior_o=interior_o, tides_o=tides_o, config=cfg ) # UpdateStatusfile recorded the error code. assert len(updates) == 1 assert updates[0][1] == 26 + # The error path exits before the tides_o storage block. + assert tides_o.interactions == [] diff --git a/tests/orbit/test_obliqua.py b/tests/orbit/test_obliqua.py new file mode 100644 index 000000000..558102849 --- /dev/null +++ b/tests/orbit/test_obliqua.py @@ -0,0 +1,1815 @@ +"""Unit tests for proteus.orbit.obliqua: the Obliqua tidal heating wrapper. + +Obliqua is the Julia-backed tidal heating module for the joint +solid/mushy/fluid interior tidal response. The Python wrapper in +``src/proteus/orbit/obliqua.py`` packages PROTEUS interior arrays and +config into Julia types via ``juliacall``, calls +``Obliqua.run_tides``, and writes the per-cell tidal power density +back into ``Interior_t.tides`` and the Love-number spectrum into +``Tides_t``. + +These unit tests mock the Julia side (``jl.Obliqua.*`` and +``juliacall.convert``) so the wrapper's orchestration logic executes +without a real Julia + Obliqua install, following the same approach +as ``test_lovepy_mocked.py`` for the sibling LovePy module. The real +``Tides_t`` container from ``proteus.orbit.common`` is used +unmocked, since it is pure Python dataclass logic, not a Julia +boundary. + +Exercises: + +- ``import_obliqua``: single ``jl.seval('using Obliqua')`` call. +- ``to_julia_dict``: recursive dict/list conversion, nesting preserved. +- ``_jlarr`` / ``_jlsca_float`` / ``_jlsca_prec``: ``juliacall.convert`` + destination-type contract. +- ``run_obliqua`` dispatch by ``config.orbit.perturber`` (star vs. + satellite orbital state) and by ``interior_energetics.module`` + (dummy two-cell construction; SPIDER array reversal on the way in + and back out; direct aragog-style write with no reversal). +- ``run_obliqua``: total tidal power is conserved (sum-invariant) + under the SPIDER reversal, since reversal is a permutation. +- ``run_obliqua``: ``juliacall.JuliaError`` is wrapped into + ``RuntimeError``, with ``UpdateStatusfile`` called with code 26. +- ``run_obliqua``: results are stored into ``tides_o`` under + ``primary='planet'`` keyed by the configured perturber. +- ``lookup_from_interior``: JSON IC -> Obliqua call -> netCDF lookup + table round trip, including the core-density/core-mass split + (density[0] becomes ``core_density``; the rest is passed as + ``rho``). +- ``LN_from_lookup``: pure NumPy/SciPy post-processing with no Julia + boundary, so these are genuine, mock-free physics tests: exact + interpolation at a lookup node, the Love-number reality/symmetry + condition (a real-valued response function must satisfy + ``LNk(-sigma) == conj(LNk(sigma))``), the missing-degree error + contract, the negative-(m,k) zeroing convention, the forcing + frequency formula ``sigma_s = m*axial_freq - k*orbit_freq``, and the + lookup-table cache/regeneration dispatch (``.json`` triggers + ``lookup_from_interior`` once and caches the result; ``.nc`` loads + directly with no regeneration). +- ``read_ncdf``/``read_ncdfs``: netCDF variable round trip, and that + ``read_ncdfs`` orders its output by the caller's ``times`` list, not + filesystem order. +- ``_padded_obliqua_k_range``: the look-ahead k-range padding applied to + the satellite-perturber adaptive spectrum while + ``tides_o.evection_zone_active`` is set -- a pure-Python helper with no + Julia boundary, tested directly (no ``jl`` mocking needed for these + cases). +- ``setup_logging``: ``jl.Obliqua.setup_logging`` call-argument + contract (log path, verbosity passthrough). +- ``sync_log_files``: copy-and-clear contract, the missing-file + fallback, and a pinned discrepancy in the *return value* (see + below). + +Known testability gap (not worked around): the aragog/non-SPIDER +branch scales a bulk-averaged tidal power by ``sum(mass)`` purely for +a ``log.debug`` line; the scaled value has no other observable +effect, so it is not independently pinned here (log-line-only +assertions are an explicitly discouraged pattern -- see +``.github/.claude/rules/proteus-tests.md`` section 16). The test for +that branch only confirms the division executes without raising and +that the unflipped profile is written to ``interior_o.tides``. + +A second, similar gap is pinned rather than worked around in +``sync_log_files``: the function's docstring says it "returns the +list of lines that were copied," but the returned list is the +*original* lines as read, not the NULL-prefix-cleaned lines actually +written to the PROTEUS logfile (the cleaned text is a locally +rebound loop variable, never written back into the list). A caller +scanning the returned lines for a failure-mode marker at the start +of line 0 would see the uncleaned text. + +See also: +- docs/How-to/test_infrastructure.md +- docs/How-to/test_building.md +- docs/How-to/test_categorization.md +""" + +from __future__ import annotations + +import json +import os +import types +from unittest.mock import MagicMock, call + +import netCDF4 as nc +import numpy as np +import pytest + +pytest.importorskip('juliacall') + +from proteus.config._orbit import Obliqua, ObliquaFluid +from proteus.orbit.common import Tides_t + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +def _make_interior_t(nlev_s: int): + """Minimal Interior_t-like stand-in with nlev_s cells (nlev_s + 1 + radius edges), matching the layout in + ``proteus.interior_energetics.common.Interior_t``. + """ + interior_o = types.SimpleNamespace() + interior_o.nlev_s = nlev_s + interior_o.density = np.linspace(3000.0, 5000.0, nlev_s) + interior_o.visc = np.full(nlev_s, 1e20) + interior_o.shear = np.full(nlev_s, 6e10) + interior_o.bulk = np.full(nlev_s, 2e11) + interior_o.phi = np.zeros(nlev_s) + interior_o.mass = np.full(nlev_s, 1e20) + interior_o.radius = np.linspace(3.0e6, 6.4e6, nlev_s + 1) + interior_o.tides = np.zeros(nlev_s) + return interior_o + + +def _make_config(module: str, perturber: str, star_planet_model: str | None = None): + """Fake Config namespace exposing exactly the attribute paths that + ``run_obliqua`` reads. ``orbit.obliqua`` must be a REAL attrs-decorated + ``Obliqua`` instance (not a duck-typed stand-in): ``_obliqua_module_cfg`` + builds Obliqua's cfg dict via ``attrs.asdict(config.orbit.obliqua)``, + which requires an actual attrs class. + """ + cfg = types.SimpleNamespace() + cfg.orbit = types.SimpleNamespace() + cfg.orbit.perturber = perturber + cfg.orbit.star_planet_model = star_planet_model + + # visc_l/visc_s are no longer part of Obliqua's own config (they are + # patched in by _obliqua_module_cfg from + # config.interior_energetics.melt_log10visc/solid_log10visc instead -- + # see below), so only the fields the real Obliqua/ObliquaFluid classes + # still declare are overridden here. + cfg.orbit.obliqua = Obliqua( + store_3D=False, + enforce_ec=True, + optimize_scales=False, + solid_shell=True, + min_frac=0.02, + visc_lus=5e5, + visc_sus=5e5, + n=[2], + m=[0, 2], + k_min='none', + k_max='none', + evection_padding_factor=2.0, + material_mu='andrade', + material_k='andrade', + alpha=0.3, + module_solid='solid0d', + module_mushy='none', + module_fluid='fluid0d', + fluid=ObliquaFluid( + sigma_R=1e-3, + sigma_R_factor=0.5, + sigma_R_prf='exp', + H_R=1e4, + efficiency=0.3, + ), + ) + + cfg.interior_energetics = types.SimpleNamespace() + cfg.interior_energetics.module = module + cfg.interior_energetics.grain_size = 1e-3 + # 10**2.0 == 1e2 Pa s, 10**22.0 == 1e22 Pa s: reproduces the old + # visc_l=1e2/visc_s=1e22 test values through the new log10 fields. + cfg.interior_energetics.melt_log10visc = 2.0 + cfg.interior_energetics.solid_log10visc = 22.0 + cfg.interior_energetics.boundary = types.SimpleNamespace( + core_density=1e4, + core_shear=8e10, + core_bulk=1.4e11, + ) + cfg.interior_struct = types.SimpleNamespace(core_density=1e4) + return cfg + + +def _make_fake_jl(power_prf, power_blk, nmk, sigma, lnk): + """Fake ``jl`` with the ``Obliqua`` namespace ``run_obliqua`` calls + directly: ``interior.get_permeability/limit_porosity/get_drained_bulk`` + and ``run_tides``. + """ + fake_jl = MagicMock(name='jl') + fake_jl.Obliqua.interior.get_permeability = MagicMock(return_value='perm') + fake_jl.Obliqua.interior.limit_porosity = MagicMock( + return_value=('perm_limited', 'phi_limited') + ) + fake_jl.Obliqua.interior.get_drained_bulk = MagicMock(return_value='bulkd') + fake_jl.Obliqua.run_tides = MagicMock(return_value=(power_prf, power_blk, nmk, sigma, lnk)) + return fake_jl + + +def _write_lookup_netcdf(path, nmk_rows, sigma, lnk): + """Write a minimal real netCDF lookup file in the schema + ``Tides_t.add_from_file`` / ``lookup_from_interior`` use: integer + ``n``/``m``/``k`` mode-index variables plus float ``sigma`` and + ``LNk_real``/``LNk_imag``, all on a single ``mode`` dimension. + """ + nmk_rows = np.asarray(nmk_rows, dtype=np.int64) + sigma = np.asarray(sigma, dtype=np.float64) + lnk = np.asarray(lnk, dtype=np.complex128) + with nc.Dataset(path, 'w', format='NETCDF4') as ds: + ds.createDimension('mode', len(sigma)) + ds.createVariable('n', 'i4', ('mode',))[:] = nmk_rows[:, 0] + ds.createVariable('m', 'i4', ('mode',))[:] = nmk_rows[:, 1] + ds.createVariable('k', 'i4', ('mode',))[:] = nmk_rows[:, 2] + ds.createVariable('sigma', 'f8', ('mode',))[:] = sigma + ds.createVariable('LNk_real', 'f8', ('mode',))[:] = np.real(lnk) + ds.createVariable('LNk_imag', 'f8', ('mode',))[:] = np.imag(lnk) + + +def _seed_satellite_dict_cache(tides_o: Tides_t, nmk_rows, sigma, lnk): + """Populate the ``('satellite_dict', 'planet')`` cache entry + directly (bypassing file I/O), matching what + ``Tides_t.add_from_file`` would have produced. + """ + entry = tides_o.add(primary='satellite_dict', perturber='planet') + entry.nmk = np.asarray(nmk_rows, dtype=int) + entry.sigma = np.asarray(sigma, dtype=float) + entry.LNk = np.asarray(lnk, dtype=complex) + return entry + + +def _seed_planet_modes(tides_o: Tides_t, nmk_rows): + """Populate the ``('planet', 'satellite')`` mode table that + ``LN_from_lookup`` reads as its starting point (normally written + earlier by ``run_obliqua``'s satellite-perturber branch). + """ + entry = tides_o.add(primary='planet', perturber='satellite') + entry.nmk = np.asarray(nmk_rows, dtype=int) + return entry + + +def _make_satellite_config(love_number_sat): + """Fake Config namespace exposing only + ``config.orbit.satellite.love_number_sat``, the single field + ``lookup_from_interior``/``LN_from_lookup`` read from ``config`` + directly (the rest comes from ``config.orbit.obliqua``, covered by + ``_make_config`` above for the tests that also call ``run_tides``). + """ + cfg = _make_config(module='aragog', perturber='satellite') + cfg.orbit.satellite = types.SimpleNamespace(love_number_sat=love_number_sat) + return cfg + + +def _patch_identity_conversions(monkeypatch, obliqua_mod): + """Patch the Julia-conversion helpers to identity so run_obliqua + tests can inspect plain numpy/Python values in call args, and + isolate the orchestration logic from the dedicated conversion + tests below. + """ + monkeypatch.setattr(obliqua_mod, '_jlarr', np.asarray) + monkeypatch.setattr(obliqua_mod, '_jlsca_float', lambda s: s) + monkeypatch.setattr(obliqua_mod, '_jlsca_prec', lambda s: s) + monkeypatch.setattr(obliqua_mod, 'to_julia_dict', lambda cfg: cfg) + monkeypatch.setattr(obliqua_mod, 'sync_log_files', lambda outdir: []) + + +# --------------------------------------------------------------------------- +# run_obliqua: spectrum selection (sp0d -> legacy, else adaptive). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + 'star_planet_model,expected_spectrum', + [('sp0d', 'legacy'), ('sp1d', 'adaptive'), (None, 'adaptive')], +) +def test_run_obliqua_spectrum_is_legacy_only_for_sp0d( + monkeypatch, tmp_path, star_planet_model, expected_spectrum +): + """``run_obliqua`` sets ``spectrum='legacy'`` (mimicking lovepy's + spin-orbit-synchronised, small-eccentricity assumption) only when + ``orbit.star_planet_model == 'sp0d'``; every other model, including + no star-planet model at all, gets the full ``'adaptive'`` spectrum.""" + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + interior_o = _make_interior_t(3) + cfg = _make_config(module='dummy', perturber='star', star_planet_model=star_planet_model) + hf_row = { + 'Time': 10.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.1, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + fake_jl = _make_fake_jl( + power_prf=np.array([0.0, 5e-7]), + power_blk=1.0, + nmk=[(2, 0, 1)], + sigma=[1e-6], + lnk=[0.01 - 0.02j], + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=Tides_t(), + config=cfg, + ) + + cfg_arg = fake_jl.Obliqua.run_tides.call_args[0][-1] + assert cfg_arg['orbit']['obliqua']['spectrum'] == expected_spectrum + + +def test_run_obliqua_stores_lnk_and_sigma_as_real_numpy_arrays(monkeypatch, tmp_path): + """``jl.Obliqua.run_tides`` returns raw PythonCall-wrapped Julia + values for ``sigma``/``LNk`` in production; simulated here with + plain Python lists, which -- like a real PythonCall ``JlArray`` -- + do not support boolean-mask fancy indexing. ``run_obliqua`` must + convert both to genuine numpy arrays before storing them on + ``tides_o``, matching ``Tides_t``'s own declared field types + (``NDArray[floating]``, ``NDArray[complexfloating]``). + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + interior_o = _make_interior_t(3) + cfg = _make_config(module='dummy', perturber='star', star_planet_model='sp1d') + hf_row = { + 'Time': 10.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.1, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + fake_jl = _make_fake_jl( + power_prf=np.array([0.0, 5e-7]), + power_blk=1.0, + nmk=[(2, 0, 1), (2, 2, 3)], + sigma=[1e-6, 2e-6], # plain list, NOT an ndarray, mimics a raw JlArray + lnk=[0.01 - 0.02j, 0.03 - 0.04j], # plain list, NOT an ndarray + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + tides_o = Tides_t() + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + + stored = tides_o.get(primary='planet', perturber='star') + assert isinstance(stored.LNk, np.ndarray) + assert isinstance(stored.sigma, np.ndarray) + assert np.issubdtype(stored.sigma.dtype, np.floating) + assert np.issubdtype(stored.LNk.dtype, np.complexfloating) + + # Discrimination: the exact operation that crashed in production, + # boolean-mask indexing, must now work without raising. + mask = np.array([True, False]) + picked = stored.LNk[mask] + assert picked.shape == (1,) + assert picked[0] == pytest.approx(0.01 - 0.02j) + + +class _FakeJuliaPrecisionScalar: + """Mimics a PythonCall-boxed Julia high-precision scalar (e.g. + ``DoubleFloats.Double64``, a 2-``Float64`` hi/lo struct): ``np.sign()`` + on a real instance can pick up numpy's array/buffer-protocol + duck-typing over that memory layout instead of treating it as a + scalar, silently returning a non-0-d array. ``float()`` still + extracts the correct scalar via the wrapped value's own real-number + conversion, which is the actual production fix. + """ + + def __init__(self, value): + self._value = value + + def __array__(self, dtype=None): + return np.array([self._value, 0.0], dtype=dtype) + + def __float__(self): + return float(self._value) + + +def test_run_obliqua_returns_a_genuine_scalar_when_omega_is_julia_boxed(monkeypatch): + """Regression test for a production crash: ``run_orbit`` logs the + returned ``Imk`` via ``'%.1e' % Imk``, which raises "only + 0-dimensional arrays can be converted to Python scalars" if + ``run_obliqua``'s return expression ends up array-shaped. + ``omega`` is passed through ``_jlsca_prec`` (a real Julia + high-precision scalar in production, identity in the other tests + here via ``_patch_identity_conversions`` -- which does NOT exercise + this bug). Using ``_FakeJuliaPrecisionScalar`` in place of the + identity patch reproduces the exact failure mode: a regression that + reintroduced ``np.sign(omega)`` (the boxed value) instead of + ``np.sign(float(omega))`` would make this return a shape-(2,) array. + """ + from proteus.orbit import obliqua as obliqua_mod + + monkeypatch.setattr(obliqua_mod, '_jlarr', np.asarray) + monkeypatch.setattr(obliqua_mod, '_jlsca_float', lambda s: s) + monkeypatch.setattr(obliqua_mod, '_jlsca_prec', _FakeJuliaPrecisionScalar) + monkeypatch.setattr(obliqua_mod, 'to_julia_dict', lambda cfg: cfg) + monkeypatch.setattr(obliqua_mod, 'sync_log_files', lambda outdir: []) + + interior_o = _make_interior_t(3) + cfg = _make_config(module='dummy', perturber='star', star_planet_model='sp1d') + hf_row = { + 'Time': 10.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.1, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + fake_jl = _make_fake_jl( + power_prf=np.array([0.0, 5e-7]), + power_blk=1.0, + nmk=[(2, 0, 1), (2, 2, 3)], + sigma=[1e-6, 2e-6], + lnk=[0.01 - 0.02j, 0.03 - 0.04j], + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + result = obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': '/unused', 'output': '/unused'}, + interior_o=interior_o, + tides_o=Tides_t(), + config=cfg, + ) + + # Discrimination: a shape-(2,) array would pass a bare truthiness/ + # non-None check but fail exactly like the production crash here. + assert np.ndim(result) == 0 + assert '%.1e' % result # must not raise TypeError, as it did in production + + # Value check: orbital_period > 0 => omega > 0 => sign is -1. + expected = -1.0 * np.mean(np.abs(np.imag([0.01 - 0.02j, 0.03 - 0.04j]))) + assert float(result) == pytest.approx(expected) + + +# --------------------------------------------------------------------------- +# import_obliqua. +# --------------------------------------------------------------------------- + + +def test_import_obliqua_activates_its_own_project_before_importing(monkeypatch): + """``import_obliqua`` must activate Obliqua's own cloned-and-instantiated + Julia project (``dirs['obliqua']``) before ``using Obliqua`` -- mirroring + ``atmos_clim.agni.activate_julia``'s ``Pkg.activate(dirs['agni'])`` + pattern, rather than relying on a separately juliapkg-managed + registration. A regression that dropped the activate call, used the + wrong dict key, or activated the wrong path would leave ``Obliqua`` + unresolvable (or resolve a stale/different clone) even though + ``get_obliqua.sh`` instantiated the right one. + """ + from proteus.orbit import obliqua as obliqua_mod + + fake_jl = MagicMock(name='jl') + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + dirs = {'obliqua': '/some/path/Obliqua', 'output': '/some/path/output'} + obliqua_mod.import_obliqua(dirs) + + fake_jl.Pkg.activate.assert_called_once_with('/some/path/Obliqua') + fake_jl.seval.assert_any_call('using Obliqua') + # Discrimination: activate must happen BEFORE `using Obliqua`, not + # after -- an ordering bug would still pass the two assertions above. + activate_index = fake_jl.mock_calls.index(call.Pkg.activate('/some/path/Obliqua')) + using_obliqua_index = fake_jl.mock_calls.index(call.seval('using Obliqua')) + assert activate_index < using_obliqua_index + + +# --------------------------------------------------------------------------- +# to_julia_dict: recursive conversion. +# --------------------------------------------------------------------------- + + +def test_to_julia_dict_recursively_converts_nested_dict_and_list(monkeypatch): + """``to_julia_dict`` recurses into nested dicts and lists, + converting every dict level via ``jl.Dict()`` while leaving + scalars untouched. Standing in the real Python ``dict`` for + ``jl.Dict`` makes the recursion observable directly: a regression + that stopped recursing into list elements, or that converted a + list itself into a Julia object instead of mapping over it, would + change the returned structure. + + ``to_julia_dict`` lives in ``proteus.utils.julia_common`` (shared + with lovepy.py); ``jl`` is patched there, not on ``obliqua_mod``, + for the same reason as the ``_jlarr``/``_jlsca_*`` tests below. + """ + from proteus.orbit import obliqua as obliqua_mod + + fake_jl = types.SimpleNamespace(Dict=dict) + monkeypatch.setattr('proteus.utils.julia_common.jl', fake_jl) + + nested = { + 'a': 1.0, + 'b': [1, {'c': 2.0}, 3], + 'd': {'e': {'f': 'leaf'}}, + } + out = obliqua_mod.to_julia_dict(nested) + + assert out == nested + # Discrimination: an empty-list edge case must round-trip to an + # empty list, not be dropped or replaced with None. + assert obliqua_mod.to_julia_dict({'empty': []}) == {'empty': []} + + +# --------------------------------------------------------------------------- +# _jlarr / _jlsca_float / _jlsca_prec: julia type conversion contract. +# --------------------------------------------------------------------------- + + +def test_jlarr_flattens_and_converts_without_reordering(monkeypatch): + """``_jlarr`` flattens the numpy array and converts it via + ``juliacall.convert`` targeting ``jl.Array[jl.Obliqua.prec, 1]``. + + Pins the *current* implementation: despite the inline source + comment claiming the array is reversed ("Make copy of array, + reverse order..."), the implementation does not reverse element + order. An asymmetric input (strictly increasing, not a palindrome) + is used so this test would fail if reversal were silently added + or removed. + + ``_jlarr`` is bound (via ``make_julia_converters('Obliqua')``) from + ``proteus.utils.julia_common``, so ``jl``/``juliacall`` are patched + there, not on ``obliqua_mod`` -- the converter closures resolve + those names in the module they were defined in, not the caller's. + """ + from proteus.orbit import obliqua as obliqua_mod + + fake_juliacall = MagicMock(name='juliacall') + fake_juliacall.convert = MagicMock(return_value='converted_array') + fake_jl = MagicMock(name='jl') + fake_jl.Array = MagicMock() + fake_jl.Obliqua = MagicMock() + fake_jl.Obliqua.prec = 'prec_sentinel' + fake_jl.Array.__getitem__ = MagicMock(return_value='destination_type') + monkeypatch.setattr('proteus.utils.julia_common.juliacall', fake_juliacall) + monkeypatch.setattr('proteus.utils.julia_common.jl', fake_jl) + + arr = np.array([1.0, 2.0, 3.0]) # asymmetric: catches accidental reversal + out = obliqua_mod._jlarr(arr) + + fake_jl.Array.__getitem__.assert_called_once_with(('prec_sentinel', 1)) + fake_juliacall.convert.assert_called_once() + call_args, _ = fake_juliacall.convert.call_args + assert call_args[0] == 'destination_type' + np.testing.assert_array_equal(call_args[1], arr) + assert out == 'converted_array' + + +def test_jlsca_float_converts_to_julia_float64_type(monkeypatch): + """``_jlsca_float`` converts via + ``juliacall.convert(jl.Obliqua.Float64, sca)``. Pins the + destination-type argument so a regression that swapped in the + ``prec`` type (used by ``_jlsca_prec`` instead) would surface. + + See ``test_jlarr_flattens_and_converts_without_reordering`` for why + ``jl``/``juliacall`` are patched on ``proteus.utils.julia_common``. + """ + from proteus.orbit import obliqua as obliqua_mod + + fake_juliacall = MagicMock(name='juliacall') + fake_juliacall.convert = MagicMock(return_value='converted_float64') + fake_jl = MagicMock(name='jl') + fake_jl.Obliqua = MagicMock() + fake_jl.Obliqua.Float64 = 'float64_sentinel' + monkeypatch.setattr('proteus.utils.julia_common.juliacall', fake_juliacall) + monkeypatch.setattr('proteus.utils.julia_common.jl', fake_jl) + + out = obliqua_mod._jlsca_float(0.5) + fake_juliacall.convert.assert_called_once_with('float64_sentinel', 0.5) + assert out == 'converted_float64' + + +def test_jlsca_prec_converts_to_julia_prec_type(monkeypatch): + """``_jlsca_prec`` converts via + ``juliacall.convert(jl.Obliqua.prec, sca)`` -- a distinct + destination type from ``_jlsca_float``. Pinning both destination + sentinels separately discriminates a regression that merged or + swapped the two conversion helpers. + + See ``test_jlarr_flattens_and_converts_without_reordering`` for why + ``jl``/``juliacall`` are patched on ``proteus.utils.julia_common``. + """ + from proteus.orbit import obliqua as obliqua_mod + + fake_juliacall = MagicMock(name='juliacall') + fake_juliacall.convert = MagicMock(return_value='converted_prec') + fake_jl = MagicMock(name='jl') + fake_jl.Obliqua = MagicMock() + fake_jl.Obliqua.prec = 'prec_sentinel' + monkeypatch.setattr('proteus.utils.julia_common.juliacall', fake_juliacall) + monkeypatch.setattr('proteus.utils.julia_common.jl', fake_jl) + + out = obliqua_mod._jlsca_prec(0.5) + fake_juliacall.convert.assert_called_once_with('prec_sentinel', 0.5) + assert out == 'converted_prec' + + +# --------------------------------------------------------------------------- +# run_obliqua: perturber dispatch (star vs. satellite orbital state). +# --------------------------------------------------------------------------- + + +def test_run_obliqua_star_perturber_reads_star_orbital_state(monkeypatch, tmp_path): + """Under ``config.orbit.perturber == 'star'``, ``run_obliqua`` + reads the star-planet orbital state (``orbital_period``, + ``eccentricity``, ``semimajorax``, ``M_star``), not the satellite + fields. Discrimination: pin that ``omega`` derives from + ``orbital_period`` (not ``axial_period``, which feeds the + separate ``axial`` argument), and that ``M_pert`` is ``M_star``. + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + nlev_s = 3 + interior_o = _make_interior_t(nlev_s) + cfg = _make_config(module='dummy', perturber='star') + + hf_row = { + 'Time': 100.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.1, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + + power_prf = np.array([0.0, 5e-7]) + fake_jl = _make_fake_jl( + power_prf=power_prf, + power_blk=1.0, + nmk=[(2, 0, 1), (2, 2, 3)], + sigma=[1e-6, 2e-6], + lnk=[0.01 - 0.02j, 0.03 - 0.04j], + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + tides_o = Tides_t() + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + + fake_jl.Obliqua.run_tides.assert_called_once() + call_args = fake_jl.Obliqua.run_tides.call_args[0] + omega, axial, ecc, sma, m_pert = call_args[:5] + + assert omega == pytest.approx(2 * np.pi / hf_row['orbital_period'], rel=1e-12) + # Discrimination: omega must not have been derived from axial_period. + assert omega != pytest.approx(2 * np.pi / hf_row['axial_period'], rel=1e-6) + assert axial == pytest.approx(2 * np.pi / hf_row['axial_period'], rel=1e-12) + assert ecc == pytest.approx(0.1, rel=1e-12) + assert sma == pytest.approx(1.5e11, rel=1e-12) + assert m_pert == pytest.approx(2.0e30, rel=1e-12) + + +def test_run_obliqua_satellite_perturber_reads_satellite_orbital_state(monkeypatch, tmp_path): + """Under ``config.orbit.perturber == 'satellite'``, ``run_obliqua`` + reads the satellite-suffixed fields + (``orbital_period_sat``/``eccentricity_sat``/``semimajorax_sat``/ + ``M_sat``) instead of the star fields. Edge case: eccentricity is + exercised at the boundary value 0.0 (circular orbit). + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + nlev_s = 3 + interior_o = _make_interior_t(nlev_s) + cfg = _make_config(module='dummy', perturber='satellite') + + hf_row = { + 'Time': 50.0, + 'axial_period': 86400.0, + 'orbital_period_sat': 86400.0 * 27.3, + 'eccentricity_sat': 0.0, + 'semimajorax_sat': 3.84e8, + 'M_sat': 7.3e22, + } + + power_prf = np.array([0.0, 2e-8]) + fake_jl = _make_fake_jl( + power_prf=power_prf, + power_blk=1.0, + nmk=[(2, 0, 1)], + sigma=[1e-7], + lnk=[0.005 - 0.001j], + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + tides_o = Tides_t() + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + + call_args = fake_jl.Obliqua.run_tides.call_args[0] + omega, _axial, ecc, sma, m_pert = call_args[:5] + + assert ecc == pytest.approx(0.0, abs=1e-15) + assert omega == pytest.approx(2 * np.pi / hf_row['orbital_period_sat'], rel=1e-12) + assert sma == pytest.approx(3.84e8, rel=1e-12) + assert m_pert == pytest.approx(7.3e22, rel=1e-12) + + +# --------------------------------------------------------------------------- +# _padded_obliqua_k_range: pure-Python, no Julia boundary +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _fast_k_range_table(monkeypatch): + """Narrow, fast eccentricity grid for the module-global [kmin, kmax] + table (mirrors the fixture of the same name in + tests/orbit/test_hansen.py): the default build costs on the order of + a minute of wall time, far outside the unit tier's budget. + """ + from proteus.orbit import hansen as hansen_mod + + monkeypatch.setattr(hansen_mod, '_k_range_table', None) + hansen_mod.init_k_range_table(e_grid=np.array([0.0, 0.2, 0.4, 0.6, 0.8]), force=True) + + +def test_padded_obliqua_k_range_passes_through_unpadded_outside_the_zone(_fast_k_range_table): + """Outside the evection zone (``tides_o.evection_zone_active`` False), + the function must return ``config.orbit.obliqua.k_min``/``k_max`` + VERBATIM -- typically ``'none'`` -- at zero cost, not silently apply + padding. + """ + from proteus.orbit.obliqua import _padded_obliqua_k_range + + config = _make_config(module='aragog', perturber='satellite') + interior_o = types.SimpleNamespace(dt=100.0) + tides_o = Tides_t(evection_zone_active=False) + hf_row = {'Time': 500.0, 'eccentricity_sat': 0.30} + assert _padded_obliqua_k_range(hf_row, interior_o, tides_o, config) == ('none', 'none') + # No cursor should be written when the padding path never runs. + assert '_obliqua_prev_ecc' not in hf_row + + +def test_padded_obliqua_k_range_widens_when_zone_active_and_rate_observed(_fast_k_range_table): + """Inside the zone, with a prior eccentricity cursor already recorded + (simulating the second-and-later real call), the resulting window + must be at least as wide as the unpadded ``kmin_kmax_for_e(e_now)`` + window, and here strictly wider given the chosen rate/step-size + combination. Also checks the cursor is advanced for the next call. + """ + from proteus.orbit.hansen import kmin_kmax_for_e + from proteus.orbit.obliqua import _padded_obliqua_k_range + + config = _make_config(module='aragog', perturber='satellite') + interior_o = types.SimpleNamespace(dt=20.0) + tides_o = Tides_t(evection_zone_active=True) + hf_row = { + 'Time': 520.0, + 'eccentricity_sat': 0.20, + '_obliqua_prev_ecc': 0.10, + '_obliqua_prev_time': 500.0, + } + # de/dt = (0.20 - 0.10) / (520 - 500) = 5.0e-3 /yr + + unpadded = kmin_kmax_for_e(0.20) + k_min, k_max = _padded_obliqua_k_range(hf_row, interior_o, tides_o, config) + + assert k_max >= unpadded[1] + assert k_min <= unpadded[0] + # Discrimination: the padded window must be a genuinely different + # (strictly wider) bucket here, not merely the unpadded one again. + assert (k_min, k_max) != unpadded + + # Cursor advanced to THIS call's (e, Time) for the next call. + assert hf_row['_obliqua_prev_ecc'] == pytest.approx(0.20) + assert hf_row['_obliqua_prev_time'] == pytest.approx(520.0) + + +def test_padded_obliqua_k_range_treats_a_missing_cursor_as_zero_rate(_fast_k_range_table): + """The FIRST call while the zone is active has no prior + ``_obliqua_prev_ecc``/``_obliqua_prev_time`` cursor yet (``hf_row.get`` + returns ``None``), which must fall back to ``de_dt_yr=0.0`` -- the + unpadded window for the current eccentricity, not raise (a bare + subtraction against ``None`` would ``TypeError``) or silently widen as + if a real rate had been observed. The cursor must still be seeded + afterward so the SECOND call has something to difference against. + """ + from proteus.orbit.hansen import kmin_kmax_for_e + from proteus.orbit.obliqua import _padded_obliqua_k_range + + config = _make_config(module='aragog', perturber='satellite') + interior_o = types.SimpleNamespace(dt=20.0) + tides_o = Tides_t(evection_zone_active=True) + hf_row = {'Time': 500.0, 'eccentricity_sat': 0.20} # no _obliqua_prev_* keys + + unpadded = kmin_kmax_for_e(0.20) + result = _padded_obliqua_k_range(hf_row, interior_o, tides_o, config) + + # Discrimination: de_dt_yr=0 gives exactly the unpadded window. + assert result == unpadded + + # Cursor now seeded from this call, for the next one to difference against. + assert hf_row['_obliqua_prev_ecc'] == pytest.approx(0.20) + assert hf_row['_obliqua_prev_time'] == pytest.approx(500.0) + + +def test_padded_obliqua_k_range_never_narrows_past_an_explicit_user_override( + _fast_k_range_table, +): + """An explicit user-supplied ``k_min``/``k_max`` (an int, not + ``'none'``) must only ever be WIDENED by the padding, never narrowed + past what the user configured -- someone who deliberately asked for + extra headroom must not have it silently clawed back. + """ + from proteus.orbit.obliqua import _padded_obliqua_k_range + + config = _make_config(module='aragog', perturber='satellite') + config.orbit.obliqua.k_min = -500 + config.orbit.obliqua.k_max = 500 + interior_o = types.SimpleNamespace(dt=20.0) + tides_o = Tides_t(evection_zone_active=True) + hf_row = { + 'Time': 520.0, + 'eccentricity_sat': 0.20, + '_obliqua_prev_ecc': 0.10, + '_obliqua_prev_time': 500.0, + } + + k_min, k_max = _padded_obliqua_k_range(hf_row, interior_o, tides_o, config) + assert k_min == -500 + assert k_max == 500 + + +def test_padded_obliqua_k_range_disabled_by_zero_padding_factor(_fast_k_range_table): + """``evection_padding_factor=0`` must reduce to the unpadded + ``config.orbit.obliqua.k_min``/``k_max`` passthrough even while the + zone is active -- the opt-out switch documented on the field. + """ + from proteus.orbit.obliqua import _padded_obliqua_k_range + + config = _make_config(module='aragog', perturber='satellite') + config.orbit.obliqua.evection_padding_factor = 0.0 + interior_o = types.SimpleNamespace(dt=20.0) + tides_o = Tides_t(evection_zone_active=True) + hf_row = { + 'Time': 520.0, + 'eccentricity_sat': 0.20, + '_obliqua_prev_ecc': 0.10, + '_obliqua_prev_time': 500.0, + } + + assert _padded_obliqua_k_range(hf_row, interior_o, tides_o, config) == ('none', 'none') + + # Discrimination: the identical zone-active state with a positive + # padding factor DOES widen (config.orbit.obliqua.k_min/k_max is + # 'none' there too), so the passthrough above follows from + # padding_factor=0, not from this hf_row/config combination never + # triggering padding at all. + config.orbit.obliqua.evection_padding_factor = 2.0 + padded = _padded_obliqua_k_range(dict(hf_row), interior_o, tides_o, config) + assert padded != ('none', 'none') + + +def test_run_obliqua_rejects_an_unrecognized_perturber(monkeypatch, tmp_path): + """Neither 'star' nor 'satellite': must raise a clear ``ValueError`` + up front, not silently fall through and fail later with an + ``UnboundLocalError`` on ``omega``/``ecc``/``sma``/``M_pert`` + (which are only ever assigned inside the 'star'/'satellite' + branches). + + ``config._config.obliqua_requires_perturber`` already rejects + ``orbit.module = 'obliqua'`` with an unset ``orbit.perturber`` at + config-load time for any real, attrs-validated ``Config`` -- this + covers a direct/programmatic call that bypasses that validator + (the fake config here is a bare ``SimpleNamespace``, exactly such + a bypass). + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + interior_o = _make_interior_t(3) + cfg = _make_config(module='dummy', perturber=None) + hf_row = {'Time': 100.0, 'axial_period': 86400.0} + tides_o = Tides_t() + + with pytest.raises(ValueError, match='perturber') as excinfo: + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + # Discrimination: the message names the actual bad value received + # (None here), not a generic "invalid config" -- and the raise + # happens up front, before any tides_o.add(...) call downstream. + assert 'None' in str(excinfo.value) + assert tides_o.interactions == [] + + +# --------------------------------------------------------------------------- +# run_obliqua: interior-module branching (dummy / spider / aragog-like). +# --------------------------------------------------------------------------- + + +def test_run_obliqua_dummy_interior_writes_single_tide_from_second_profile_entry( + monkeypatch, tmp_path +): + """Under ``interior_energetics.module == 'dummy'``, the two-cell + profile hack writes ``interior_o.tides[0] = power_prf[1]`` + (not ``power_prf[0]``). The asymmetric mock profile below + discriminates an off-by-one index regression. + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + interior_o = _make_interior_t(nlev_s=1) + cfg = _make_config(module='dummy', perturber='star') + hf_row = { + 'Time': 0.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.05, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + + power_prf = np.array([1e-9, 7e-7]) # [0]=1e-9, [1]=7e-7: distinguishable + fake_jl = _make_fake_jl( + power_prf=power_prf, + power_blk=1.0, + nmk=[(2, 0, 1)], + sigma=[1e-6], + lnk=[0.01 - 0.02j], + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + tides_o = Tides_t() + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + + assert interior_o.tides[0] == pytest.approx(7e-7, rel=1e-12) + # Discrimination: not the first profile entry. + assert interior_o.tides[0] != pytest.approx(1e-9, rel=1e-6) + + +def test_run_obliqua_spider_interior_reverses_and_conserves_total_power(monkeypatch, tmp_path): + """Under ``interior_energetics.module == 'spider'``, arrays are + reversed on the way into Obliqua (so index 0 sits at the CMB) and + the returned profile is reversed again on write-back, mirroring + the LovePy SPIDER convention. + + Physics invariant: reversal is a permutation, so the total tidal + power dissipated (``sum(interior_o.tides)``) must equal + ``sum(power_prf)`` exactly -- a regression that instead dropped or + duplicated an entry during the flip would break this sum even + though it might still "look like" the right values individually. + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + nlev_s = 4 + interior_o = _make_interior_t(nlev_s) + cfg = _make_config(module='spider', perturber='star') + hf_row = { + 'Time': 0.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.1, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + + power_prf = np.array([1e-6, 2e-6, 3e-6, 4e-6]) + fake_jl = _make_fake_jl( + power_prf=power_prf, + power_blk=1.0, + nmk=[(2, 0, 1)], + sigma=[1e-6], + lnk=[0.02 - 0.03j], + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + tides_o = Tides_t() + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + + # Input side: rho/radius must have been reversed before being passed to + # run_tides (call_args[0][5] is rho, [0][6] is radius -- see the + # positional run_tides(...) call in obliqua.py), not just the output. + call_args = fake_jl.Obliqua.run_tides.call_args[0] + rho_passed = call_args[5] + np.testing.assert_allclose(rho_passed, interior_o.density[::-1], rtol=1e-12) + # Discrimination: input was not left in surface-first order. + assert not np.allclose(rho_passed, interior_o.density) + + np.testing.assert_allclose(interior_o.tides, power_prf[::-1], rtol=1e-12) + assert np.sum(interior_o.tides) == pytest.approx(np.sum(power_prf), rel=1e-12) + + +def test_run_obliqua_aragog_like_interior_writes_direct_profile(monkeypatch, tmp_path): + """Under a non-dummy, non-spider ``interior_energetics.module`` + (e.g. ``'aragog'``), the profile is written directly with no + reversal: ``interior_o.tides[:] = power_prf[:]``. + + This branch also computes ``power_blk / sum(mass)`` purely for a + ``log.debug`` line (see module docstring for the testability + gap); this test only confirms that division executes on a + realistic non-zero mass array without raising, alongside the + direct (unflipped) tides write. + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + nlev_s = 4 + interior_o = _make_interior_t(nlev_s) + cfg = _make_config(module='aragog', perturber='star') + hf_row = { + 'Time': 0.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.1, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + + power_prf = np.array([1e-6, 2e-6, 3e-6, 4e-6]) + fake_jl = _make_fake_jl( + power_prf=power_prf, + power_blk=1e4, + nmk=[(2, 0, 1)], + sigma=[1e-6], + lnk=[0.02 - 0.03j], + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + tides_o = Tides_t() + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + + np.testing.assert_allclose(interior_o.tides, power_prf, rtol=1e-12) + # Discrimination: unlike SPIDER, the profile is NOT reversed. + assert not np.allclose(interior_o.tides, power_prf[::-1]) + + +# --------------------------------------------------------------------------- +# run_obliqua: error handling. +# --------------------------------------------------------------------------- + + +def test_run_obliqua_julia_error_wrapped_into_runtime_error(monkeypatch, tmp_path): + """``juliacall.JuliaError`` raised from ``run_tides`` is caught and + re-raised as ``RuntimeError``. ``UpdateStatusfile`` is called with + status code 26 before the re-raise, matching the LovePy failure + contract. + """ + import juliacall as real_juliacall + + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + interior_o = _make_interior_t(nlev_s=3) + cfg = _make_config(module='dummy', perturber='star') + hf_row = { + 'Time': 0.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.1, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + + fake_jl = MagicMock(name='jl') + fake_jl.Obliqua.interior.get_permeability = MagicMock(return_value='perm') + fake_jl.Obliqua.interior.limit_porosity = MagicMock(return_value=('perm', 'phi')) + fake_jl.Obliqua.interior.get_drained_bulk = MagicMock(return_value='bulkd') + fake_jl.Obliqua.run_tides = MagicMock( + side_effect=real_juliacall.JuliaError('mock Obliqua crash') + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + updates: list[tuple] = [] + monkeypatch.setattr( + obliqua_mod, + 'UpdateStatusfile', + lambda dirs, code: updates.append((dirs, code)), + ) + + tides_o = Tides_t() + with pytest.raises(RuntimeError, match=r'(?i)obliqua'): + obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + + assert len(updates) == 1 + assert updates[0][1] == 26 + + +# --------------------------------------------------------------------------- +# run_obliqua: storage into tides_o. +# --------------------------------------------------------------------------- + + +def test_run_obliqua_stores_love_spectrum_keyed_by_configured_perturber(monkeypatch, tmp_path): + """Results are stored in ``tides_o`` under + ``primary='planet', perturber=config.orbit.perturber``: the + ``nmk`` mode table (stacked to an integer array), the forcing + frequencies ``sigma``, and the complex Love numbers ``LNk``. The + return value is ``mean(imag(LNk))``, pinned with a sign + discrimination guard (the real part carries no dissipative + information under this convention). + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + interior_o = _make_interior_t(nlev_s=3) + cfg = _make_config(module='dummy', perturber='star') + hf_row = { + 'Time': 0.0, + 'axial_period': 86400.0, + 'orbital_period': 86400.0 * 365.0, + 'eccentricity': 0.1, + 'semimajorax': 1.5e11, + 'M_star': 2.0e30, + } + + nmk = [(2, 0, 1), (2, 2, 3)] + sigma = [1e-6, 2e-6] + lnk = np.array([0.01 - 0.02j, 0.03 - 0.06j]) + fake_jl = _make_fake_jl( + power_prf=np.array([0.0, 5e-7]), + power_blk=1.0, + nmk=nmk, + sigma=sigma, + lnk=lnk, + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + tides_o = Tides_t() + out = obliqua_mod.run_obliqua( + hf_row, + dirs={'output/data': str(tmp_path), 'output': str(tmp_path)}, + interior_o=interior_o, + tides_o=tides_o, + config=cfg, + ) + + storage = tides_o.get(primary='planet', perturber='star') + np.testing.assert_array_equal(storage.nmk, np.array(nmk, dtype=int)) + np.testing.assert_allclose(storage.sigma, sigma, rtol=1e-12) + np.testing.assert_allclose(storage.LNk, lnk, rtol=1e-12) + + expected = np.mean(np.imag(lnk)) + assert out == pytest.approx(expected, rel=1e-12) + # Sign discrimination: this Im(k2) convention is negative here; a + # regression that returned mean(real(LNk)) instead would be + # positive and would fail this sign check. + assert out < 0.0 + + +# --------------------------------------------------------------------------- +# lookup_from_interior: JSON IC -> netCDF lookup table. +# --------------------------------------------------------------------------- + + +def _write_interior_json(path, density, radius, visc, shear, bulk, phi): + payload = { + 'omega': 1e-6, + 'axial': 7e-5, + 'ecc': 0.05, + 'sma': 3.8e8, + 'S_mass': 7.3e22, + 'density': density, + 'radius': radius, + 'visc': visc, + 'shear': shear, + 'bulk': bulk, + 'phi': phi, + } + with open(path, 'w') as f: + json.dump(payload, f) + + +def test_lookup_from_interior_raises_when_love_number_path_unset(tmp_path): + """``lookup_from_interior`` raises ``ValueError`` immediately when + ``config.orbit.satellite.love_number_sat`` is unset, before + touching any file or the Julia boundary. Edge case: empty string + is treated the same as ``None`` (both are falsy). Also pins that + the status file is updated (Tides/orbit-model error code) before + the raise, matching the JuliaError failure contract. + """ + from proteus.orbit import obliqua as obliqua_mod + + dirs = {'output/data': str(tmp_path), 'output': str(tmp_path)} + + cfg = _make_satellite_config(love_number_sat=None) + with pytest.raises(ValueError, match=r'love_number_sat'): + obliqua_mod.lookup_from_interior(dirs=dirs, config=cfg) + assert (tmp_path / 'status').read_text().splitlines()[0] == '26' + + cfg_empty = _make_satellite_config(love_number_sat='') + with pytest.raises(ValueError, match=r'love_number_sat'): + obliqua_mod.lookup_from_interior(dirs=dirs, config=cfg_empty) + + +def test_lookup_from_interior_splits_core_density_from_mantle_profile(monkeypatch, tmp_path): + """The first entry of the JSON ``density`` array is extracted as + ``core_density`` (used only in ``cfg['struct']['core_density']``) + and excluded from ``rho`` -- the array actually passed to + ``run_tides`` covers the mantle only. An asymmetric density + profile discriminates a regression that passed the full array + (including the core) as ``rho``, or dropped the wrong end. + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + json_path = tmp_path / 'interior.json' + _write_interior_json( + json_path, + density=[8000.0, 4000.0, 4200.0, 4500.0], # [0]=core, [1:]=mantle + radius=[3.0e6, 3.8e6, 4.6e6, 5.4e6], + visc=[1e20, 1e19, 1e18], + shear=[6e10, 5e10, 4e10], + bulk=[2e11, 1.9e11, 1.8e11], + phi=[0.0, 0.0, 0.05], + ) + cfg = _make_satellite_config(love_number_sat=str(json_path)) + + fake_jl = _make_fake_jl( + power_prf=np.array([1e-6]), + power_blk=1.0, + nmk=[(2, 0, 1)], + sigma=[1e-6], + lnk=[0.01 - 0.02j], + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + obliqua_mod.lookup_from_interior(dirs={'output/data': str(tmp_path)}, config=cfg) + + call_args = fake_jl.Obliqua.run_tides.call_args[0] + rho_passed, cfg_passed = call_args[5], call_args[13] + + np.testing.assert_allclose(rho_passed, [4000.0, 4200.0, 4500.0], rtol=1e-12) + # Discrimination: the core entry must not leak into rho. + assert not np.any(np.isclose(rho_passed, 8000.0)) + assert cfg_passed['struct']['core_density'] == pytest.approx(8000.0, rel=1e-12) + + +def test_lookup_from_interior_writes_netcdf_matching_run_tides_output(monkeypatch, tmp_path): + """The written ``sat_tides.nc`` lookup file exactly reproduces + the ``(nmk, sigma, LNk)`` triple returned by ``run_tides`` -- + a round trip through real netCDF I/O (not mocked), pinned against + an asymmetric two-mode result so a column swap (e.g. writing ``m`` + into the ``k`` variable) would be caught. + """ + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + json_path = tmp_path / 'interior.json' + _write_interior_json( + json_path, + density=[8000.0, 4000.0], + radius=[3.0e6, 4.0e6], + visc=[1e20], + shear=[6e10], + bulk=[2e11], + phi=[0.0], + ) + cfg = _make_satellite_config(love_number_sat=str(json_path)) + + nmk = [(2, 0, 1), (2, 2, 3)] + sigma = [1e-6, 2e-6] + lnk = [0.01 - 0.02j, 0.03 - 0.04j] + fake_jl = _make_fake_jl( + power_prf=np.array([1e-6]), power_blk=1.0, nmk=nmk, sigma=sigma, lnk=lnk + ) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + obliqua_mod.lookup_from_interior(dirs={'output/data': str(tmp_path)}, config=cfg) + + out = obliqua_mod.read_ncdf(str(tmp_path / 'sat_tides.nc')) + np.testing.assert_array_equal(out['n'], [2, 2]) + np.testing.assert_array_equal(out['m'], [0, 2]) + np.testing.assert_array_equal(out['k'], [1, 3]) + np.testing.assert_allclose(out['sigma'], sigma, rtol=1e-12) + np.testing.assert_allclose(out['LNk_real'], np.real(lnk), rtol=1e-12) + np.testing.assert_allclose(out['LNk_imag'], np.imag(lnk), rtol=1e-12) + + +def test_lookup_from_interior_julia_error_wrapped_into_runtime_error(monkeypatch, tmp_path): + """``juliacall.JuliaError`` raised from ``run_tides`` during + lookup-table generation is caught and re-raised as + ``RuntimeError``, with ``UpdateStatusfile`` called with code 26, + matching the ``run_obliqua`` failure contract. + """ + import juliacall as real_juliacall + + from proteus.orbit import obliqua as obliqua_mod + + _patch_identity_conversions(monkeypatch, obliqua_mod) + + json_path = tmp_path / 'interior.json' + _write_interior_json( + json_path, + density=[8000.0, 4000.0], + radius=[3.0e6, 4.0e6], + visc=[1e20], + shear=[6e10], + bulk=[2e11], + phi=[0.0], + ) + cfg = _make_satellite_config(love_number_sat=str(json_path)) + + fake_jl = MagicMock(name='jl') + fake_jl.Obliqua.interior.get_permeability = MagicMock(return_value='perm') + fake_jl.Obliqua.interior.limit_porosity = MagicMock(return_value=('perm', 'phi')) + fake_jl.Obliqua.interior.get_drained_bulk = MagicMock(return_value='bulkd') + fake_jl.Obliqua.run_tides = MagicMock(side_effect=real_juliacall.JuliaError('mock crash')) + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + updates: list[tuple] = [] + monkeypatch.setattr( + obliqua_mod, 'UpdateStatusfile', lambda dirs, code: updates.append((dirs, code)) + ) + + with pytest.raises(RuntimeError, match=r'(?i)obliqua'): + obliqua_mod.lookup_from_interior(dirs={'output/data': str(tmp_path)}, config=cfg) + + assert len(updates) == 1 + assert updates[0][1] == 26 + + +# --------------------------------------------------------------------------- +# LN_from_lookup: pure NumPy/SciPy post-processing, no Julia boundary. +# --------------------------------------------------------------------------- + + +def _default_lookup_table(): + """Degree-2 lookup table used by most ``LN_from_lookup`` tests: + three positive forcing frequencies with distinct, asymmetric + complex Love numbers so interpolation/symmetry bugs are visible. + """ + nmk_lookup = [(2, 0, 1), (2, 0, 2), (2, 0, 3)] + sigma_lookup = [1e-6, 2e-6, 3e-6] + lnk_lookup = [0.01 - 0.02j, 0.02 - 0.03j, 0.03 - 0.05j] + return nmk_lookup, sigma_lookup, lnk_lookup + + +@pytest.mark.physics_invariant +def test_ln_from_lookup_interpolates_exact_value_at_lookup_node(): + """At a forcing frequency that lands exactly on a tabulated + ``sigma`` node, linear interpolation must reproduce that node's + Love number exactly (up to float rounding). ``m=2, k=0`` with + ``axial_freq_s = 1e-6`` puts ``sigma_s`` exactly on the table's + second node. + """ + from proteus.orbit import obliqua as obliqua_mod + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(2, 2, 0)]) + nmk_lookup, sigma_lookup, lnk_lookup = _default_lookup_table() + _seed_satellite_dict_cache(tides_o, nmk_lookup, sigma_lookup, lnk_lookup) + + hf_row = { + 'axial_period_sat': 2 * np.pi / 1e-6, + 'orbital_period_sat': 86400.0 * 27.3, + } + cfg = _make_satellite_config(love_number_sat='unused.nc') + + obliqua_mod.LN_from_lookup(hf_row, dirs={}, tides_o=tides_o, config=cfg) + + storage = tides_o.get(primary='satellite', perturber='planet') + assert storage.LNk[0] == pytest.approx(0.02 - 0.03j, rel=1e-9) + # Discrimination: not a neighboring node's value. + assert abs(storage.LNk[0] - (0.01 - 0.02j)) > 1e-4 + assert abs(storage.LNk[0] - (0.03 - 0.05j)) > 1e-4 + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_ln_from_lookup_enforces_love_number_reality_symmetry(): + """Physical invariant: the tidal Love number is the frequency + response of a real-valued physical system, so it must satisfy + the reality/symmetry condition ``LNk(-sigma) == conj(LNk(sigma))``. + Evaluating at ``m=-2, k=0`` (sigma_s = -2e-6, the negative of the + node used in the previous test) must return the complex conjugate + of that node's Love number, not the same value or its negative. + """ + from proteus.orbit import obliqua as obliqua_mod + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(2, -2, 0)]) + nmk_lookup, sigma_lookup, lnk_lookup = _default_lookup_table() + _seed_satellite_dict_cache(tides_o, nmk_lookup, sigma_lookup, lnk_lookup) + + hf_row = { + 'axial_period_sat': 2 * np.pi / 1e-6, + 'orbital_period_sat': 86400.0 * 27.3, + } + cfg = _make_satellite_config(love_number_sat='unused.nc') + + obliqua_mod.LN_from_lookup(hf_row, dirs={}, tides_o=tides_o, config=cfg) + + storage = tides_o.get(primary='satellite', perturber='planet') + expected = np.conj(0.02 - 0.03j) + assert storage.LNk[0] == pytest.approx(expected, rel=1e-9) + # Discrimination: not the un-conjugated value (sign of imaginary + # part flipped relative to a same-sigma, no-symmetry bug). + assert storage.LNk[0].imag > 0.0 + + +def test_ln_from_lookup_raises_for_degree_missing_from_lookup_table(tmp_path): + """A planet-side mode at a tidal degree absent from the lookup + table raises ``ValueError`` naming the missing degree, rather + than silently returning zero or extrapolating across degrees + (Love numbers are not comparable across different ``n``). Also + pins that the status file is updated before the raise. + """ + from proteus.orbit import obliqua as obliqua_mod + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(3, 2, 0)]) # degree 3: not in the lookup + nmk_lookup, sigma_lookup, lnk_lookup = _default_lookup_table() # degree 2 only + _seed_satellite_dict_cache(tides_o, nmk_lookup, sigma_lookup, lnk_lookup) + + hf_row = {'axial_period_sat': 2 * np.pi / 1e-6, 'orbital_period_sat': 86400.0 * 27.3} + cfg = _make_satellite_config(love_number_sat='unused.nc') + + with pytest.raises(ValueError, match=r'degree n = 3'): + obliqua_mod.LN_from_lookup( + hf_row, dirs={'output': str(tmp_path)}, tides_o=tides_o, config=cfg + ) + assert (tmp_path / 'status').read_text().splitlines()[0] == '26' + + +def test_ln_from_lookup_zeroes_only_the_negative_m_and_k_modes(): + """The ``(m < 0) & (k < 0)`` convention zeroes exactly the modes + with both indices negative; a sibling mode with the same degree + but non-negative ``k`` in the same call is interpolated normally, + discriminating a regression that zeroed every mode (or none). + """ + from proteus.orbit import obliqua as obliqua_mod + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(2, -1, -3), (2, 2, 0)]) + nmk_lookup, sigma_lookup, lnk_lookup = _default_lookup_table() + _seed_satellite_dict_cache(tides_o, nmk_lookup, sigma_lookup, lnk_lookup) + + hf_row = { + 'axial_period_sat': 2 * np.pi / 1e-6, + 'orbital_period_sat': 86400.0 * 27.3, + } + cfg = _make_satellite_config(love_number_sat='unused.nc') + + obliqua_mod.LN_from_lookup(hf_row, dirs={}, tides_o=tides_o, config=cfg) + + storage = tides_o.get(primary='satellite', perturber='planet') + assert storage.LNk[0] == pytest.approx(0.0 + 0.0j, abs=1e-15) + # The sibling mode (m=2, k=0) lands on the same exact node as in + # the first interpolation test and must NOT be zeroed. + assert storage.LNk[1] == pytest.approx(0.02 - 0.03j, rel=1e-9) + + +@pytest.mark.physics_invariant +def test_ln_from_lookup_forcing_frequency_matches_m_axial_minus_k_orbital(): + """``sigma_s = m * axial_freq_s - k * orbital_freq_s``, pinned + with sign and column-order discrimination guards: a formula that + swapped ``m``/``k`` or used ``+`` instead of ``-`` would land on a + different value than the one asserted here. + """ + from proteus.orbit import obliqua as obliqua_mod + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(2, 3, 5)]) + nmk_lookup, sigma_lookup, lnk_lookup = _default_lookup_table() + _seed_satellite_dict_cache(tides_o, nmk_lookup, sigma_lookup, lnk_lookup) + + axial_freq_s = 1e-6 + orbit_freq_s = 4e-7 + hf_row = { + 'axial_period_sat': 2 * np.pi / axial_freq_s, + 'orbital_period_sat': 2 * np.pi / orbit_freq_s, + } + cfg = _make_satellite_config(love_number_sat='unused.nc') + + obliqua_mod.LN_from_lookup(hf_row, dirs={}, tides_o=tides_o, config=cfg) + + storage = tides_o.get(primary='satellite', perturber='planet') + expected = 3 * axial_freq_s - 5 * orbit_freq_s # = 1e-6 + assert storage.sigma[0] == pytest.approx(expected, rel=1e-12) + # Discrimination: m/k swapped (5*axial - 3*orbit = 3.8e-6). + assert storage.sigma[0] != pytest.approx(5 * axial_freq_s - 3 * orbit_freq_s, rel=1e-6) + # Discrimination: sign flipped to '+' (3*axial + 5*orbit = 5e-6). + assert storage.sigma[0] != pytest.approx(3 * axial_freq_s + 5 * orbit_freq_s, rel=1e-6) + + +def test_ln_from_lookup_generates_lookup_once_from_json_path_and_caches_it( + monkeypatch, tmp_path +): + """When ``love_number_sat`` points at a ``.json`` IC file and no + lookup is cached yet, ``LN_from_lookup`` calls + ``lookup_from_interior`` once to generate ``sat_tides.nc``, then + caches the loaded table under ``('satellite_dict', 'planet')``. A + second call on the same ``tides_o`` must reuse the cache rather + than regenerating it. + """ + from proteus.orbit import obliqua as obliqua_mod + + nmk_lookup, sigma_lookup, lnk_lookup = _default_lookup_table() + nc_path = tmp_path / 'sat_tides.nc' + + calls: list[tuple] = [] + + def fake_lookup_from_interior(dirs, config): + calls.append((dirs, config)) + _write_lookup_netcdf(nc_path, nmk_lookup, sigma_lookup, lnk_lookup) + + monkeypatch.setattr(obliqua_mod, 'lookup_from_interior', fake_lookup_from_interior) + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(2, 2, 0)]) + cfg = _make_satellite_config(love_number_sat=str(tmp_path / 'interior_source.json')) + hf_row = {'axial_period_sat': 2 * np.pi / 1e-6, 'orbital_period_sat': 86400.0 * 27.3} + dirs = {'output/data': str(tmp_path)} + + obliqua_mod.LN_from_lookup(hf_row, dirs=dirs, tides_o=tides_o, config=cfg) + assert len(calls) == 1 + storage = tides_o.get(primary='satellite', perturber='planet') + assert storage.LNk[0] == pytest.approx(0.02 - 0.03j, rel=1e-9) + + # Second call: cache already populated, must not regenerate. + _seed_planet_modes(tides_o, [(2, 2, 0)]) + obliqua_mod.LN_from_lookup(hf_row, dirs=dirs, tides_o=tides_o, config=cfg) + assert len(calls) == 1 + + +def test_ln_from_lookup_loads_nc_path_directly_without_regenerating(monkeypatch, tmp_path): + """When ``love_number_sat`` already points at a ``.nc`` lookup + file, ``LN_from_lookup`` must load it directly and must NOT call + ``lookup_from_interior`` at all (that branch is a no-op ``pass`` + in the source; regenerating on an ``.nc`` path would be wasted + Julia work at best, or silently overwrite a hand-provided table). + """ + from proteus.orbit import obliqua as obliqua_mod + + def fail_if_called(dirs, config): + raise AssertionError('lookup_from_interior must not be called for a .nc path') + + monkeypatch.setattr(obliqua_mod, 'lookup_from_interior', fail_if_called) + + nc_path = tmp_path / 'provided_lookup.nc' + nmk_lookup, sigma_lookup, lnk_lookup = _default_lookup_table() + _write_lookup_netcdf(nc_path, nmk_lookup, sigma_lookup, lnk_lookup) + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(2, 2, 0)]) + cfg = _make_satellite_config(love_number_sat=str(nc_path)) + hf_row = {'axial_period_sat': 2 * np.pi / 1e-6, 'orbital_period_sat': 86400.0 * 27.3} + + obliqua_mod.LN_from_lookup(hf_row, dirs={}, tides_o=tides_o, config=cfg) + + storage = tides_o.get(primary='satellite', perturber='planet') + assert storage.LNk[0] == pytest.approx(0.02 - 0.03j, rel=1e-9) + + +def test_ln_from_lookup_loads_an_unrecognized_extension_path_unchanged(monkeypatch, tmp_path): + """When ``love_number_sat`` ends in neither ``.nc`` nor ``.json``, + ``LN_from_lookup`` must fall through both branches unchanged and + still pass the ORIGINAL path straight to ``add_from_file`` (not + regenerate via ``lookup_from_interior``, and not raise) -- the same + contract as the ``.nc`` case above, just reached via neither + explicit branch. + """ + from proteus.orbit import obliqua as obliqua_mod + + def fail_if_called(dirs, config): + raise AssertionError('lookup_from_interior must not be called for this path') + + monkeypatch.setattr(obliqua_mod, 'lookup_from_interior', fail_if_called) + + # Real netCDF content behind an unrecognized extension: the fallthrough + # must pass this exact path through to add_from_file unmodified. + odd_path = tmp_path / 'provided_lookup.dat' + nmk_lookup, sigma_lookup, lnk_lookup = _default_lookup_table() + _write_lookup_netcdf(odd_path, nmk_lookup, sigma_lookup, lnk_lookup) + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(2, 2, 0)]) + cfg = _make_satellite_config(love_number_sat=str(odd_path)) + hf_row = {'axial_period_sat': 2 * np.pi / 1e-6, 'orbital_period_sat': 86400.0 * 27.3} + + obliqua_mod.LN_from_lookup(hf_row, dirs={}, tides_o=tides_o, config=cfg) + + # Discrimination: the lookup entry must have actually been registered + # (add_from_file really ran on the unchanged path) -- a broken + # fallthrough that silently skipped loading entirely would still + # leave this test looking like it "did nothing wrong" without this + # check, since no exception would fire either way. + lookup = tides_o.get(primary='satellite_dict', perturber='planet') + assert lookup.nmk.shape[0] == len(sigma_lookup) + + storage = tides_o.get(primary='satellite', perturber='planet') + assert storage.LNk[0] == pytest.approx(0.02 - 0.03j, rel=1e-9) + + +def test_ln_from_lookup_raises_when_path_unset_and_no_cache(tmp_path): + """With no cached lookup table and no ``love_number_sat`` path, + ``LN_from_lookup`` raises ``ValueError`` rather than silently + returning an empty or default Love-number spectrum. Also pins + that the status file is updated before the raise. + """ + from proteus.orbit import obliqua as obliqua_mod + + tides_o = Tides_t() + _seed_planet_modes(tides_o, [(2, 2, 0)]) + cfg = _make_satellite_config(love_number_sat=None) + hf_row = {'axial_period_sat': 2 * np.pi / 1e-6, 'orbital_period_sat': 86400.0 * 27.3} + + with pytest.raises(ValueError, match=r'love_number_sat'): + obliqua_mod.LN_from_lookup( + hf_row, dirs={'output': str(tmp_path)}, tides_o=tides_o, config=cfg + ) + assert (tmp_path / 'status').read_text().splitlines()[0] == '26' + + +# --------------------------------------------------------------------------- +# read_ncdf / read_ncdfs. +# --------------------------------------------------------------------------- + + +def test_read_ncdf_returns_all_variables_as_a_dict(tmp_path): + """``read_ncdf`` returns every variable in the file as a dict + entry, keyed by variable name, values matching exactly. Uses + asymmetric non-repeating values so a column/key mixup is visible. + """ + from proteus.orbit import obliqua as obliqua_mod + + nc_path = tmp_path / 'lookup.nc' + nmk = [(2, 0, 1), (3, 1, 2)] + sigma = [1e-6, 5e-6] + lnk = [0.01 - 0.02j, 0.07 - 0.11j] + _write_lookup_netcdf(nc_path, nmk, sigma, lnk) + + out = obliqua_mod.read_ncdf(str(nc_path)) + + assert set(out.keys()) == {'n', 'm', 'k', 'sigma', 'LNk_real', 'LNk_imag'} + np.testing.assert_array_equal(out['n'], [2, 3]) + np.testing.assert_array_equal(out['m'], [0, 1]) + np.testing.assert_array_equal(out['k'], [1, 2]) + np.testing.assert_allclose(out['sigma'], sigma, rtol=1e-12) + np.testing.assert_allclose(out['LNk_real'], np.real(lnk), rtol=1e-12) + np.testing.assert_allclose(out['LNk_imag'], np.imag(lnk), rtol=1e-12) + + +def test_read_ncdfs_orders_output_by_requested_times_not_filesystem_order(tmp_path): + """``read_ncdfs`` returns files in the order given by ``times``, + not filesystem/lexical-name order. ``times=[2, 10]`` is chosen so + lexical filename order ('10_obliqua.nc' < '2_obliqua.nc', since + '1' < '2') is the *opposite* of the requested numeric order: a + regression that globbed and sorted filenames as strings instead + of indexing by the given ``times`` list would return the t=10 + entry first and the t=2 entry second -- the reverse of what this + test asserts. + """ + from proteus.orbit import obliqua as obliqua_mod + + data_dir = tmp_path / 'data' + data_dir.mkdir() + _write_lookup_netcdf(data_dir / '2_obliqua.nc', [(2, 0, 1)], [1e-6], [0.01 - 0.02j]) + _write_lookup_netcdf(data_dir / '10_obliqua.nc', [(2, 0, 1)], [1e-6], [0.09 - 0.09j]) + + out = obliqua_mod.read_ncdfs(str(tmp_path), times=[2, 10]) + + assert len(out) == 2 + np.testing.assert_allclose(out[0]['LNk_real'], [0.01], rtol=1e-12) # t=2 + np.testing.assert_allclose(out[1]['LNk_real'], [0.09], rtol=1e-12) # t=10 + + +# --------------------------------------------------------------------------- +# setup_logging. +# --------------------------------------------------------------------------- + + +def test_setup_logging_calls_jl_with_joined_path_and_verbosity(monkeypatch, tmp_path): + """``setup_logging`` calls ``jl.Obliqua.setup_logging`` with the + joined ``dirs['output']``/``Obliqua_LOGFILE_NAME`` path and the + verbosity value passed straight through. A non-default verbosity + (2) discriminates a regression that hardcoded a default instead + of forwarding the argument. + """ + from proteus.orbit import obliqua as obliqua_mod + + fake_jl = MagicMock(name='jl') + monkeypatch.setattr(obliqua_mod, 'jl', fake_jl) + + obliqua_mod.setup_logging(dirs={'output': str(tmp_path)}, verbosity=2) + + expected_path = os.path.join(str(tmp_path), obliqua_mod.Obliqua_LOGFILE_NAME) + fake_jl.Obliqua.setup_logging.assert_called_once_with(expected_path, 2) + + +# --------------------------------------------------------------------------- +# sync_log_files. +# --------------------------------------------------------------------------- + + +def test_sync_log_files_returns_empty_list_when_obliqua_logfile_missing(tmp_path): + """When the Obliqua logfile does not exist yet (e.g. before the + first tidal-heating call of a run), ``sync_log_files`` catches the + resulting ``OSError`` and returns an empty list rather than + raising, and does not touch the destination PROTEUS logfile. + """ + from proteus.orbit import obliqua as obliqua_mod + + proteus_log = tmp_path / 'proteus_00.log' + proteus_log.write_text('pre-existing content\n') + + assert obliqua_mod.sync_log_files(str(tmp_path)) == [] + # Discrimination: the early OSError return must not partially + # execute the copy (e.g. opening the destination in append mode + # before the source read is attempted). + assert proteus_log.read_text() == 'pre-existing content\n' + + +def test_sync_log_files_copies_cleaned_text_but_returns_uncleaned_first_line(tmp_path): + """Pins the observed (not necessarily intended) contract: the + on-disk PROTEUS logfile receives the first line with its leading + NULL-character prefix stripped (any text before the first ``[`` + is dropped), but the list this function *returns* is the + original, uncleaned lines -- see the module docstring's + "Known testability gap" note. This test documents that mismatch + rather than silently assuming the return value is safe to scan + for failure markers. + """ + from proteus.orbit import obliqua as obliqua_mod + + (tmp_path / 'proteus_00.log').write_text('') # so GetCurrentLogfileIndex resolves to 0 + obliqua_log = tmp_path / obliqua_mod.Obliqua_LOGFILE_NAME + raw_first_line = '\x00\x00[2024-01-01] line one\n' + obliqua_log.write_text(raw_first_line + 'line two\n') + + out = obliqua_mod.sync_log_files(str(tmp_path)) + + proteus_log_text = (tmp_path / 'proteus_00.log').read_text() + assert proteus_log_text == '[2024-01-01] line one\nline two\n' + # Obliqua's own logfile is cleared after the copy. + assert obliqua_log.read_text() == '' + # Pinned discrepancy: the returned lines still carry the raw, + # uncleaned first line, not the cleaned text written above. + assert out[0] == raw_first_line + assert out[1] == 'line two\n' + + +def test_sync_log_files_copies_first_line_unchanged_when_no_bracket_present(tmp_path): + """Edge case: if the first line contains no ``[`` at all, the + NULL-stripping branch is not taken and the line is copied + through unchanged (not truncated or dropped), and the return + value matches the same unchanged text (unlike the NULL-prefixed + case, there is no cleaning for the return value to omit). + """ + from proteus.orbit import obliqua as obliqua_mod + + (tmp_path / 'proteus_00.log').write_text('') + obliqua_log = tmp_path / obliqua_mod.Obliqua_LOGFILE_NAME + obliqua_log.write_text('no bracket on this line\n') + + out = obliqua_mod.sync_log_files(str(tmp_path)) + + assert (tmp_path / 'proteus_00.log').read_text() == 'no bracket on this line\n' + assert out == ['no bracket on this line\n'] + # Obliqua's own logfile is cleared after the copy, same as the + # bracket-present case. + assert obliqua_log.read_text() == '' diff --git a/tests/orbit/test_orbit.py b/tests/orbit/test_orbit.py index 2945a7e75..03c5e1edb 100644 --- a/tests/orbit/test_orbit.py +++ b/tests/orbit/test_orbit.py @@ -1,183 +1,833 @@ -"""Unit tests for the dummy orbit module.""" +"""Unit tests for the star-planet tidal orbit evolution module +(``proteus.orbit.orbit``). + +``sp0d``'s ODE right-hand sides (the Driscoll & Barnes 2015 ``de/dt`` +and ``da/dt``) are private closures nested inside ``sp0d`` -- kept +that way deliberately (each orbital model has its own, differently +shaped right-hand side, so hoisting them to module scope would +require per-model name prefixes purely to satisfy testing, at the +cost of the module's readability). They are therefore tested here +as a black box, through the public ``sp0d(hf_row, dt)`` entry point: +calling ``sp0d`` with a very small ``dt`` and finite-differencing the +resulting change in ``semimajorax``/``eccentricity`` recovers the +instantaneous right-hand side to high precision (``solve_ivp``'s +default adaptive RK45 tracks the true derivative closely over a +sub-second time span; empirically, relative error is ~1e-4 at +``dt_yr=1e-6``), which is tight enough to reproduce the same +exponent- and sign-discrimination guards a direct closure call would +give. + +Also exercises the public ``sp0d`` integrator over realistic step +sizes, and ``evolve_orbit_star``'s dispatch by +``config.orbit.star_planet_model``. + +Anti-happy-path coverage (sp0d): + +- The eccentricity derivative is linear in ``e`` and vanishes at + ``e=0`` so the zero-eccentricity orbit is a fixed point. +- The semi-major axis derivative satisfies the kinematic relation + ``da_dt = 2 a e * de_dt`` and is identically zero on circular + orbits. +- Discriminating numeric values pin the exponents (``a**6.5``, + ``Rpl**5``) so that a bugged ``a**5`` or ``Rpl**4`` is caught. +- Adversarial ``hf_row`` inputs (zero ``Imk2``, near-unity ``e``) + are exercised to confirm ``sp0d`` does not crash or return + non-finite results. +- ``evolve_orbit_star`` dispatch is exercised both for the + recognized ``'sp0d'`` model and for an unrecognized model (no-op, + the current source has no ``else`` branch). + +``sp1d`` (planet spin + orbit, Hansen-coefficient-based) is tested the +same way, black-box through the public ``sp1d(hf_row, tides_o, dt)`` +entry point, since its ODE closures are also private and per-model. +Unlike ``sp0d``, ``sp1d`` DOES track planetary spin as a state +variable, and its ``domega_dt``/``da_dt``/``de_dt`` are constructed so +that total angular momentum (``C_planet*Omega_p + L_orbital``) is +actually conserved -- verified here as a genuine physics invariant, +not assumed. + +Anti-happy-path coverage (sp1d): + +- Zero dissipation (all Love numbers 0) is an exact fixed point of + ``(Omega_p, a, e)``. +- Total angular momentum is conserved to solver tolerance across a + real, non-trivial evolution step. +- Adversarial near-unity eccentricity does not produce NaN/inf, and + eccentricity is clamped at 0 rather than going negative. +- ``evolve_orbit_star`` dispatch to the ``'sp1d'`` branch is exercised + through the public entry point, including its ``get_C_planet`` call. + +Design note, not a bug: ``orbitals`` (the actual ODE right-hand side) +computes ``K_p0``/``K_p2`` from +``abs(Love_number.imag) * smooth_sign(forcing_frequency)`` rather than +using the Love number's raw signed imaginary part. This is necessary, +not incidental: the Love number is frozen for the whole +``solve_ivp`` call, but the actual forcing frequency (a function of +the solver's current trial ``Omega_p``/``a``/``e``) varies +continuously across the integration and can cross zero -- re-deriving +the dissipation sign from the CURRENT forcing frequency at every +evaluation is what keeps the response physically consistent (always +opposing relative motion) rather than risking an unphysical +sign/pumping artifact if the forcing frequency's sign flips mid-step. +``dE_dt`` (used only for a one-off debug-log power estimate at the +initial state, not for the dynamics) instead uses the raw signed +value directly (``-Love_number.imag``); that shortcut is reasonable +for a single-point snapshot at a known state, but would not be +correct if reused across a varying trajectory the way ``orbitals`` is. +A Love number carrying an unexpected raw sign therefore evolves the +orbit exactly the same way (by design), but can log a debug-only +tidal power estimate with the wrong sign. + +See also: +- docs/How-to/test_infrastructure.md +- docs/How-to/test_building.md +- docs/How-to/test_categorization.md +""" from __future__ import annotations from types import SimpleNamespace from typing import Any, cast +from unittest.mock import MagicMock import numpy as np import pytest -from proteus.interior_energetics.common import Interior_t -from proteus.orbit.dummy import run_dummy_orbit +import proteus.orbit.hansen as hansen_mod +from proteus.config._orbit import OrbitSolver +from proteus.orbit.common import Tides_t +from proteus.orbit.orbit import ( + _state_is_valid_star, + evolve_orbit_star, + sp0d, + sp1d, +) +from proteus.utils.constants import const_G, secs_per_year pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] +# Minimal config stand-in exposing only config.orbit.solver, for tests that +# call sp0d/sp1d directly (not through evolve_orbit_star's dispatch). +_SOLVER_CONFIG = cast(Any, SimpleNamespace(orbit=SimpleNamespace(solver=OrbitSolver()))) + +# Finite-difference step for probing sp0d's instantaneous ODE right-hand +# side. Must be small enough that even a ~32x-faster-evolving case (e.g. +# the Rpl**5 scaling test, Rpl doubled) still has negligible bias from the +# rate itself changing across the step: empirically, the naive +# ratio-of-differences estimate for that 32x case is only accurate to +# ~3% at dt_yr=1e-6, but ~1e-5 relative at dt_yr=1e-9. Still far above +# float cancellation noise at the SI-unit magnitudes used here. +_FD_DT_YR = 1e-9 + + +def _sp0d_instantaneous_rates(sma, ecc, Imk2, Mst, Rpl, Mpl, dt_yr=_FD_DT_YR): + """Probe sp0d's private ODE right-hand side via finite difference: + run one very short integration and divide the resulting change by + the elapsed time. Returns ``(da_dt, de_dt)`` in SI units (m/s, + 1/s). + """ + hf_row = { + 'semimajorax': sma, + 'eccentricity': ecc, + 'Imk2': Imk2, + 'M_star': Mst, + 'R_int': Rpl, + 'M_int': Mpl, + } + sp0d(hf_row, dt=dt_yr, config=_SOLVER_CONFIG) + dt_s = dt_yr * secs_per_year + da_dt = (hf_row['semimajorax'] - sma) / dt_s + de_dt = (hf_row['eccentricity'] - ecc) / dt_s + return da_dt, de_dt + + +# Reusable "unit" system: Mst = Rpl = Mpl = Imk2 = 1 (SI units), so +# algebra is easy to verify by hand. G is always the real physical +# const_G (sp0d has no way to receive a substitute), so every +# "expected" value below uses const_G explicitly rather than 1.0. +_UNIT_SYS = dict(Imk2=1.0, Mst=1.0, Rpl=1.0, Mpl=1.0) + + +# --------------------------------------------------------------------------- +# sp0d's de/dt: eccentricity derivative (probed via finite difference) +# --------------------------------------------------------------------------- + + +@pytest.mark.physics_invariant +def test_sp0d_de_dt_vanishes_at_zero_eccentricity(): + """A circular orbit (e=0) is a fixed point of the tidal evolution. + Holds for any semi-major axis: e=0 zeros the prefactor regardless + of a, so we exercise two values of a to confirm the fixed point is + a property of the eccentricity factor, not an accidental zero at + one a value.""" + _, de1 = _sp0d_instantaneous_rates(sma=1.0, ecc=0.0, **_UNIT_SYS) + assert de1 == pytest.approx(0.0, abs=1e-20) + # Limit-input invariant: a must drop out of the e=0 result; a + # regression that introduced a stray a-dependent additive term + # would only show at a != 1. + _, de5 = _sp0d_instantaneous_rates(sma=5.0, ecc=0.0, **_UNIT_SYS) + assert de5 == pytest.approx(0.0, abs=1e-20) + + +@pytest.mark.physics_invariant +def test_sp0d_de_dt_is_linear_in_eccentricity(): + """sp0d's de/dt scales linearly in ``e`` per Driscoll and Barnes + (2015) Eq. 16.""" + _, base = _sp0d_instantaneous_rates(sma=1.0, ecc=0.01, **_UNIT_SYS) + _, scaled = _sp0d_instantaneous_rates(sma=1.0, ecc=0.05, **_UNIT_SYS) + # 5x e -> 5x de/dt within finite-difference precision. + assert scaled == pytest.approx(5.0 * base, rel=1e-3) + # Linearity guard: a quadratic-in-e regression would give a ratio + # of 25, not 5. The absolute gap discriminates. + assert abs(scaled / base - 25.0) > 10.0 + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_sp0d_de_dt_matches_driscoll_barnes_2015_eq16(): + """Pin sp0d's de/dt against Driscoll and Barnes (2015) + Astrobiology 15, 739, DOI 10.1089/ast.2015.1325, Eq. 16. + (arXiv:1509.07452.) + + The paper writes the formula as + + de/dt = (21/2) Im(k2) M_*^(3/2) G^(1/2) R_p^5 / M_p * e / a^(13/2) -def _make_config(phi_tide: str, h_tide: float, imk2: float) -> Any: - dummy = SimpleNamespace(Phi_tide=phi_tide, H_tide=h_tide, Imk2=imk2) - orbit = SimpleNamespace(dummy=dummy) - return cast(Any, SimpleNamespace(orbit=orbit)) + with the paper convention Im(k2) < 0 for tidal dissipation (the + paper's Eq. 4 makes -Im(k2) the positive dissipation efficiency). + The PROTEUS source uses positive Imk2 in its calling convention, so + the formula evaluated with Imk2 = +1 here returns a positive de/dt; + documented as a known-sign-convention item in the source docstring. + Discriminating evaluation point: Imk2 = Mst = Rpl = Mpl = 1 (SI + units), a = 2, e = 0.5, with the real ``const_G`` (sp0d always + uses the physical constant, not a caller-supplied value): -@pytest.mark.unit -def test_less_than_threshold_heating_scales_with_melt_fraction(): - """Heating applies where phi < threshold and scales linearly with melt fraction.""" - config = _make_config('<0.5', h_tide=10.0, imk2=1e-2) - interior = Interior_t(nlev_b=4) - interior.phi = np.array([0.1, 0.4, 0.6]) + de/dt = (21/2) * const_G**0.5 * 0.5 / 2**6.5 + + See ``docs/Validation/orbit/orbit.md`` for the validation registry + entry. + """ + _, val = _sp0d_instantaneous_rates(sma=2.0, ecc=0.5, **_UNIT_SYS) + expected = (21.0 / 2.0) * const_G**0.5 * 0.5 / (2.0**6.5) + assert val == pytest.approx(expected, rel=1e-3) + # Exponent-error guard: an off-by-one in the semi-major-axis + # exponent puts the result far outside the finite-difference noise + # floor. Check a**5 (way too big) AND a**7 (too small), the two + # closest neighbouring exponents. + wrong_a5 = (21.0 / 2.0) * const_G**0.5 * 0.5 / (2.0**5) + wrong_a7 = (21.0 / 2.0) * const_G**0.5 * 0.5 / (2.0**7) + assert abs(val - wrong_a5) / expected > 1.0 + assert abs(val - wrong_a7) / expected > 0.1 + # Sign guard: under the PROTEUS calling convention (positive Imk2) + # the RHS is positive. A flip would fail this. Note: this does NOT + # certify the convention matches Driscoll and Barnes; see source + # docstring. + assert val > 0.0 - result = run_dummy_orbit(config, interior) - assert result == pytest.approx(1e-2) - assert interior.tides == pytest.approx( - [10.0 * (1 - 0.1 / 0.5), 10.0 * (1 - 0.4 / 0.5), 0.0] +@pytest.mark.physics_invariant +def test_sp0d_de_dt_scales_as_radius_to_the_fifth_power(): + """Doubling ``Rpl`` should multiply sp0d's de/dt by 32, not 16 or + 64.""" + _, small = _sp0d_instantaneous_rates( + sma=1.0, ecc=0.1, Rpl=1.0, **{k: v for k, v in _UNIT_SYS.items() if k != 'Rpl'} + ) + _, big = _sp0d_instantaneous_rates( + sma=1.0, ecc=0.1, Rpl=2.0, **{k: v for k, v in _UNIT_SYS.items() if k != 'Rpl'} ) + ratio = big / small + assert ratio == pytest.approx(32.0, rel=1e-3) + # Exponent guards: 2**5 = 32; reject the neighbours 2**4 = 16 and + # 2**6 = 64. The base 2 choice makes adjacent-exponent regressions + # land at well-separated values. + assert abs(ratio - 16.0) > 10.0 + assert abs(ratio - 64.0) > 20.0 -@pytest.mark.unit -def test_greater_than_threshold_heating_scales_linearly(): - """Heating applies where phi > threshold and increases toward fully liquid.""" - config = _make_config('>0.25', h_tide=5.0, imk2=2e-3) - interior = Interior_t(nlev_b=5) - interior.phi = np.array([0.1, 0.25, 0.75, 1.0]) +@pytest.mark.physics_invariant +def test_sp0d_de_dt_inverse_planet_mass_dependence(): + """sp0d's de/dt is inversely proportional to planet mass + ``Mpl``.""" + _, val_light = _sp0d_instantaneous_rates( + sma=1.0, ecc=0.1, Mpl=1.0, **{k: v for k, v in _UNIT_SYS.items() if k != 'Mpl'} + ) + _, val_heavy = _sp0d_instantaneous_rates( + sma=1.0, ecc=0.1, Mpl=3.0, **{k: v for k, v in _UNIT_SYS.items() if k != 'Mpl'} + ) + ratio = val_light / val_heavy + assert ratio == pytest.approx(3.0, rel=1e-3) + # Monotonicity guard: heavier planet always damps slower under + # inverse-Mpl. A regression that put Mpl in the numerator would + # flip the inequality. + assert val_heavy < val_light - result = run_dummy_orbit(config, interior) - assert result == pytest.approx(2e-3) - expected = [0.0, 0.0, 5.0 * (0.75 - 0.25) / (1 - 0.25), 5.0 * (1.0 - 0.25) / (1 - 0.25)] - assert interior.tides == pytest.approx(expected) +# --------------------------------------------------------------------------- +# sp0d's da/dt: semi-major axis derivative (probed via finite difference) +# --------------------------------------------------------------------------- -@pytest.mark.unit @pytest.mark.physics_invariant -def test_equal_to_threshold_produces_zero_heating(): - """Boundary equality does not trigger heating (strict inequality). +def test_sp0d_da_dt_is_zero_for_circular_orbit(): + """At ``e=0``, ``da_dt = 2 a e de_dt = 0`` regardless of ``a`` or + params. - Boundedness invariant at the comparator boundary: phi == threshold - falls outside the strict-less-than condition and so the tidal - heating remains exactly zero. Discriminates the strict-vs-non-strict - comparator regression. + A bug that dropped the ``e`` factor (``da_dt = 2 a de_dt``) would + return a nonzero value here. """ - config = _make_config('<0.3', h_tide=7.5, imk2=0.0) - interior = Interior_t(nlev_b=3) - interior.phi = np.array([0.3, 0.3]) - - run_dummy_orbit(config, interior) - - assert interior.tides == pytest.approx([0.0, 0.0]) - # Discrimination guard: shifting phi just below the threshold must - # produce strictly positive heating (h_tide=7.5 at phi=0.299 with - # threshold=0.3 gives 7.5 * (1 - 0.299/0.3) ~ 0.025). A regression - # that used <= instead of < would have produced 0.0 at phi=0.3 - # above AND would also produce > 0 here, so the difference between - # the boundary case and the just-below case is what discriminates - # the strict comparator. - interior_below = Interior_t(nlev_b=3) - interior_below.phi = np.array([0.299, 0.299]) - run_dummy_orbit(config, interior_below) - assert interior_below.tides[0] > 0.0 - assert interior_below.tides[0] < 7.5 # bounded by h_tide - - -@pytest.mark.unit -@pytest.mark.physics_invariant -def test_no_cells_meet_condition_keeps_zero_heating(): - """If no layer meets inequality, tides remain zero everywhere. - - Limit-input invariant: with phi values all above the < 0.1 threshold, - no cells qualify and the tidal heating array stays at the zero IC. - """ - config = _make_config('<0.1', h_tide=9.0, imk2=0.5) - interior = Interior_t(nlev_b=3) - interior.phi = np.array([0.5, 0.9]) - - run_dummy_orbit(config, interior) - - assert interior.tides == pytest.approx([0.0, 0.0]) - # Sign / positivity guard on the unrelated return value: the - # function still returns Imk2 from config even when no cells heat. - # A regression that returned 0.0 (e.g. short-circuited on the - # empty mask) would still satisfy the zero-tides equality above - # but break the Imk2 contract documented in test_returns_imk2_from_config. - result = run_dummy_orbit(config, interior) - assert result == pytest.approx(0.5) - # Discrimination guard: with one phi shifted into the < 0.1 region, - # the corresponding tides entry must become strictly positive while - # the others stay zero. This separates "no heating because no cells - # qualify" from "no heating because the formula is broken". - interior_mixed = Interior_t(nlev_b=3) - interior_mixed.phi = np.array([0.05, 0.9]) - run_dummy_orbit(config, interior_mixed) - assert interior_mixed.tides[0] > 0.0 - assert interior_mixed.tides[1] == pytest.approx(0.0) - - -@pytest.mark.unit -@pytest.mark.physics_invariant -def test_phi_array_unchanged_by_heating_calculation(): - """run_dummy_orbit updates tides but leaves melt fractions untouched. - - Input-immutability invariant: melt fraction is the input state; the - dummy orbit applies tidal heating WITHOUT mutating phi. A regression - that modified phi in place would corrupt the interior solver's - state across iterations. - """ - config = _make_config('>0.2', h_tide=4.0, imk2=1.0) - interior = Interior_t(nlev_b=4) - interior.phi = np.array([0.1, 0.2, 0.3]) - phi_before = interior.phi.copy() - - run_dummy_orbit(config, interior) - - assert interior.phi == pytest.approx(phi_before) - # Discrimination guard: tides must still have been UPDATED for the - # cell that qualifies (phi=0.3 > 0.2). A regression that did nothing - # (returned without touching either array) would satisfy the phi - # equality but leave tides at the IC zero. - assert interior.tides[2] > 0.0 - # Sign / scale guard: the active cell at phi=0.3, threshold=0.2, - # h_tide=4.0 must land at 4.0 * (0.3 - 0.2) / (1 - 0.2) = 0.5. - # Pins both the formula and the magnitude. - assert interior.tides[2] == pytest.approx(0.5) - - -@pytest.mark.unit -def test_returns_imk2_from_config(): - """Function returns Imk2 value provided in config without modification. - - Pass-through contract: the dummy orbit relays Imk2 from config to - the caller verbatim, independent of phi or heating state. - """ - config = _make_config('<0.9', h_tide=1.0, imk2=3.21) - interior = Interior_t(nlev_b=2) - interior.phi = np.array([0.5]) - - result = run_dummy_orbit(config, interior) - - assert result == pytest.approx(3.21) - # Discrimination guard: pick a different Imk2 value and confirm the - # return tracks it. A regression that hardcoded 3.21 or returned a - # constant (h_tide, 0, NaN) would pass the first equality but fail - # this second call. - config2 = _make_config('<0.9', h_tide=1.0, imk2=7.65) - interior2 = Interior_t(nlev_b=2) - interior2.phi = np.array([0.5]) - result2 = run_dummy_orbit(config2, interior2) - assert result2 == pytest.approx(7.65) - # The two return values must differ (rules out a regression that - # ignored the config and always returned the same constant). - assert result != pytest.approx(result2) - - -@pytest.mark.unit -def test_single_level_interpolates_correctly(): - """Single-layer interior still applies heating logic and preserves array shapes.""" - config = _make_config('>0.5', h_tide=2.0, imk2=0.7) - interior = Interior_t(nlev_b=2) - interior.phi = np.array([0.8]) - - run_dummy_orbit(config, interior) - - assert interior.tides.shape == (1,) - assert interior.tides == pytest.approx([2.0 * (0.8 - 0.5) / (1 - 0.5)]) + da10, _ = _sp0d_instantaneous_rates(sma=10.0, ecc=0.0, **_UNIT_SYS) + assert da10 == pytest.approx(0.0, abs=1e-20) + # Limit-input invariant: the fixed point is independent of a. A + # regression that recovered an a-dependent constant term at e=0 + # would only show at a different a value. + da_half, _ = _sp0d_instantaneous_rates(sma=0.5, ecc=0.0, **_UNIT_SYS) + assert da_half == pytest.approx(0.0, abs=1e-20) + + +@pytest.mark.physics_invariant +def test_sp0d_da_dt_obeys_kinematic_identity(): + """``da_dt = 2 a e * de_dt`` must hold (mathematical identity; + finite-difference precision, not exact machine precision, since + both sides are independently probed from the same short + integration).""" + a, e = 1.5, 0.3 + da, de = _sp0d_instantaneous_rates(sma=a, ecc=e, **_UNIT_SYS) + rhs = 2.0 * a * e * de + assert da == pytest.approx(rhs, rel=1e-2) + # Identity guard at a different (a, e): the relation must hold + # everywhere, not at one accidentally-coincidental point. + a2, e2 = 3.0, 0.7 + da2, de2 = _sp0d_instantaneous_rates(sma=a2, ecc=e2, **_UNIT_SYS) + rhs2 = 2.0 * a2 * e2 * de2 + assert da2 == pytest.approx(rhs2, rel=1e-2) + + +@pytest.mark.physics_invariant +def test_sp0d_da_dt_quadratic_in_eccentricity(): + """Together with the de/dt linearity, sp0d's da/dt is quadratic in + ``e``.""" + da_base, _ = _sp0d_instantaneous_rates(sma=1.0, ecc=0.01, **_UNIT_SYS) + da_scaled, _ = _sp0d_instantaneous_rates(sma=1.0, ecc=0.04, **_UNIT_SYS) + # (0.04 / 0.01)**2 = 16 + assert da_scaled == pytest.approx(16.0 * da_base, rel=1e-2) + # Exponent guard: linear-in-e (ratio 4) and cubic (ratio 64) are + # both rejected by the absolute gap from 16. + assert abs(da_scaled / da_base - 4.0) > 5.0 + assert abs(da_scaled / da_base - 64.0) > 20.0 + + +# --------------------------------------------------------------------------- +# sp0d: public integrator that mutates hf_row in place +# --------------------------------------------------------------------------- + + +def _make_hf_row( + *, + sma_m: float = 1.5e11, + ecc: float = 0.1, + Imk2: float = 1e-3, + M_star: float = 1.989e30, + R_int: float = 6.371e6, + M_int: float = 5.972e24, +) -> dict: + return { + 'semimajorax': sma_m, + 'eccentricity': ecc, + 'Imk2': Imk2, + 'M_star': M_star, + 'R_int': R_int, + 'M_int': M_int, + # Only read by evolve_orbit_star's adaptive-substep controller + # (for log messages), not by sp0d/sp1d directly. + 'Time': 0.0, + # get_C_planet's fallback for a missing hf_row entry reads + # config.interior_struct.core_density; set directly here so + # these tests don't need a full config.interior_struct stand-in. + 'core_density': 5500.0, + } + + +@pytest.mark.physics_invariant +def test_sp0d_zero_imk2_preserves_sma_and_eccentricity(): + """With ``Imk2 = 0`` the right-hand sides vanish identically; the + integrator must leave ``sma`` and ``ecc`` unchanged regardless of + the step length. + """ + hf_row = _make_hf_row(sma_m=2.0 * 1.5e11, ecc=0.4, Imk2=0.0) + sp0d(hf_row, dt=1e5, config=_SOLVER_CONFIG) + assert hf_row['semimajorax'] == pytest.approx(2.0 * 1.5e11) + assert hf_row['eccentricity'] == pytest.approx(0.4) + + +@pytest.mark.physics_invariant +def test_sp0d_zero_eccentricity_is_a_fixed_point(): + """``e = 0`` is a fixed point of the system; with positive + ``Imk2`` the integrator must keep the orbit circular and ``sma`` + unchanged. + """ + hf_row = _make_hf_row(sma_m=1.5e11, ecc=0.0, Imk2=1e-2) + sp0d(hf_row, dt=1e3, config=_SOLVER_CONFIG) + assert hf_row['eccentricity'] == pytest.approx(0.0, abs=1e-30) + assert hf_row['semimajorax'] == pytest.approx(1.5e11) + + +@pytest.mark.physics_invariant +def test_sp0d_returns_finite_for_high_eccentricity(): + """Adversarial near-unity eccentricity: the integrator must not + emit NaN or inf for an aggressive but physically valid input. + """ + hf_row = _make_hf_row(ecc=0.95, Imk2=1e-6) + sp0d(hf_row, dt=1.0, config=_SOLVER_CONFIG) + assert np.isfinite(hf_row['semimajorax']) + assert np.isfinite(hf_row['eccentricity']) + + +def test_sp0d_mutates_hf_row_in_place(): + """``sp0d`` mutates ``hf_row`` and returns ``None``; it must not + silently return a new dict. + """ + hf_row = _make_hf_row(sma_m=0.7 * 1.5e11) + assert sp0d(hf_row, dt=1.0, config=_SOLVER_CONFIG) is None + assert hf_row['semimajorax'] == pytest.approx(0.7 * 1.5e11) + + +# --------------------------------------------------------------------------- +# _state_is_valid_star +# --------------------------------------------------------------------------- + + +def test_state_is_valid_star_accepts_a_physically_sane_state(): + """A normal, finite state well clear of every rejection boundary + must be accepted -- the baseline every rejection test below is + contrasted against.""" + hf_row = { + 'semimajorax': 1.496e11, + 'eccentricity': 0.05, + 'R_star': 6.96e8, + 'axial_period': 86400.0, + } + assert _state_is_valid_star(hf_row) is True + # Discrimination: a state failing only one of the three checks + # (eccentricity here) must be rejected -- confirms this isn't a + # function that always returns True regardless of input. + assert _state_is_valid_star({**hf_row, 'eccentricity': 1.5}) is False + + +def test_state_is_valid_star_rejects_semimajorax_inside_stellar_surface(): + """``a <= 1.05 R_star`` (spiralled into the star) must reject, + including the non-finite-``a`` edge case that trips the same + branch via the ``np.nan`` default.""" + hf_row = {'semimajorax': 1.0e8, 'eccentricity': 0.05, 'R_star': 6.96e8} + assert _state_is_valid_star(hf_row) is False + # Edge case: semimajorax entirely absent defaults to np.nan, which + # must also reject (not raise or silently pass). + hf_row_missing = {'eccentricity': 0.05, 'R_star': 6.96e8} + assert _state_is_valid_star(hf_row_missing) is False + + +def test_state_is_valid_star_rejects_unphysical_eccentricity(): + """Eccentricity outside ``[0, 0.999)`` must reject -- both a + negative value and one at/above the near-parabolic ceiling.""" + base = {'semimajorax': 1.496e11, 'R_star': 6.96e8} + assert _state_is_valid_star({**base, 'eccentricity': -0.01}) is False + assert _state_is_valid_star({**base, 'eccentricity': 0.999}) is False + assert _state_is_valid_star({**base, 'eccentricity': float('nan')}) is False + + +def test_state_is_valid_star_rejects_nonfinite_axial_period_when_present(): + """``axial_period`` (only tracked by sp1d, absent for sp0d) must + reject when present but non-finite; absent entirely is fine (sp0d + has no spin state to check).""" + base = {'semimajorax': 1.496e11, 'eccentricity': 0.05, 'R_star': 6.96e8} + assert _state_is_valid_star({**base, 'axial_period': float('inf')}) is False + # Absent axial_period (sp0d) must NOT be treated as invalid. + assert _state_is_valid_star(base) is True + + +# --------------------------------------------------------------------------- +# evolve_orbit_star: dispatch by config.orbit.star_planet_model +# --------------------------------------------------------------------------- + + +def _make_star_planet_config(model) -> Any: + return cast( + Any, + SimpleNamespace(orbit=SimpleNamespace(star_planet_model=model, solver=OrbitSolver())), + ) + + +def test_evolve_orbit_star_sp0d_model_evolves_hf_row(): + """``config.orbit.star_planet_model == 'sp0d'`` dispatches to + ``sp0d``, which must actually change ``hf_row`` (nonzero Imk2, so + the orbit is not a fixed point). + + ``tides_o`` is unused on this branch (only ``sp1d`` reads it), so + a bare object stands in for it without needing a real ``Tides_t``. + """ + hf_row = _make_hf_row(ecc=0.2, Imk2=1e-2) + sma_before = hf_row['semimajorax'] + config = _make_star_planet_config('sp0d') + interior_o = SimpleNamespace(dt=1e7) + + evolve_orbit_star(hf_row, config, dirs={}, tides_o=object(), interior_o=interior_o) + + # Discrimination: the orbit actually evolved (not a silent no-op). + # An absolute (not relative-tolerance) gap avoids a false negative + # from a real-but-tiny change at a short step: pytest.approx's + # default rel window can otherwise swallow a genuine, if small, + # displacement. + assert abs(hf_row['semimajorax'] - sma_before) > 1e-3 + + +def test_evolve_orbit_star_unrecognized_model_raises_immediately(monkeypatch): + """An unrecognized (or ``None``) ``star_planet_model`` is rejected + up-front by the dispatch, before the shared adaptive-substep + controller ever starts. Also records status code 26 (via + ``UpdateStatusfile``) before raising, so a crashed run is recorded + as such rather than left unexplained. + """ + from proteus.orbit import orbit as orbit_mod + + hf_row = _make_hf_row(ecc=0.2, Imk2=1e-2) + hf_row_before = dict(hf_row) + config = _make_star_planet_config(None) + interior_o = SimpleNamespace(dt=1e4) + dirs = {'output': '/tmp/unused'} + + mock_update_status = MagicMock() + monkeypatch.setattr(orbit_mod, 'UpdateStatusfile', mock_update_status) + + with pytest.raises(ValueError, match='None'): + evolve_orbit_star(hf_row, config, dirs=dirs, tides_o=object(), interior_o=interior_o) + + # No side effect: the raise happens before any substep runs. + assert hf_row == hf_row_before + mock_update_status.assert_called_once_with(dirs, 26) + + +def test_evolve_orbit_star_skips_domega_p_when_axial_period_hits_zero(monkeypatch): + """``rel_change_fn``'s ``dOmega_p`` gradient check must be skipped + (not raise a ZeroDivisionError) when the planet's spin period comes + back exactly zero -- a degenerate but ``np.isfinite`` value that + ``_state_is_valid_star`` does not itself reject (unlike + ``semimajorax``, spin period has no explicit positivity/range + check, only a finiteness one). + + ``sp1d`` is replaced with a controlled stand-in here because no + real tidal model actually drives spin to exactly zero -- this + isolates the shared controller's own gradient-check robustness + from whether real physics would ever produce this input. + """ + from proteus.orbit import orbit as orbit_mod + + def fake_sp1d(hf_row, tides_o, dt_yr, config): + hf_row['axial_period'] = 0.0 + + monkeypatch.setattr(orbit_mod, 'sp1d', fake_sp1d) + + hf_row = _make_sp1d_hf_row() + interior_o = SimpleNamespace( + dt=0.5, radius=np.array([0.0, 3.0e6, 6.371e6]), density=np.array([5500.0, 5000.0]) + ) + config = _make_star_planet_config('sp1d') + config.interior_energetics = SimpleNamespace(module='aragog') + config.orbit.solver.dt0_yr = 0.5 # completes in exactly one substep + tides_o = Tides_t() + + orbit_mod.evolve_orbit_star(hf_row, config, dirs={}, tides_o=tides_o, interior_o=interior_o) + + assert hf_row['axial_period'] == 0.0 + # Discrimination: the substep was actually ACCEPTED, not stuck + # retrying/rejecting forever -- a broken guard that raised + # ZeroDivisionError inside rel_change_fn would be caught by the + # substep's own try/except and masquerade as an ordinary rejection. + assert tides_o.dt_yr is not None + + +# --------------------------------------------------------------------------- +# sp1d: planet spin + orbit (Hansen-coefficient-based), black-box tested +# through the public sp1d(hf_row, tides_o, dt) entry point. +# --------------------------------------------------------------------------- + +# sp1d calls proteus.orbit.common.get_all_m_hansen, which lazily builds a +# module-level cache (_hansen_table) via a full FFT sweep over a ~100-point +# eccentricity grid on first use -- on the order of a minute of wall time +# (the same trap fixed in tests/orbit/test_common.py). Every sp1d test +# force-builds a tiny, fast table instead, and monkeypatch restores the +# prior (possibly None) module state afterward so this can never leak into +# other tests sharing the same pytest process. +_FAST_E_GRID = np.array([0.0, 0.1, 0.3, 0.5, 0.7, 0.9]) +_FAST_KMIN, _FAST_KMAX = -6, 6 + +_SP1D_MST = 1.989e30 # 1 M_sun +_SP1D_MPL = 5.972e24 # 1 M_earth +_SP1D_RST = 6.957e8 # 1 R_sun +_SP1D_RPL = 6.371e6 # 1 R_earth +_SP1D_CPL = 0.33 * _SP1D_MPL * _SP1D_RPL**2 # Earth-like moment-of-inertia factor + + +@pytest.fixture +def _fast_hansen_table(monkeypatch): + """Force-build a small, fast Hansen table for the duration of one + test, restoring whatever module-level table (if any) existed + before, so this cannot bleed into other tests. + """ + monkeypatch.setattr(hansen_mod, '_hansen_table', None) + hansen_mod.init_hansen_table( + e_grid=_FAST_E_GRID, kmin=_FAST_KMIN, kmax=_FAST_KMAX, n_deg=2, force=True + ) + + +def _make_planet_star_tides(lnk_value: complex) -> Tides_t: + """Build a ``Tides_t`` with a ``('planet', 'star')`` mode table + covering ``m in {0, 2}`` over the fast Hansen table's k-range, all + modes carrying the same Love number (sp1d only reads the m=0 and + m=2, degree-2 rows). + """ + nmk = [(2, 0, k) for k in range(_FAST_KMIN, _FAST_KMAX + 1)] + [ + (2, 2, k) for k in range(_FAST_KMIN, _FAST_KMAX + 1) + ] + tides_o = Tides_t() + entry = tides_o.add(primary='planet', perturber='star') + entry.nmk = np.array(nmk, dtype=int) + entry.LNk = np.full(len(nmk), lnk_value, dtype=complex) + return tides_o + + +def _make_sp1d_hf_row(*, axial_period=86400.0, sma=0.02 * 1.496e11, ecc=0.3): + return { + 'axial_period': axial_period, + 'semimajorax': sma, + 'eccentricity': ecc, + 'M_int': _SP1D_MPL, + 'M_star': _SP1D_MST, + 'R_int': _SP1D_RPL, + 'R_star': _SP1D_RST, + 'C_int': _SP1D_CPL, + # Only read by evolve_orbit_star's adaptive-substep controller + # (for log messages), not by sp1d directly. + 'Time': 0.0, + # get_C_planet's fallback for a missing hf_row entry + 'core_density': 5500.0, + } + + +def _sp1d_spin_and_orbital_am(hf_row: dict) -> tuple[float, float]: + """Angular momentum components under sp1d's own bookkeeping: + ``(planet spin AM, orbital AM)`` -- planet spin is + ``C_planet * Omega_p``, orbital AM is the two-body reduced-mass + form. + """ + a, e, axial_period = hf_row['semimajorax'], hf_row['eccentricity'], hf_row['axial_period'] + omega_p = 2 * np.pi / axial_period + mu = _SP1D_MST * _SP1D_MPL / (_SP1D_MST + _SP1D_MPL) + l_orb = mu * np.sqrt(const_G * (_SP1D_MST + _SP1D_MPL) * a * (1 - e**2)) + return hf_row['C_int'] * omega_p, l_orb + + +def _sp1d_total_am(hf_row: dict) -> float: + """Total angular momentum: sum of the two components above.""" + spin, orb = _sp1d_spin_and_orbital_am(hf_row) + return spin + orb + + +@pytest.mark.physics_invariant +def test_sp1d_zero_dissipation_is_an_exact_fixed_point(_fast_hansen_table): + """With every Love number exactly 0 (no tidal response), spin, + semimajor axis, and eccentricity must all be left exactly + unchanged: every term in ``orbitals`` is proportional to + ``K_p0``/``K_p2``, both zero. + """ + hf_row = _make_sp1d_hf_row() + axial_before, sma_before, ecc_before = ( + hf_row['axial_period'], + hf_row['semimajorax'], + hf_row['eccentricity'], + ) + tides_o = _make_planet_star_tides(0.0 + 0.0j) + + sp1d(hf_row, tides_o, dt=1e5, config=_SOLVER_CONFIG) + + assert hf_row['axial_period'] == pytest.approx(axial_before, rel=1e-12) + assert hf_row['semimajorax'] == pytest.approx(sma_before, rel=1e-12) + assert hf_row['eccentricity'] == pytest.approx(ecc_before, rel=1e-12) + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_sp1d_conserves_total_angular_momentum(_fast_hansen_table): + """Physical law (not specific to Driscoll & Barnes or any single + paper): for an isolated two-body system with only internal tidal + torques, total angular momentum (planet spin + orbital) is + conserved. sp1d's ``domega_dt``/``da_dt``/``de_dt`` are + constructed to respect this -- checked here across a real, + non-trivial step (eccentricity drops from 0.3 to well below 0.1, + semimajor axis shrinks by several percent), not a near-zero + finite-difference probe. + + Tolerance rel=1e-6 matches ``solve_ivp``'s configured ``rtol``. + """ + hf_row = _make_sp1d_hf_row(ecc=0.3) + am_before = _sp1d_total_am(hf_row) + tides_o = _make_planet_star_tides(-0.01 - 0.02j) + + sp1d(hf_row, tides_o, dt=1e5, config=_SOLVER_CONFIG) + + # Discrimination: the step must have actually done something + # substantial, or a bug that silently no-oped would trivially + # "conserve" AM too. + assert hf_row['eccentricity'] < 0.2 + am_after = _sp1d_total_am(hf_row) + assert am_after == pytest.approx(am_before, rel=1e-6) + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_sp1d_spin_am_gain_matches_orbital_am_loss(_fast_hansen_table): + """A more targeted phrasing of the same conservation law above, + needed because the planet's spin AM is ~1e-6 of the total system + AM at these masses (Earth-like planet, Sun-like star): a bug + confined entirely to ``domega_dt`` changes the spin term by a + large relative amount while barely moving the SUM (total AM), + since the sum is completely dominated by the orbital term. + Comparing the two individually-sized deltas directly + (``Delta(spin AM) == -Delta(orbital AM)``) is sensitive to + exactly this class of bug, which the raw total-AM check above is + not (verified by injecting a 17% error into ``domega_dt``'s + coupling coefficient: the total-AM check above still passed, but + this delta comparison failed by the same ~17%). + + Tolerance rel=5e-2: looser than the raw total-AM check because + differencing two close orbital-AM values before comparing to the + much smaller spin delta amplifies relative solver-tolerance and + Hansen-table interpolation-grid noise; still tight enough to + reject a double-digit-percent coupling error with a wide margin. + """ + hf_row = _make_sp1d_hf_row(ecc=0.3) + spin_before, orb_before = _sp1d_spin_and_orbital_am(hf_row) + tides_o = _make_planet_star_tides(-0.01 - 0.02j) + + sp1d(hf_row, tides_o, dt=1e5, config=_SOLVER_CONFIG) + + spin_after, orb_after = _sp1d_spin_and_orbital_am(hf_row) + d_spin = spin_after - spin_before + d_orb = orb_after - orb_before + # Discrimination: both deltas must be substantial, not near-zero + # (a near-zero step would make the ratio numerically meaningless). + assert abs(d_spin) > 1e30 + assert abs(d_orb) > 1e30 + assert d_spin == pytest.approx(-d_orb, rel=5e-2) + # Sign guard: spin gains AM (planet spins up) exactly as the + # orbit loses it during circularization. + assert d_spin > 0.0 + assert d_orb < 0.0 + + +@pytest.mark.physics_invariant +def test_sp1d_dissipation_circularizes_regardless_of_input_love_number_sign( + _fast_hansen_table, +): + """``orbitals`` derives the dissipation direction from the sign of + the CURRENT forcing frequency alone (``smooth_sign``), using only + the magnitude of the Love number's imaginary part + (``abs(LNk.imag)``) -- necessary because the Love number is fixed + for the whole integration while the forcing frequency varies as + the solver explores trial states (see module docstring). Pins + that a positive- and a negative-imaginary-part Love number of the + same magnitude produce IDENTICAL evolution: the raw sign carried + by the input is not part of the dynamics' contract, unlike + ``dE_dt``'s debug-only power estimate. + """ + hf_row_neg = _make_sp1d_hf_row(ecc=0.3) + hf_row_pos = _make_sp1d_hf_row(ecc=0.3) + tides_neg = _make_planet_star_tides(-0.01 - 0.02j) + tides_pos = _make_planet_star_tides(-0.01 + 0.02j) + + sp1d(hf_row_neg, tides_neg, dt=1e5, config=_SOLVER_CONFIG) + sp1d(hf_row_pos, tides_pos, dt=1e5, config=_SOLVER_CONFIG) + + assert hf_row_neg['eccentricity'] == pytest.approx(hf_row_pos['eccentricity'], rel=1e-10) + assert hf_row_neg['semimajorax'] == pytest.approx(hf_row_pos['semimajorax'], rel=1e-10) + # Discrimination: this is circularization, not a coincidental + # no-op -- eccentricity actually dropped from the 0.3 IC. + assert hf_row_neg['eccentricity'] < 0.3 + + +@pytest.mark.physics_invariant +def test_sp1d_eccentricity_clamped_at_zero_not_negative(_fast_hansen_table): + """Aggressive dissipation at high initial eccentricity can drive + the raw ODE solution for ``e`` slightly negative within a step; + the source clamps this to exactly 0.0 rather than reporting an + unphysical negative eccentricity, and the output must stay + finite. + """ + hf_row = _make_sp1d_hf_row(ecc=0.9) + tides_o = _make_planet_star_tides(-0.01 - 0.02j) + + sp1d(hf_row, tides_o, dt=1e4, config=_SOLVER_CONFIG) + + assert np.isfinite(hf_row['eccentricity']) + assert np.isfinite(hf_row['semimajorax']) + assert hf_row['eccentricity'] >= 0.0 + + +def test_sp1d_sma_dot_matches_bookkeeping_identity(_fast_hansen_table): + """``hf_row['sma_dot_planet']`` must equal the actual change in + ``semimajorax`` over the step divided by the elapsed time in + seconds -- pins the bookkeeping identity so a regression that + used the wrong ``sol.y`` row/index would be caught even though + ``semimajorax`` itself might still look plausible. + """ + hf_row = _make_sp1d_hf_row(ecc=0.3) + sma_before = hf_row['semimajorax'] + tides_o = _make_planet_star_tides(-0.01 - 0.02j) + dt_yr = 1e5 + + sp1d(hf_row, tides_o, dt=dt_yr, config=_SOLVER_CONFIG) + + expected_sma_dot = (hf_row['semimajorax'] - sma_before) / (dt_yr * secs_per_year) + assert hf_row['sma_dot_planet'] == pytest.approx(expected_sma_dot, rel=1e-9) + + +def _uniform_sphere_interior_for_c_planet(nlev_b: int = 20): + """Minimal Interior_t-like stand-in for ``get_C_planet``: a + uniform-density sphere so its own moment of inertia has a known + closed form, mirroring the fixture in tests/orbit/test_common.py. + """ + radius = np.linspace(0.0, _SP1D_RPL, nlev_b) + density = np.full(nlev_b - 1, 5500.0) + interior_o = SimpleNamespace(radius=radius, density=density) + return interior_o + + +def test_evolve_orbit_star_sp1d_model_calls_get_c_planet_and_evolves_hf_row( + _fast_hansen_table, +): + """``config.orbit.star_planet_model == 'sp1d'`` dispatches through + ``get_C_planet`` (populating ``hf_row['C_int']`` from the + interior profile) and then ``sp1d``, via the public + ``evolve_orbit_star`` entry point. + """ + hf_row = _make_sp1d_hf_row(ecc=0.3) + del hf_row['C_int'] # get_C_planet must populate this itself + hf_row['M_int'] = _SP1D_MPL + hf_row['R_int'] = _SP1D_RPL + tides_o = _make_planet_star_tides(-0.01 - 0.02j) + interior_o = _uniform_sphere_interior_for_c_planet() + interior_o.dt = 1e5 + config = cast( + Any, + SimpleNamespace( + orbit=SimpleNamespace(star_planet_model='sp1d', solver=OrbitSolver()), + interior_energetics=SimpleNamespace(module='aragog'), + ), + ) + + evolve_orbit_star(hf_row, config, dirs={}, tides_o=tides_o, interior_o=interior_o) + + assert 'C_int' in hf_row + assert hf_row['C_int'] > 0.0 + # Discrimination: the orbit actually evolved under sp1d, not a + # silent no-op. + assert hf_row['eccentricity'] < 0.3 diff --git a/tests/orbit/test_orbit_evolve.py b/tests/orbit/test_orbit_evolve.py deleted file mode 100644 index 0f7156b6d..000000000 --- a/tests/orbit/test_orbit_evolve.py +++ /dev/null @@ -1,351 +0,0 @@ -"""Unit tests for the Driscoll & Barnes (2015) tidal orbit evolution module -(``proteus.orbit.orbit``). - -Exercises the ODE right-hand sides ``de_dt`` and ``da_dt``, the -``orbitals`` wrapper used by ``scipy.integrate.solve_ivp``, and the -``evolve_orbital`` orchestrator that mutates ``hf_row`` in place. - -Anti-happy-path coverage: - -- The eccentricity derivative is linear in ``e`` and vanishes at - ``e=0`` so the zero-eccentricity orbit is a fixed point. -- The semi-major axis derivative satisfies the kinematic relation - ``da_dt = 2 a e * de_dt`` and is identically zero on circular - orbits. -- Discriminating numeric values pin the exponents (``a**6.5``, - ``Rpl**5``) so that a bugged ``a**5`` or ``Rpl**4`` is caught. -- Adversarial ``hf_row`` inputs (zero ``Imk2``, near-unity ``e``) - are exercised to confirm the orchestrator does not crash or - return non-finite results. -""" - -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any, cast - -import numpy as np -import pytest - -from proteus.orbit.orbit import da_dt, de_dt, evolve_orbital, orbitals -from proteus.utils.constants import AU - -pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] - -# Reusable parameter tuple (Imk2, Mst, G, Rpl, Mpl) with unit scales so -# that algebra is easy to verify by hand. -_UNIT_PARAMS = (1.0, 1.0, 1.0, 1.0, 1.0) - - -# --------------------------------------------------------------------------- -# de_dt: eccentricity derivative -# --------------------------------------------------------------------------- - - -@pytest.mark.physics_invariant -def test_de_dt_vanishes_at_zero_eccentricity(): - """A circular orbit (e=0) is a fixed point of the tidal evolution. - Holds for any semi-major axis: e=0 zeros the prefactor regardless - of a, so we exercise two values of a to confirm the fixed point is - a property of the eccentricity factor, not an accidental zero at - one a value.""" - assert de_dt(a=1.0, e=0.0, params=_UNIT_PARAMS) == pytest.approx(0.0, abs=1e-12) - # Limit-input invariant: a must drop out of the e=0 result; a - # regression that introduced a stray a-dependent additive term - # would only show at a != 1. - assert de_dt(a=5.0, e=0.0, params=_UNIT_PARAMS) == pytest.approx(0.0, abs=1e-12) - - -@pytest.mark.physics_invariant -def test_de_dt_is_linear_in_eccentricity(): - """``de_dt`` scales linearly in ``e`` per Driscoll and Barnes (2015) Eq. 16.""" - base = de_dt(a=1.0, e=0.01, params=_UNIT_PARAMS) - scaled = de_dt(a=1.0, e=0.05, params=_UNIT_PARAMS) - # 5x e -> 5x de/dt within float precision - assert scaled == pytest.approx(5.0 * base, rel=1e-12) - # Linearity guard: a quadratic-in-e regression would give a ratio - # of 25, not 5. The absolute gap discriminates. - assert abs(scaled / base - 25.0) > 10.0 - - -@pytest.mark.reference_pinned -@pytest.mark.physics_invariant -def test_de_dt_matches_driscoll_barnes_2015_eq16(): - """Pin de_dt against Driscoll and Barnes (2015) Astrobiology 15, 739, - DOI 10.1089/ast.2015.1325, Eq. 16. (arXiv:1509.07452.) - - The paper writes the formula as - - de/dt = (21/2) Im(k2) M_*^(3/2) G^(1/2) R_p^5 / M_p * e / a^(13/2) - - with the paper convention Im(k2) < 0 for tidal dissipation (the - paper's Eq. 4 makes -Im(k2) the positive dissipation efficiency). - The PROTEUS source uses positive Imk2 in its calling convention, so - the formula evaluated with Imk2 = +1 here returns a positive de/dt; - documented as a known-sign-convention item in the source docstring. - - Discriminating evaluation point: Imk2 = Mst = G = Rpl = Mpl = 1, - a = 2, e = 0.5 gives - - de/dt = (21/2) * 0.5 / 2**6.5 = 5.7996e-2 - - See ``docs/Validation/orbit/orbit.md`` for the validation registry - entry, including the sign-convention note. - """ - val = de_dt(a=2.0, e=0.5, params=_UNIT_PARAMS) - expected = (21.0 / 2.0) * 0.5 / (2.0**6.5) - assert val == pytest.approx(expected, rel=1e-12) - # Exponent-error guard: an off-by-one in the semi-major-axis - # exponent puts the result far outside the rel=1e-12 main assertion. - # Check a**5 (way too big at 0.164) AND a**7 (way too small at 0.041 - # but only 0.017 away from 0.058, hence a tighter threshold). The - # main pytest.approx(expected, rel=1e-12) above is the primary - # guard; these are explicit demonstrations that the chosen test - # point discriminates against the two closest neighbouring - # exponents. - wrong_a5 = (21.0 / 2.0) * 0.5 / (2.0**5) - wrong_a7 = (21.0 / 2.0) * 0.5 / (2.0**7) - assert abs(val - wrong_a5) > 0.05 - assert abs(val - wrong_a7) > 0.01 - # Sign guard: under the PROTEUS calling convention (positive Imk2) - # the RHS is positive. A flip would fail this. Note: this does NOT - # certify the convention matches Driscoll and Barnes; see source - # docstring. - assert val > 0.0 - # Scale guard: order of magnitude is ~6e-2. A unit-conversion bug - # (kg vs g, AU vs m) would land outside the [1e-3, 1.0] bracket. - assert 1e-3 < val < 1.0 - - -@pytest.mark.physics_invariant -def test_de_dt_scales_as_radius_to_the_fifth_power(): - """Doubling ``Rpl`` should multiply ``de_dt`` by 32, not 16 or 64.""" - p_small = (1.0, 1.0, 1.0, 1.0, 1.0) - p_big = (1.0, 1.0, 1.0, 2.0, 1.0) - ratio = de_dt(a=1.0, e=0.1, params=p_big) / de_dt(a=1.0, e=0.1, params=p_small) - assert ratio == pytest.approx(32.0, rel=1e-12) - # Exponent guards: 2**5 = 32; reject the neighbours 2**4 = 16 and - # 2**6 = 64. The base 2 choice makes adjacent-exponent regressions - # land at well-separated values. - assert abs(ratio - 16.0) > 10.0 - assert abs(ratio - 64.0) > 20.0 - - -@pytest.mark.physics_invariant -def test_de_dt_inverse_planet_mass_dependence(): - """``de_dt`` is inversely proportional to planet mass ``Mpl``.""" - p_light = (1.0, 1.0, 1.0, 1.0, 1.0) - p_heavy = (1.0, 1.0, 1.0, 1.0, 3.0) - val_light = de_dt(a=1.0, e=0.1, params=p_light) - val_heavy = de_dt(a=1.0, e=0.1, params=p_heavy) - ratio = val_light / val_heavy - assert ratio == pytest.approx(3.0, rel=1e-12) - # Monotonicity guard: heavier planet always damps slower under - # inverse-Mpl. A regression that put Mpl in the numerator would - # flip the inequality. - assert val_heavy < val_light - - -# --------------------------------------------------------------------------- -# da_dt: semi-major axis derivative -# --------------------------------------------------------------------------- - - -@pytest.mark.physics_invariant -def test_da_dt_is_zero_for_circular_orbit(): - """At ``e=0``, ``da_dt = 2 a e de_dt = 0`` regardless of ``a`` or params. - - A bug that dropped the ``e`` factor (``da_dt = 2 a de_dt``) would - return a nonzero value here. - """ - assert da_dt(a=10.0, e=0.0, params=_UNIT_PARAMS) == pytest.approx(0.0, abs=1e-12) - # Limit-input invariant: the fixed point is independent of a. A - # regression that recovered an a-dependent constant term at e=0 - # would only show at a different a value. - assert da_dt(a=0.5, e=0.0, params=_UNIT_PARAMS) == pytest.approx(0.0, abs=1e-12) - - -@pytest.mark.physics_invariant -def test_da_dt_obeys_kinematic_identity(): - """``da_dt = 2 a e * de_dt`` must hold exactly (mathematical identity).""" - a, e = 1.5, 0.3 - lhs = da_dt(a=a, e=e, params=_UNIT_PARAMS) - rhs = 2.0 * a * e * de_dt(a=a, e=e, params=_UNIT_PARAMS) - assert lhs == pytest.approx(rhs, rel=1e-14) - # Identity guard at a different (a, e): the relation must hold - # everywhere, not at one accidentally-coincidental point. Exercise - # at a second point well away from the first. - a2, e2 = 3.0, 0.7 - lhs2 = da_dt(a=a2, e=e2, params=_UNIT_PARAMS) - rhs2 = 2.0 * a2 * e2 * de_dt(a=a2, e=e2, params=_UNIT_PARAMS) - assert lhs2 == pytest.approx(rhs2, rel=1e-14) - - -@pytest.mark.physics_invariant -def test_da_dt_quadratic_in_eccentricity(): - """Together with the de_dt linearity, ``da_dt`` is quadratic in ``e``.""" - base = da_dt(a=1.0, e=0.01, params=_UNIT_PARAMS) - scaled = da_dt(a=1.0, e=0.04, params=_UNIT_PARAMS) - # (0.04 / 0.01)**2 = 16 - assert scaled == pytest.approx(16.0 * base, rel=1e-12) - # Exponent guard: linear-in-e (ratio 4) and cubic (ratio 64) are - # both rejected by the absolute gap from 16. The base 4x in e - # makes adjacent-exponent landings well-separated. - assert abs(scaled / base - 4.0) > 5.0 - assert abs(scaled / base - 64.0) > 20.0 - - -# --------------------------------------------------------------------------- -# orbitals: ODE wrapper -# --------------------------------------------------------------------------- - - -def test_orbitals_returns_da_then_de_in_that_order(): - """``orbitals`` must return ``[da_dt, de_dt]``: order matters for solve_ivp. - - A swapped order would silently corrupt the integration trajectory. - """ - z = [1.5, 0.3] - out = orbitals(t=0.0, z=z, params=_UNIT_PARAMS) - assert out[0] == da_dt(a=z[0], e=z[1], params=_UNIT_PARAMS) - assert out[1] == de_dt(a=z[0], e=z[1], params=_UNIT_PARAMS) - assert len(out) == 2 - - -def test_orbitals_ignores_explicit_time_argument(): - """The system is autonomous: the same state at different ``t`` gives the same RHS.""" - z = [1.5, 0.3] - a_early = orbitals(t=0.0, z=z, params=_UNIT_PARAMS) - a_late = orbitals(t=1e9, z=z, params=_UNIT_PARAMS) - assert a_early == a_late - # Autonomy invariant: hold at a third time too. A regression that - # picked up a time-dependent term (e.g. a stray dt in the - # right-hand side) would generally fail one of the three - # equalities even if two happened to coincide. - a_mid = orbitals(t=1e3, z=z, params=_UNIT_PARAMS) - assert a_mid == a_early - - -# --------------------------------------------------------------------------- -# evolve_orbital: top-level orchestrator that mutates hf_row in place -# --------------------------------------------------------------------------- - - -def _make_config(semimajoraxis_au: float = 1.0, eccentricity: float = 0.05) -> Any: - return cast( - Any, - SimpleNamespace( - orbit=SimpleNamespace( - semimajoraxis=semimajoraxis_au, - eccentricity=eccentricity, - ) - ), - ) - - -def _make_hf_row( - *, - time: float, - sma_m: float = AU, - ecc: float = 0.1, - Imk2: float = 1e-3, - M_star: float = 1.989e30, - R_int: float = 6.371e6, - M_int: float = 5.972e24, -) -> dict: - return { - 'Time': time, - 'semimajorax': sma_m, - 'eccentricity': ecc, - 'Imk2': Imk2, - 'M_star': M_star, - 'R_int': R_int, - 'M_int': M_int, - } - - -@pytest.mark.physics_invariant -@pytest.mark.reference_pinned -def test_evolve_orbital_first_call_seeds_from_config_with_au_conversion(): - """On the first call (``Time <= 1``) the orchestrator must seed - ``hf_row`` from ``config``, applying the AU to m conversion to the - semi-major axis. The pin against ``0.5 * AU`` (~7.48e10 m) catches - a regression that forgot the AU factor (which would leave - ``semimajorax`` at 0.5 instead of ~7.48e10). - - See ``docs/Validation/orbit/orbit.md`` for the validation registry - entry. - """ - cfg = _make_config(semimajoraxis_au=0.5, eccentricity=0.2) - hf_row = _make_hf_row(time=0.0, sma_m=999.0, ecc=999.0) # garbage that must be overwritten - evolve_orbital(hf_row, cfg, dt=1.0) - assert hf_row['semimajorax'] == pytest.approx(0.5 * AU, rel=1e-12) - assert hf_row['eccentricity'] == pytest.approx(0.2, rel=1e-12) - # Explicit scale guard: a missing AU factor would leave the value - # at 0.5; anything below 1e9 m would be sub-stellar-radius for any - # real system. The lower bound discriminates AU-vs-meter slip. - assert hf_row['semimajorax'] > 1e10 - - -@pytest.mark.parametrize('time', [0.0, 0.5, 1.0]) -def test_evolve_orbital_first_call_boundary_inclusive(time): - """The ``Time <= 1`` boundary is inclusive: at exactly 1 yr the - config-seed branch must still fire. - """ - cfg = _make_config(semimajoraxis_au=0.3, eccentricity=0.01) - hf_row = _make_hf_row(time=time, sma_m=42.0) - evolve_orbital(hf_row, cfg, dt=0.1) - assert hf_row['semimajorax'] == pytest.approx(0.3 * AU) - # Config-seed branch must also overwrite the garbage eccentricity - # in hf_row with the config value. A regression that gated the - # eccentricity seed on a different boundary would leave - # hf_row['eccentricity'] at the _make_hf_row default of 0.1. - assert hf_row['eccentricity'] == pytest.approx(0.01, rel=1e-12) - - -@pytest.mark.physics_invariant -def test_evolve_orbital_zero_imk2_preserves_sma_and_eccentricity(): - """With ``Imk2 = 0`` the right-hand sides vanish identically; the - integrator must leave ``sma`` and ``ecc`` unchanged regardless of - the step length. - """ - cfg = _make_config() - hf_row = _make_hf_row(time=1e6, sma_m=2.0 * AU, ecc=0.4, Imk2=0.0) - evolve_orbital(hf_row, cfg, dt=1e5) - assert hf_row['semimajorax'] == pytest.approx(2.0 * AU) - assert hf_row['eccentricity'] == pytest.approx(0.4) - - -@pytest.mark.physics_invariant -def test_evolve_orbital_zero_eccentricity_is_a_fixed_point(): - """``e = 0`` is a fixed point of the system; with positive ``Imk2`` - the integrator must keep the orbit circular and ``sma`` unchanged. - """ - cfg = _make_config() - hf_row = _make_hf_row(time=1e6, sma_m=AU, ecc=0.0, Imk2=1e-2) - evolve_orbital(hf_row, cfg, dt=1e3) - assert hf_row['eccentricity'] == pytest.approx(0.0, abs=1e-30) - assert hf_row['semimajorax'] == pytest.approx(AU) - - -@pytest.mark.physics_invariant -def test_evolve_orbital_returns_finite_for_high_eccentricity(): - """Adversarial near-unity eccentricity: the orchestrator must not - emit NaN or inf for an aggressive but physically valid input. - """ - cfg = _make_config() - hf_row = _make_hf_row(time=10.0, sma_m=AU, ecc=0.95, Imk2=1e-6) - evolve_orbital(hf_row, cfg, dt=1.0) - assert np.isfinite(hf_row['semimajorax']) - assert np.isfinite(hf_row['eccentricity']) - - -def test_evolve_orbital_mutates_hf_row_in_place(): - """The orchestrator mutates ``hf_row`` and returns ``None``; it - must not silently return a new dict. - """ - cfg = _make_config(semimajoraxis_au=0.7, eccentricity=0.03) - hf_row = _make_hf_row(time=0.0) - result = evolve_orbital(hf_row, cfg, dt=1.0) - assert result is None - assert hf_row['semimajorax'] == pytest.approx(0.7 * AU) diff --git a/tests/orbit/test_satellite.py b/tests/orbit/test_satellite.py index 615f72ab8..bfeccd3c8 100644 --- a/tests/orbit/test_satellite.py +++ b/tests/orbit/test_satellite.py @@ -1,24 +1,67 @@ -"""Unit tests for the satellite-orbit evolution module -(``proteus.orbit.satellite``), based on Korenaga (2023) Eqs. 58-59. - -Exercises the right-hand sides ``dω_dt`` and ``da_dt``, the angular- -momentum bookkeeping ``Ltot``, the ``orbitals`` wrapper used by -``scipy.integrate.solve_ivp``, and the ``update_satellite`` -orchestrator that mutates ``hf_row`` in place. - -Anti-happy-path coverage: - -- Zero tidal-power input must yield zero rotational and orbital - evolution: a fixed-point check that holds regardless of - integrator tolerance. -- Angular-momentum book-keeping (``Ltot``) must scale linearly in - ``ω`` and as ``a**0.5`` in the orbital term; both exponents are - pinned to discriminating numeric values. -- ``orbitals`` must return ``[da_dt, dω_dt]`` in that order - (a swap would silently corrupt the integration). -- ``update_satellite`` first-call branch must convert the user-set - ``axial_period`` from hours to seconds, and must fall back to - spin-orbit-resonance when the config requests it. +"""Unit tests for the planet-satellite tidal orbit evolution module +(``proteus.orbit.satellite``). + +Structure mirrors ``tests/orbit/test_orbit.py``: each model's ODE +right-hand sides are private closures (``Ltot``/``dw_dt``/``da_dt`` +nested in ``ps0d``; ``domega_dt``/``dE_dt``/``orbitals`` nested in +``ps1d``/``ps1d_evec``), so they are tested black-box through their +public entry points, using finite-difference probes for the closed- +form algebra checks and real (non-trivial) integration steps for the +conservation checks. + +Exercises: + +- ``compute_a_res_prime``, ``_state_is_valid``, ``_in_evection_band``, + ``_flush_fine_evection_csv``: pure/file-I/O helpers, tested directly. +- ``ps0d`` (Korenaga 2023 Icarus 400, 115564, Eqs. 58-60): the + ``Ltot`` M_sat-vs-M_planet discrimination guard the source itself + asks for (see the comment above ``Ltot``'s ``return`` statement), + the Eq. 58/59 fixed point and closed-form pin, and the + ``current_time <= 10`` angular-momentum bootstrap. +- ``ps1d``: zero-dissipation fixed point, and total angular momentum + (now THREE components: planet spin + satellite spin + orbital) + conservation -- using both the raw-total and the delta-cancellation + formulation, since (as found for ``sp1d`` in ``orbit.py``) the raw + total is dominated by the orbital term and is comparatively blind + to a bug confined to one spin-coupling term. +- ``ps1d_evec``: the "three clocks" model (PROTEUS's coarse elapsed + time -> evolve_orbit_satellite's adaptive substep controller -> + solve_ivp's own internal adaptive stepping -> the storage-clock + throttle in _flush_fine_evection_csv). ``filter_value=0`` (out of + band) reduces it EXACTLY to ps1d's physics: evection angle frozen, + same 3-component total AM conserved to machine precision, verified + here, not assumed. ``filter_value=1`` (in band) activates the + star's secular/evection torque: the evection angle evolves and the + planet-satellite subsystem's own AM is NOT expected to be conserved + (a real three-body exchange with the star) -- checked numerically + and documented as physical, not asserted as a false invariant. + Also covers the wiring from ``_in_evection_band``'s live result + through to ``ps1d_evec``'s ``filter_value`` (with ``ps1d_evec`` + itself mocked out, since that wiring check has nothing to do with + its internal physics), and the storage-clock density + contrast (dense in-band vs throttled out-of-band) through the real + ``evolve_orbit_satellite`` -> ``ps1d_evec`` pipeline. The CPL + resonance-physics literature comparison against a published case is + out of scope for this file (a separate, long-running test). +- ``evolve_orbit_satellite``: dispatch to each model (and the + unrecognized-model error), the accept/reject adaptive-step + controller (forcing a rejection to observe the rollback), the + documented C_planet angular-momentum-conserving spin rescale on a + structural (interior) change between calls -- including with real + (non-quiescent) ps0d dynamics running across the change -- and + controller-state persistence (``tides_o.dt_yr``/ + ``tides_o.resonance_state``) across calls, and that ``hf_row`` carries + exactly one evection column (``evection_dt_cap_yr``), not the + ``in_evection_band``/``near_evection_band`` flags of an earlier design. + +The evection dt-cap computation itself (``proteus.orbit.timestep``'s +``_evection_rate_cap_yr``/``_estimate_evection_dt_cap_yr``) has its own +test file, ``tests/orbit/test_timestep.py``, mirroring the source split. + +See also: +- docs/How-to/test_infrastructure.md +- docs/How-to/test_building.md +- docs/How-to/test_categorization.md """ from __future__ import annotations @@ -29,329 +72,1669 @@ import numpy as np import pytest +import proteus.orbit.hansen as hansen_mod +from proteus.config._orbit import OrbitSolver +from proteus.orbit.common import Tides_t from proteus.orbit.satellite import ( - Ltot, - da_dt, - dω_dt, - orbitals, - update_satellite, + _flush_fine_evection_csv, + _in_evection_band, + _solve_e_stationary, + _state_is_valid, + compute_a_res_prime, + evolve_orbit_satellite, + ps0d, + ps1d, + ps1d_evec, ) -from proteus.utils.constants import const_G, secs_per_hour +from proteus.utils.constants import M_earth, R_earth, const_G, secs_per_year pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] +# Minimal config stand-in exposing only config.orbit.solver, for tests that +# call ps0d/ps1d/ps1d_evec directly (not through evolve_orbit_satellite's +# dispatch). +_SOLVER_CONFIG = cast(Any, SimpleNamespace(orbit=SimpleNamespace(solver=OrbitSolver()))) + + +# --------------------------------------------------------------------------- +# compute_a_res_prime +# --------------------------------------------------------------------------- + + +def test_compute_a_res_prime_matches_closed_form_at_earth_like_spin(): + """``a_res = (Lambda * s' / (1 - e^2))**(4/7)``, ``s' = Omega_p / + Omega_earth``. Pinned at Earth's own present-day spin (so + ``s' = 1``) and ``e = 0``, where the formula collapses to + ``Lambda**(4/7)``. + """ + omega_earth = np.sqrt(const_G * M_earth / R_earth**3) + hf_row = { + 'eccentricity_sat': 0.0, + 'axial_period': 2 * np.pi / omega_earth, + 'M_int': M_earth, + 'R_int': R_earth, + } + lam = np.sqrt(1.5 * 0.315 * omega_earth / (2 * np.pi / secs_per_year)) + expected = lam ** (4.0 / 7.0) + assert compute_a_res_prime(hf_row) == pytest.approx(expected, rel=1e-10) + # Discrimination: a doubled spin rate must NOT double a_res (the + # exponent is 4/7 on s', not 1); pin the ratio explicitly. + hf_row_fast = { + 'eccentricity_sat': 0.0, + 'axial_period': 2 * np.pi / (2 * omega_earth), + 'M_int': M_earth, + 'R_int': R_earth, + } + ratio = compute_a_res_prime(hf_row_fast) / compute_a_res_prime(hf_row) + assert ratio == pytest.approx(2.0 ** (4.0 / 7.0), rel=1e-10) + assert abs(ratio - 2.0) > 0.3 # rejects a linear-in-s' regression -# Reusable parameter tuple (I, L, G, Mpl, Msa, dE_tidal). -# Use simple unit-scaled values so algebra is easy to verify. -def _params(I=1.0, L=10.0, G=1.0, Mpl=1.0, Msa=0.1, dE=1.0): - return (I, L, G, Mpl, Msa, dE) + +def test_compute_a_res_prime_increases_with_eccentricity(): + """``1/(1-e^2)`` grows with ``e``, so ``a_res`` is monotonically + increasing in eccentricity at fixed spin (a boundedness/ + monotonicity invariant, not just a point pin).""" + omega_earth = np.sqrt(const_G * M_earth / R_earth**3) + axial_period = 2 * np.pi / omega_earth + planet = {'M_int': M_earth, 'R_int': R_earth} + a_res_circular = compute_a_res_prime( + {'eccentricity_sat': 0.0, 'axial_period': axial_period, **planet} + ) + a_res_eccentric = compute_a_res_prime( + {'eccentricity_sat': 0.5, 'axial_period': axial_period, **planet} + ) + assert a_res_eccentric > a_res_circular + # Edge case: near-parabolic e must not raise (only warn/produce a + # large-but-finite value); errstate(invalid='ignore') is only for + # e >= 1 exactly, so use a merely-large e here. + a_res_extreme = compute_a_res_prime( + {'eccentricity_sat': 0.9, 'axial_period': axial_period, **planet} + ) + assert np.isfinite(a_res_extreme) + assert a_res_extreme > a_res_eccentric # --------------------------------------------------------------------------- -# Ltot: angular momentum bookkeeping +# _solve_e_stationary # --------------------------------------------------------------------------- @pytest.mark.physics_invariant -def test_ltot_returns_zero_when_omega_and_a_are_zero(): - """``L = I omega + Mpl (G(Mpl+Msa) a)**0.5``: both terms vanish at - ``omega = 0`` and ``a = 0``.""" - assert Ltot(ω=0.0, a=0.0, params=_params()) == pytest.approx(0.0, abs=1e-12) - # Limit-input invariant: when only omega is zero but a is positive, - # the orbital term must remain. A regression that masked Ltot to - # zero on either-zero input would fail here. - assert Ltot(ω=0.0, a=1.0, params=_params()) > 0.0 +def test_solve_e_stationary_returns_nan_for_non_finite_inputs(): + """Limit-input guard: a non-finite ``a_prime`` or ``s_prime`` must + return NaN directly, without ever calling the root-finder (which + would itself raise on a non-finite bracket).""" + assert np.isnan(_solve_e_stationary(np.nan, 1.0, 1.0, 1.0)) + assert np.isnan(_solve_e_stationary(1.0, np.inf, 1.0, 1.0)) -@pytest.mark.physics_invariant -def test_ltot_is_linear_in_spin(): - """The spin term is ``I omega``; doubling ``omega`` adds exactly - ``I d_omega`` to L.""" - base = Ltot(ω=1.0, a=1.0, params=_params()) - boosted = Ltot(ω=3.0, a=1.0, params=_params()) - # The orbital term cancels in the difference, leaving ``I * d_omega = 2``. - assert boosted - base == pytest.approx(2.0, rel=1e-12) - # Linearity guard at a third spin value: L(omega=5) - L(omega=1) - # must give I*(5-1) = 4. A regression that introduced quadratic - # omega dependence would fail one of the two equalities. - extra = Ltot(ω=5.0, a=1.0, params=_params()) - assert extra - base == pytest.approx(4.0, rel=1e-12) +def test_solve_e_stationary_returns_nan_when_f_does_not_bracket_a_root(): + """When ``f(lo)`` and ``f(hi)`` have the same sign (no root in + ``(0, 1)``), the function must return NaN rather than letting + brentq raise a bracketing error. ``Lambda=0`` makes every term but + the ``-1`` constant vanish, so ``f`` is exactly ``-1`` everywhere: + same sign at both ends, no root. + """ + result = _solve_e_stationary(a_prime=5.0, s_prime=1.0, Lambda=0.0, Omega_ratio=0.5) + assert np.isnan(result) + # Discrimination: a genuinely different (Lambda != 0) case at the + # same a_prime/s_prime/Omega_ratio DOES bracket a root (see the + # test below) -- confirming this NaN is specific to the no-root + # condition, not a blanket failure of the function for these inputs. + other = _solve_e_stationary(a_prime=8.0, s_prime=5.0, Lambda=1.2, Omega_ratio=0.05) + assert np.isfinite(other) @pytest.mark.physics_invariant -def test_ltot_orbital_term_scales_as_sqrt_a(): - """Orbital term is ``Msa * sqrt(G * (Mpl+Msa) * a)``, scaling as - ``a**0.5``. Multiplying ``a`` by 4 must multiply the orbital - contribution by 2.0, not 4.0 or 16.0. - """ - # Strip out the spin term by setting omega = 0. - L_small = Ltot(ω=0.0, a=1.0, params=_params()) - L_big = Ltot(ω=0.0, a=4.0, params=_params()) - ratio = L_big / L_small - assert ratio == pytest.approx(2.0, rel=1e-12) - # Exponent guards: sqrt(4) = 2; linear-in-a gives 4, quadratic gives - # 16. Both wrong-exponent landings are well separated from 2. - assert abs(ratio - 4.0) > 1.0 - assert abs(ratio - 16.0) > 10.0 +def test_solve_e_stationary_finds_a_genuine_root_in_bounds(): + """With physically reasonable inputs (comparable in scale to the + Earth-Moon-Sun system this model targets), a real root must exist + and satisfy the defining equation to solver tolerance -- not just + return some finite number in range. + """ + a_prime, s_prime, Lambda, Omega_ratio = 8.0, 5.0, 1.2, 0.05 + e_s = _solve_e_stationary(a_prime, s_prime, Lambda, Omega_ratio) + assert np.isfinite(e_s) + assert 0.0 < e_s < 1.0 + + def f(e): + return ( + Lambda**2 * s_prime**2 / (a_prime**3.5 * (1.0 - e**2) ** 2) + - 1.0 + - 3.0 * np.sqrt(1.0 - e**2) * a_prime**1.5 * Omega_ratio + ) + + assert abs(f(e_s)) < 1e-8 # --------------------------------------------------------------------------- -# dω_dt: spin derivative +# _state_is_valid # --------------------------------------------------------------------------- +def test_state_is_valid_accepts_a_physically_sane_state(): + """A realistic Earth-Moon-like state with finite spin periods and + e in-range must be accepted.""" + hf_row = { + 'semimajorax_sat': 3.844e8, + 'eccentricity_sat': 0.05, + 'axial_period': 86400.0, + 'axial_period_sat': 2.36e6, + } + assert _state_is_valid(hf_row) is True + + @pytest.mark.physics_invariant -def test_d_omega_dt_vanishes_when_dE_tidal_is_zero(): - """No tidal dissipation gives no spin-down (numerator is zero).""" - assert dω_dt(a=1.0, ω=1.0, params=_params(dE=0.0)) == pytest.approx(0.0, abs=1e-12) - # Limit-input invariant: the zero result must be independent of - # the (a, omega) operating point. Exercise a second state to - # rule out an accidental cancellation at (1, 1). - assert dω_dt(a=2.5, ω=0.7, params=_params(dE=0.0)) == pytest.approx(0.0, abs=1e-12) +def test_state_is_valid_rejects_satellite_inside_planet(): + """A semimajor axis at or below ``1.05 R_earth`` (the satellite + has effectively spiralled into the planet) is rejected -- a hard + physical floor, not a solver-tolerance artifact. + """ + hf_row = { + 'semimajorax_sat': 1.0 * R_earth, + 'eccentricity_sat': 0.05, + 'axial_period': 86400.0, + 'axial_period_sat': 2.36e6, + 'R_planet': R_earth, + } + assert _state_is_valid(hf_row) is False + # Discrimination: just above the floor must be accepted -- pins + # the boundary itself, not merely "small values are rejected". + hf_row_ok = dict(hf_row, semimajorax_sat=1.06 * R_earth) + assert _state_is_valid(hf_row_ok) is True @pytest.mark.physics_invariant -def test_d_omega_dt_sign_follows_negative_dE_tidal(): - """The formula prepends a minus sign on ``dE_tidal``: with - ``dE_tidal > 0`` the spin derivative must be negative. +@pytest.mark.parametrize('bad_e', [-0.01, 0.999, 1.5, float('nan')]) +def test_state_is_valid_rejects_eccentricity_outside_0_to_0p999(bad_e): + """Eccentricity must lie in ``[0, 0.999)``: negative, exactly at + or beyond the sub-parabolic ceiling, and NaN are all rejected. + """ + hf_row = { + 'semimajorax_sat': 3.844e8, + 'eccentricity_sat': bad_e, + 'axial_period': 86400.0, + 'axial_period_sat': 2.36e6, + } + assert _state_is_valid(hf_row) is False + + +def test_state_is_valid_rejects_non_finite_spin_period(): + """A non-finite planet or satellite spin period is rejected even + when ``a``/``e`` are otherwise fine.""" + base = {'semimajorax_sat': 3.844e8, 'eccentricity_sat': 0.05} + assert _state_is_valid(dict(base, axial_period=np.nan, axial_period_sat=2.36e6)) is False + assert _state_is_valid(dict(base, axial_period=86400.0, axial_period_sat=np.inf)) is False + + +# --------------------------------------------------------------------------- +# _in_evection_band: debounced hysteretic detector +# --------------------------------------------------------------------------- + + +def test_in_evection_band_enters_when_within_margin_and_exits_when_far(monkeypatch): + """A two-step history that stays within ``margin_enter`` of + ``a_res`` must activate the band; once active, moving far outside + even the wider ``margin_exit`` must deactivate it (asymmetric + Schmitt-trigger margins, not a single threshold). + """ + from proteus.orbit import satellite as sat_mod + + # Pin a_res_prime to a known constant so the band geometry is + # controlled by semimajorax_sat alone, independent of spin/e. + monkeypatch.setattr(sat_mod, 'compute_a_res_prime', lambda hf_row: 60.0) + + state = {} + # Two consecutive calls at a'=60.03 R_earth: 0.05% inside a_res=60. + hf_row = {'semimajorax_sat': 60.03 * R_earth} + assert _in_evection_band(hf_row, state, margin_enter=0.10, margin_exit=0.35) is True + assert _in_evection_band(hf_row, state, margin_enter=0.10, margin_exit=0.35) is True + + # Now move far outside even the wide exit margin (a' = 100, i.e. + # d_a_rel = 0.667 >> 0.35): must deactivate. + hf_row_far = {'semimajorax_sat': 100.0 * R_earth} + assert _in_evection_band(hf_row_far, state, margin_enter=0.10, margin_exit=0.35) is False + + +def test_in_evection_band_hysteresis_keeps_active_within_exit_margin(monkeypatch): + """Once active, a displacement that would have failed the + (narrower) entry margin but still satisfies the (wider) exit + margin must NOT deactivate the band -- this asymmetry is the + entire point of the Schmitt trigger (prevents rapid toggling). + """ + from proteus.orbit import satellite as sat_mod + + monkeypatch.setattr(sat_mod, 'compute_a_res_prime', lambda hf_row: 60.0) + + state = {} + hf_row_in = {'semimajorax_sat': 60.0 * R_earth} + assert _in_evection_band(hf_row_in, state, margin_enter=0.10, margin_exit=0.35) is True + + # d_a_rel = 0.20: outside the 0.10 entry margin, inside the 0.35 + # exit margin. Because the band is already active, this must + # stay active (uses margin_exit, not margin_enter). + hf_row_mid = {'semimajorax_sat': 72.0 * R_earth} + assert _in_evection_band(hf_row_mid, state, margin_enter=0.10, margin_exit=0.35) is True + + +def test_in_evection_band_handles_non_finite_a_res_gracefully(): + """A non-finite or zero ``a_res`` (e.g. e -> 1 upstream) must + deactivate the band and clear history rather than raise or emit + NaN comparisons. + """ + state = {'active': True, 'hist_d_a_rel': [0.05, 0.06]} + # e = 1.0 exactly makes (1 - e**2) == 0.0, so a_res_prime's + # division produces +inf (suppressed RuntimeWarning), not merely + # a large finite value -- the actual branch under test. + hf_row = { + 'semimajorax_sat': 60.0 * R_earth, + 'eccentricity_sat': 1.0, + 'axial_period': 86400.0, + 'M_int': M_earth, + 'R_int': R_earth, + } + with np.errstate(divide='ignore'): + result = _in_evection_band(hf_row, state, margin_enter=0.10, margin_exit=0.35) + assert result is False + assert state['active'] is False + assert state['hist_d_a_rel'] == [] + # A non-finite a_res must not leave a stale finite distance behind -- + # the pre-emptive near_evection_band consumer needs +inf here, not + # the last successfully computed value, so it correctly reads "not + # near" rather than latching onto history. + assert state['d_a_rel_now'] == np.inf + + +def test_in_evection_band_stashes_raw_distance_for_the_approach_margin(monkeypatch): + """``_in_evection_band`` must record the raw (undebounced, unsmoothed) + relative distance under ``resonance_state['d_a_rel_now']`` on every + call -- the primitive that ``evolve_orbit_satellite`` derives + ``near_evection_band`` from, using a wider margin than the + hysteretic ``active`` flag this function itself returns. """ - val = dω_dt(a=1.0, ω=1.0, params=_params(dE=1.0)) - assert val < 0.0 - # Sign symmetry: flipping the sign of dE_tidal must flip the sign - # of the spin derivative. A regression that lost the linear-in-dE - # dependence would give the same sign for both. - val_neg = dω_dt(a=1.0, ω=1.0, params=_params(dE=-1.0)) - assert val_neg > 0.0 + from proteus.orbit import satellite as sat_mod + + monkeypatch.setattr(sat_mod, 'compute_a_res_prime', lambda hf_row: 60.0) + + state = {} + # a' = 72 R_earth vs a_res = 60: d_a_rel = 0.20 exactly. + hf_row = {'semimajorax_sat': 72.0 * R_earth} + active = _in_evection_band(hf_row, state, margin_enter=0.10, margin_exit=0.35) + + assert state['d_a_rel_now'] == pytest.approx(0.20, rel=1e-12) + # Discrimination: 0.20 is outside the (narrower) entry margin the + # hysteretic flag uses, so the flag itself must stay inactive even + # though a wider approach margin (e.g. 0.30) would already call this + # "near" from the stashed raw value alone. + assert active is False # --------------------------------------------------------------------------- -# da_dt: semi-major axis derivative +# _flush_fine_evection_csv: dedup + storage-clock throttle, real file I/O # --------------------------------------------------------------------------- +def _make_fine_entry(t_abs_yr, n=None): + t_abs_yr = np.asarray(t_abs_yr, dtype=float) + n = len(t_abs_yr) if n is None else n + return { + 't_abs_yr': t_abs_yr, + 'omega_p': np.full(n, 7.27e-5), + 'omega_s': np.full(n, 2.5e-6), + 'sma': np.full(n, 3.844e8), + 'ecc': np.full(n, 0.05), + 'phi': np.full(n, 0.0), + 'da_planet_tide_cum': np.zeros(n), + 'da_sat_tide_cum': np.zeros(n), + 'de_planet_tide_cum': np.zeros(n), + 'de_sat_tide_cum': np.zeros(n), + 'filter': np.ones(n), + } + + +def test_flush_fine_evection_csv_in_band_keeps_every_sample(tmp_path): + """When ``in_band`` is True, every sample surviving the dedup + filter is written -- no storage-clock throttling.""" + tides_o = Tides_t() + entry = _make_fine_entry([1.0, 2.0, 3.0]) + _flush_fine_evection_csv( + tides_o, str(tmp_path), entry, in_band=True, storage_target_interval_yr=100.0 + ) + + csv_path = tmp_path / 'fine_evection_data.csv' + lines = csv_path.read_text().splitlines() + assert len(lines) == 1 + 3 # header + 3 rows, none throttled + assert tides_o.fine_csv_last_t_yr == pytest.approx(3.0) + + +def test_flush_fine_evection_csv_out_of_band_throttles_to_target_spacing(tmp_path): + """Out of band, only samples reaching/crossing the storage target + are kept; the target then advances from the KEPT sample's time, + not blindly by a fixed increment. + """ + tides_o = Tides_t() + entry = _make_fine_entry([1.0, 2.0, 3.0, 20.0, 21.0, 50.0]) + _flush_fine_evection_csv( + tides_o, str(tmp_path), entry, in_band=False, storage_target_interval_yr=10.0 + ) + csv_path = tmp_path / 'fine_evection_data.csv' + lines = csv_path.read_text().splitlines() + # Header + kept rows. First target is -inf -> t=1.0 is kept + # (starts the cursor at 1+10=11); 2,3 are dropped (< 11); 20.0 + # crosses 11 -> kept, cursor advances to 20+10=30; 21.0 dropped + # (< 30); 50.0 crosses 30 -> kept. + assert len(lines) - 1 == 3 + kept_times = [float(line.split(',')[0]) for line in lines[1:]] + assert kept_times == pytest.approx([1.0, 20.0, 50.0]) + + +def test_flush_fine_evection_csv_dedup_drops_samples_at_or_before_last_write(tmp_path): + """A sample at or before the persisted ``tides_o.fine_csv_last_t_yr`` + cursor (e.g. a duplicate boundary sample from the previous + accepted call) is dropped regardless of the in-band/out-of-band + policy. + """ + tides_o = Tides_t(fine_csv_last_t_yr=5.0) + entry = _make_fine_entry([4.0, 5.0, 6.0, 7.0]) + _flush_fine_evection_csv( + tides_o, str(tmp_path), entry, in_band=True, storage_target_interval_yr=100.0 + ) + + csv_path = tmp_path / 'fine_evection_data.csv' + lines = csv_path.read_text().splitlines() + kept_times = [float(line.split(',')[0]) for line in lines[1:]] + # 4.0 and 5.0 (<= last_t) dropped; 6.0 and 7.0 kept. + assert kept_times == pytest.approx([6.0, 7.0]) + assert tides_o.fine_csv_last_t_yr == pytest.approx(7.0) + + +def test_flush_fine_evection_csv_no_kept_samples_writes_no_file(tmp_path): + """If every sample is deduped away, no file is created at all (not + an empty/header-only file).""" + tides_o = Tides_t(fine_csv_last_t_yr=100.0) + entry = _make_fine_entry([1.0, 2.0, 3.0]) + _flush_fine_evection_csv( + tides_o, str(tmp_path), entry, in_band=True, storage_target_interval_yr=100.0 + ) + assert not (tmp_path / 'fine_evection_data.csv').exists() + + +def test_flush_fine_evection_csv_empty_entry_is_a_no_op(tmp_path): + """An entry with zero samples returns immediately without writing + a file or touching the cursors.""" + tides_o = Tides_t() + entry = _make_fine_entry([]) + _flush_fine_evection_csv( + tides_o, str(tmp_path), entry, in_band=True, storage_target_interval_yr=100.0 + ) + assert not (tmp_path / 'fine_evection_data.csv').exists() + assert tides_o.fine_csv_last_t_yr is None + + +# --------------------------------------------------------------------------- +# ps0d: Korenaga (2023) Icarus 400, 115564, Eqs. 58-60 (single satellite, +# planet spin + satellite orbit; no satellite spin state). Ltot/dw_dt/da_dt +# are private closures, probed black-box through the public ps0d(hf_row, dt) +# entry point via finite differences (calibrated below: rates converge +# cleanly from dt_yr=1e4 to 1e8, so dt_yr=1e5 is used throughout). +# --------------------------------------------------------------------------- + +_PS0D_RPL = 6.371e6 # R_earth +_PS0D_MPL = 5.972e24 # M_earth +_PS0D_MSA = 7.342e22 # M_moon +_PS0D_SMA = 3.844e8 # Earth-Moon distance, m +_PS0D_AXIAL_PERIOD = 86400.0 # 1 day +_PS0D_I = 2.0 / 5.0 * _PS0D_MPL * _PS0D_RPL**2 # uniform-sphere moment of inertia +_PS0D_FD_DT_YR = 1e5 + + +def _ps0d_korenaga_L(omega, sma): + """Korenaga (2023) Eq. 60, computed independently of the source + (no call into ps0d/Ltot): the orbital prefactor is the SATELLITE + mass, not the planet mass.""" + return _PS0D_I * omega + _PS0D_MSA * (const_G * (_PS0D_MPL + _PS0D_MSA) * _PS0D_SMA) ** 0.5 + + +def _make_ps0d_hf_row( + *, time=100.0, L=None, F_tidal=1e-3, sma=_PS0D_SMA, axial_period=_PS0D_AXIAL_PERIOD +): + if L is None: + L = _ps0d_korenaga_L(2 * np.pi / axial_period, sma) + return { + 'R_int': _PS0D_RPL, + 'M_int': _PS0D_MPL, + 'M_sat': _PS0D_MSA, + 'semimajorax_sat': sma, + 'axial_period': axial_period, + 'plan_sat_am': L, + 'F_tidal': F_tidal, + 'Time': time, + # get_C_planet's fallback for a missing hf_row entry. + 'core_density': 5500.0, + # ps0d's ODE now reads its moment-of-inertia coefficient from + # here directly (matching ps1d/ps1d_evec), not a fixed + # uniform-sphere approximation computed internally. + 'C_int': _PS0D_I, + } + + +def _ps0d_instantaneous_rates(**hf_row_kwargs): + """Probe ps0d's private da_dt/dw_dt via finite difference over a + short (but well-converged, see module docstring) step. Returns + ``(da_dt, domega_dt)`` in SI units. + """ + hf_row = _make_ps0d_hf_row(**hf_row_kwargs) + sma_before, axial_before = hf_row['semimajorax_sat'], hf_row['axial_period'] + omega_before = 2 * np.pi / axial_before + + ps0d(hf_row, dt=_PS0D_FD_DT_YR, config=_SOLVER_CONFIG) + + dt_s = _PS0D_FD_DT_YR * secs_per_year + da_dt = (hf_row['semimajorax_sat'] - sma_before) / dt_s + omega_after = 2 * np.pi / hf_row['axial_period'] + domega_dt = (omega_after - omega_before) / dt_s + return da_dt, domega_dt + + +@pytest.mark.reference_pinned @pytest.mark.physics_invariant -def test_da_dt_zero_when_dE_tidal_zero(): - """``da_dt = -2 I a / (L - I omega) * d_omega_dt``: zero whenever - ``d_omega_dt`` is zero.""" - assert da_dt(a=1.0, ω=1.0, params=_params(dE=0.0)) == pytest.approx(0.0, abs=1e-12) - # Limit-input invariant: zero-tidal-power must zero da/dt for any - # valid (a, omega). A regression that dropped the dE factor from - # the chain through dw/dt would emit a non-zero value at a - # different operating point. - assert da_dt(a=3.0, ω=0.4, params=_params(dE=0.0)) == pytest.approx(0.0, abs=1e-12) +def test_ps0d_bootstrap_am_uses_satellite_mass_not_planet_mass(): + """``ps0d``'s angular-momentum bootstrap (``current_time <= 10`` + and ``plan_sat_am == 0``) computes ``L`` via ``Ltot``, Korenaga + (2023) Eq. 60: the orbital term's prefactor is the SATELLITE mass + (the M_sat/M_planet -> 0 limit of the textbook reduced-mass + formula), not the planet mass -- this is exactly the discriminating + guard the source's own comment above ``Ltot``'s ``return`` + statement asks for. + + For Earth-Moon, M_planet/M_sat ~ 81, so a regression that + substituted M_planet for M_sat in the orbital term would inflate + the bootstrapped L by roughly that factor -- checked explicitly + below, not just approximated. + """ + hf_row = { + 'R_int': _PS0D_RPL, + 'M_int': _PS0D_MPL, + 'M_sat': _PS0D_MSA, + 'semimajorax_sat': _PS0D_SMA, + 'axial_period': _PS0D_AXIAL_PERIOD, + 'plan_sat_am': 0, # triggers the bootstrap + 'F_tidal': 0.0, + 'Time': 0.0, + # In production, evolve_orbit_satellite populates this via + # get_C_planet before calling ps0d; seeded directly here since + # this test calls ps0d in isolation. + 'C_int': _PS0D_I, + } + ps0d(hf_row, dt=1.0, config=_SOLVER_CONFIG) + + omega = 2 * np.pi / _PS0D_AXIAL_PERIOD + expected = _ps0d_korenaga_L(omega, _PS0D_SMA) + assert hf_row['plan_sat_am'] == pytest.approx(expected, rel=1e-6) + + # Discrimination: the M_planet-substituted orbital term. + wrong_orbital = _PS0D_MPL * (const_G * (_PS0D_MPL + _PS0D_MSA) * _PS0D_SMA) ** 0.5 + wrong_L = _PS0D_I * omega + wrong_orbital + assert abs(hf_row['plan_sat_am'] - wrong_L) / expected > 50.0 + # Scale guard: Eq. 60 for Earth-Moon lands at ~3.6e34 kg m^2/s + # (spin ~7e33 + orbital ~2.9e34); the M_planet substitution would + # land at ~2.4e36, two orders of magnitude above this bracket. + assert 1e34 < hf_row['plan_sat_am'] < 1e35 + + +def test_ps0d_bootstrap_only_fires_once_am_is_populated(): + """Once ``plan_sat_am`` is nonzero, a later call at ``Time <= 10`` + must NOT recompute/overwrite it via the bootstrap -- only the + ODE-evolved value should change it.""" + hf_row = { + 'R_int': _PS0D_RPL, + 'M_int': _PS0D_MPL, + 'M_sat': _PS0D_MSA, + 'semimajorax_sat': _PS0D_SMA, + 'axial_period': _PS0D_AXIAL_PERIOD, + 'plan_sat_am': 0, + 'F_tidal': 0.0, + 'Time': 0.0, + 'C_int': _PS0D_I, # see comment in the test above + } + ps0d(hf_row, dt=1.0, config=_SOLVER_CONFIG) + bootstrapped_L = hf_row['plan_sat_am'] + + # A second call, still at Time <= 10, with zero tidal power (a + # fixed point, see below) so L should not evolve either -- if the + # bootstrap incorrectly re-fired here, it would still land on the + # same value by construction of Ltot, so instead directly assert + # the bootstrap condition's guard: seed a deliberately WRONG + # sentinel L and confirm it is preserved (not silently replaced). + hf_row['Time'] = 5.0 + hf_row['plan_sat_am'] = 999.0 + hf_row['F_tidal'] = 0.0 + ps0d(hf_row, dt=1.0, config=_SOLVER_CONFIG) + assert hf_row['plan_sat_am'] == pytest.approx(999.0, rel=1e-12) + assert hf_row['plan_sat_am'] != pytest.approx(bootstrapped_L, rel=1e-3) + + +@pytest.mark.physics_invariant +def test_ps0d_domega_dt_vanishes_when_dE_tidal_is_zero(): + """No tidal dissipation gives no spin-down (Eq. 58's numerator is + zero): a fixed point of BOTH omega and, through the Eq. 59 + kinematic chain, the semimajor axis too. + """ + da_dt, domega_dt = _ps0d_instantaneous_rates(F_tidal=0.0) + assert domega_dt == pytest.approx(0.0, abs=1e-30) + assert da_dt == pytest.approx(0.0, abs=1e-15) @pytest.mark.physics_invariant -def test_da_dt_matches_korenaga_eq59_closed_form_value(): - """Pin ``da_dt`` against an independently hand-computed value of - Korenaga (2023) Eq. 59 at ``(I, L, G, Mpl, Msa, dE) = (1.5, 8.0, 1.0, - 1.0, 0.1, 1.0)`` and ``(a, ω) = (2.0, 0.5)``. - - Hand derivation: - dω/dt = -1 / (1.5*0.5 + 0.15 / (2*(8 - 0.75))) - = -1 / 0.76034482758... = -1.31519274376... - da/dt = -2*1.5*2 / 7.25 * dω/dt - = -0.82758620690... * -1.31519274376... - = 1.08843537414966... - - The pin is independent of the source (no call to ``dω_dt`` in the - test) and so discriminates an Eq. 59 rearrangement bug; a regression - that replaces the ``-2 I a`` prefactor by ``-3 I a`` would land at - ~1.63 instead of ~1.088, well outside the 1e-12 tolerance. - """ - params = _params(I=1.5, L=8.0) - expected = 1.08843537414966 - actual = da_dt(a=2.0, ω=0.5, params=params) - assert actual == pytest.approx(expected, rel=1e-12) - # Sign guard: dE > 0 and L > I*ω make dω/dt < 0 (spin slows), which - # combined with -2 I a / (L - I ω) < 0 must yield da/dt > 0 (orbit - # expands). The factor of `-3` rearrangement would still be positive - # so this guard alone does not catch it, but it pins the qualitative - # outward-migration prediction Korenaga Eq. 59 makes for the prograde - # Earth-Moon configuration. - assert actual > 0.0 - # Scale guard: a prefactor of `-3` would land at ~1.63, outside the - # [1.05, 1.15] window; a prefactor of `-1` would land at ~0.544. - assert 1.05 < actual < 1.15 +def test_ps0d_domega_dt_is_negative_for_positive_tidal_dissipation(): + """Eq. 58 has an explicit minus sign on ``dE_tidal``: positive + tidal dissipation must spin the planet DOWN (angular momentum + flows from spin into the satellite's orbit, growing ``a``).""" + da_dt, domega_dt = _ps0d_instantaneous_rates(F_tidal=1e-3) + assert domega_dt < 0.0 + # Discrimination: the orbit correspondingly expands (da/dt > 0), + # the qualitative Eq. 59 prediction for a prograde Moon losing + # spin AM to the orbit -- not just "domega_dt is negative". + assert da_dt > 0.0 + + +@pytest.mark.physics_invariant +def test_ps0d_da_dt_obeys_korenaga_eq59_kinematic_identity(): + """``da/dt = -2 I a / (L - I omega) * domega/dt`` (Eq. 59) must + hold between the two independently finite-differenced rates.""" + hf_row_state = _make_ps0d_hf_row(F_tidal=2e-3) + a, omega, L = ( + hf_row_state['semimajorax_sat'], + 2 * np.pi / hf_row_state['axial_period'], + hf_row_state['plan_sat_am'], + ) + da_dt, domega_dt = _ps0d_instantaneous_rates(F_tidal=2e-3) + + expected_da_dt = -2.0 * _PS0D_I * a / (L - _PS0D_I * omega) * domega_dt + assert da_dt == pytest.approx(expected_da_dt, rel=1e-6) + + +def test_ps0d_finite_output_over_a_realistic_step(): + """A realistic multi-year integration for the present-day Earth- + Moon system must yield finite, positive semimajor axis and axial + period.""" + hf_row = _make_ps0d_hf_row(F_tidal=1e-3) + ps0d(hf_row, dt=1e3, config=_SOLVER_CONFIG) + assert np.isfinite(hf_row['semimajorax_sat']) + assert np.isfinite(hf_row['axial_period']) + assert hf_row['semimajorax_sat'] > 0.0 + assert hf_row['axial_period'] > 0.0 # --------------------------------------------------------------------------- -# orbitals: ODE wrapper +# ps1d: planet spin + satellite spin + orbit (Hansen-coefficient-based, +# same structure as orbit.py's sp1d, extended to a second spinning body). +# +# ps1d/ps1d_evec call proteus.orbit.common.get_all_m_hansen, which lazily +# builds a module-level cache via a full FFT sweep over a ~100-point +# eccentricity grid on first use -- on the order of a minute of wall time +# (the trap fixed in tests/orbit/test_common.py, and worked around the +# same way in tests/orbit/test_orbit.py's sp1d tests). Every test below +# that touches ps1d/ps1d_evec force-builds a tiny, fast table instead via +# the _fast_hansen_table fixture, monkeypatched so it cannot leak into +# other tests sharing the same pytest process. # --------------------------------------------------------------------------- +_FAST_E_GRID = np.array([0.0, 0.1, 0.3, 0.5, 0.7, 0.9]) +_FAST_KMIN, _FAST_KMAX = -6, 6 + + +@pytest.fixture +def _fast_hansen_table(monkeypatch): + monkeypatch.setattr(hansen_mod, '_hansen_table', None) + hansen_mod.init_hansen_table( + e_grid=_FAST_E_GRID, kmin=_FAST_KMIN, kmax=_FAST_KMAX, n_deg=2, force=True + ) + + +_PS1D_MPL, _PS1D_MSA = 5.972e24, 7.342e22 # Earth, Moon +_PS1D_RPL, _PS1D_RSA = 6.371e6, 1.737e6 +_PS1D_CPL = 0.33 * _PS1D_MPL * _PS1D_RPL**2 +_PS1D_CSA = 0.33 * _PS1D_MSA * _PS1D_RSA**2 -def test_orbitals_returns_da_then_d_omega_in_that_order(): - """``orbitals`` returns ``[da_dt, dω_dt]``: order must match the - state-vector convention used by ``update_satellite``.""" - z = [1.5, 0.5] - out = orbitals(t=0.0, z=z, params=_params()) - assert out[0] == da_dt(a=z[0], ω=z[1], params=_params()) - assert out[1] == dω_dt(a=z[0], ω=z[1], params=_params()) - assert len(out) == 2 +def _make_ps1d_tides(lnk_value: complex) -> Tides_t: + nmk = [(2, 0, k) for k in range(_FAST_KMIN, _FAST_KMAX + 1)] + [ + (2, 2, k) for k in range(_FAST_KMIN, _FAST_KMAX + 1) + ] + tides_o = Tides_t() + for primary, perturber in (('planet', 'satellite'), ('satellite', 'planet')): + entry = tides_o.add(primary=primary, perturber=perturber) + entry.nmk = np.array(nmk, dtype=int) + entry.LNk = np.full(len(nmk), lnk_value, dtype=complex) + return tides_o -def test_orbitals_is_autonomous(): - """The system has no explicit time dependence, so identical state gives identical RHS.""" - z = [1.5, 0.5] - early = orbitals(t=0.0, z=z, params=_params()) - late = orbitals(t=1e9, z=z, params=_params()) - assert early == late - # Autonomy invariant at a third t: a regression that picked up a - # linear-in-t term would generally fail one of the three equalities - # even if two coincided at the chosen pair. - mid = orbitals(t=2.5, z=z, params=_params()) - assert mid == early + +def _make_ps1d_hf_row(*, axial_period=86400.0, axial_period_sat=2.36e6, sma=3.844e8, ecc=0.3): + return { + 'axial_period': axial_period, + 'axial_period_sat': axial_period_sat, + 'semimajorax_sat': sma, + 'eccentricity_sat': ecc, + 'M_int': _PS1D_MPL, + 'M_sat': _PS1D_MSA, + 'R_int': _PS1D_RPL, + 'R_sat': _PS1D_RSA, + 'C_int': _PS1D_CPL, + 'C_sat': _PS1D_CSA, + 'core_density': 5500.0, + } + + +def _ps1d_am_components(hf_row: dict) -> tuple[float, float, float]: + """``(planet spin AM, satellite spin AM, orbital AM)`` under + ps1d's own bookkeeping.""" + a = hf_row['semimajorax_sat'] + e = hf_row['eccentricity_sat'] + omega_p = 2 * np.pi / hf_row['axial_period'] + omega_s = 2 * np.pi / hf_row['axial_period_sat'] + mu = _PS1D_MPL * _PS1D_MSA / (_PS1D_MPL + _PS1D_MSA) + l_orb = mu * np.sqrt(const_G * (_PS1D_MPL + _PS1D_MSA) * a * (1 - e**2)) + return hf_row['C_int'] * omega_p, hf_row['C_sat'] * omega_s, l_orb + + +@pytest.mark.physics_invariant +def test_ps1d_zero_dissipation_is_an_exact_fixed_point(_fast_hansen_table): + """With every Love number exactly 0, both spins, semimajor axis, + and eccentricity are left exactly unchanged.""" + hf_row = _make_ps1d_hf_row() + before = dict(hf_row) + tides_o = _make_ps1d_tides(0.0 + 0.0j) + + ps1d(hf_row, tides_o, dt=1e5, config=_SOLVER_CONFIG) + + assert hf_row['axial_period'] == pytest.approx(before['axial_period'], rel=1e-12) + assert hf_row['axial_period_sat'] == pytest.approx(before['axial_period_sat'], rel=1e-12) + assert hf_row['semimajorax_sat'] == pytest.approx(before['semimajorax_sat'], rel=1e-12) + assert hf_row['eccentricity_sat'] == pytest.approx(before['eccentricity_sat'], rel=1e-12) + + +@pytest.mark.reference_pinned +@pytest.mark.physics_invariant +def test_ps1d_conserves_total_angular_momentum(_fast_hansen_table): + """Physical law: total angular momentum (planet spin + satellite + spin + orbital) is conserved for this isolated two-body-plus- + tides system. Checked across a real, non-trivial step. + + Tolerance rel=1e-6 matches ``solve_ivp``'s configured ``rtol``. + """ + hf_row = _make_ps1d_hf_row(ecc=0.3) + spin_p0, spin_s0, orb0 = _ps1d_am_components(hf_row) + am_before = spin_p0 + spin_s0 + orb0 + tides_o = _make_ps1d_tides(-0.01 - 0.02j) + + ps1d(hf_row, tides_o, dt=1e5, config=_SOLVER_CONFIG) + + spin_p1, spin_s1, orb1 = _ps1d_am_components(hf_row) + am_after = spin_p1 + spin_s1 + orb1 + # Discrimination: the satellite spin must have actually moved + # (otherwise "conservation" would be a trivial no-op check). + assert spin_s1 != pytest.approx(spin_s0, rel=1e-6) + assert am_after == pytest.approx(am_before, rel=1e-6) + + +@pytest.mark.physics_invariant +def test_ps1d_satellite_spin_am_change_matches_the_rest_of_the_system(_fast_hansen_table): + """More targeted phrasing of the same law, needed for the same + reason as ``sp1d``'s equivalent test in ``tests/orbit/test_orbit.py``: + the satellite's own spin AM (~1e29 here) is dwarfed by the + planet's spin AM (~1e33) and the orbital AM (~1e34), so a bug + confined to the satellite's ``domega_dt`` coupling would barely + move the raw total. Comparing + ``Delta(satellite spin) == -(Delta(planet spin) + Delta(orbital))`` + directly is sensitive to exactly that class of bug. + """ + hf_row = _make_ps1d_hf_row(ecc=0.3) + spin_p0, spin_s0, orb0 = _ps1d_am_components(hf_row) + tides_o = _make_ps1d_tides(-0.01 - 0.02j) + + ps1d(hf_row, tides_o, dt=1e5, config=_SOLVER_CONFIG) + + spin_p1, spin_s1, orb1 = _ps1d_am_components(hf_row) + d_spin_s = spin_s1 - spin_s0 + d_rest = (spin_p1 - spin_p0) + (orb1 - orb0) + assert abs(d_spin_s) > 1e26 # substantial, not a near-zero step + assert d_spin_s == pytest.approx(-d_rest, rel=5e-2) + + +@pytest.mark.physics_invariant +def test_ps1d_da_tidal_split_sums_to_total_sma_change(_fast_hansen_table): + """``sma_dot_planet`` and ``sma_dot_sat`` are the solver's own + exact split of the total semimajor-axis change into planet-raised + and satellite-raised contributions -- pins the source's own + logged self-consistency check (``da_total_check``): the two parts + must sum to the actual total change over the step, to solver + tolerance. + """ + hf_row = _make_ps1d_hf_row(ecc=0.3) + sma_before = hf_row['semimajorax_sat'] + tides_o = _make_ps1d_tides(-0.01 - 0.02j) + dt_yr = 1e5 + + ps1d(hf_row, tides_o, dt=dt_yr, config=_SOLVER_CONFIG) + + dt_s = dt_yr * secs_per_year + total_da_dt = (hf_row['semimajorax_sat'] - sma_before) / dt_s + split_sum = hf_row['sma_dot_planet'] + hf_row['sma_dot_sat'] + assert split_sum == pytest.approx(total_da_dt, rel=1e-6) + + +@pytest.mark.physics_invariant +def test_ps1d_eccentricity_clamped_at_zero_not_negative(_fast_hansen_table): + """Dissipation strong enough to drive a high-initial-eccentricity + orbit to (near-)circular within one step must not report a + negative eccentricity or non-finite output. This combination + (e0=0.9, dt=1e6 yr) lands within ~1e-4 of exact circularization + while keeping the Radau solver's internal Jacobian estimate well + inside its stable range; a substantially larger Love-number + magnitude at the same state drives that estimate into overflow. + """ + hf_row = _make_ps1d_hf_row(ecc=0.9) + tides_o = _make_ps1d_tides(-0.005 - 0.01j) + + ps1d(hf_row, tides_o, dt=1e6, config=_SOLVER_CONFIG) + + assert np.isfinite(hf_row['eccentricity_sat']) + assert np.isfinite(hf_row['semimajorax_sat']) + assert hf_row['eccentricity_sat'] >= 0.0 + assert hf_row['eccentricity_sat'] < 0.01 # --------------------------------------------------------------------------- -# update_satellite: top-level orchestrator +# evolve_orbit_satellite: dispatch, the C_planet angular-momentum-conserving +# rescale (the "figure skater" effect, see the function's own docstring), +# and the adaptive accept/reject substep controller. # --------------------------------------------------------------------------- -def _make_config( - *, - semimajoraxis_sat: float = 3.844e8, # m, Earth-Moon distance - mass_sat: float = 7.342e22, # kg, Moon mass - axial_period_h: float | None = 24.0, -) -> Any: +def _make_interior_for_c_planet(density: float, nlev_b: int = 20): + """Uniform-density-sphere Interior_t-like stand-in for + get_C_planet, matching the fixture style in + tests/orbit/test_common.py and tests/orbit/test_orbit.py.""" + radius = np.linspace(0.0, _PS0D_RPL, nlev_b) + return SimpleNamespace(radius=radius, density=np.full(nlev_b - 1, density)) + + +def _make_satellite_config(model) -> Any: return cast( Any, SimpleNamespace( - orbit=SimpleNamespace( - semimajoraxis_sat=semimajoraxis_sat, - mass_sat=mass_sat, - axial_period=axial_period_h, - ) + orbit=SimpleNamespace(planet_satellite_model=model, solver=OrbitSolver()), + interior_energetics=SimpleNamespace(module='aragog'), + # evection_maximum=0.0 (disabled) by default so evolve_orbit_satellite's + # unconditional _estimate_evection_dt_cap_yr call is a no-op (np.inf) for + # every test that doesn't care about the cap -- config.params.dt is read + # directly (no getattr fallback) for evection_maximum, so it must exist. + params=SimpleNamespace(dt=SimpleNamespace(evection_maximum=0.0)), ), ) -def _make_hf_row( - *, - time: float, - R_int: float = 6.371e6, - M_int: float = 5.972e24, - F_tidal: float = 1e-3, - orbital_period_s: float = 86400.0, -) -> dict: +def _make_evolve_hf_row(*, time=100.0, axial_period=_PS0D_AXIAL_PERIOD, plan_sat_am=1.0): return { 'Time': time, - 'R_int': R_int, - 'M_int': M_int, - 'F_tidal': F_tidal, - 'orbital_period': orbital_period_s, + 'F_tidal': 0.0, + 'semimajorax_sat': _PS0D_SMA, + 'eccentricity_sat': 0.05, + 'axial_period': axial_period, + 'axial_period_sat': 2.36e6, + 'R_int': _PS0D_RPL, + 'M_int': _PS0D_MPL, + 'M_sat': _PS0D_MSA, + # Nonzero so ps0d's own AM bootstrap (a separate mechanism, + # tested above) does not also fire and confound this check. + 'plan_sat_am': plan_sat_am, + # get_C_planet's fallback for a missing hf_row entry. + 'core_density': 5500.0, } -def test_update_satellite_first_call_converts_axial_period_hours_to_seconds(): - """A user-supplied ``axial_period`` in hours must be multiplied by - ``secs_per_hour`` on the first call. A regression that stored the - raw value (24.0) would be off by a factor of 3600. - """ - cfg = _make_config(axial_period_h=24.0) - hf_row = _make_hf_row(time=0.0) - update_satellite(hf_row, cfg, dt=1.0) - assert hf_row['axial_period'] == pytest.approx(24.0 * secs_per_hour) - # Scale guard: the converted value lands in seconds (~86400 s for - # one rotation in hours = 24 h), well above the 100 s lower bound - # and below 1e6 s. A regression that forgot the conversion would - # leave the value at 24.0, below the lower bound. - assert 1e4 < hf_row['axial_period'] < 1e6 - - -def test_update_satellite_first_call_falls_back_to_sor_when_axial_period_is_none(): - """When ``config.orbit.axial_period`` is ``None``, the planet locks - into a 1:1 spin-orbit resonance with the satellite's orbital period.""" - cfg = _make_config(axial_period_h=None) - hf_row = _make_hf_row(time=0.5, orbital_period_s=4.32e4) - update_satellite(hf_row, cfg, dt=1.0) - assert hf_row['axial_period'] == pytest.approx(4.32e4) - # 1:1 SOR pass-through: changing the orbital period must drive an - # identical change in axial_period under SOR. A regression that - # stamped a constant default would fail this second case. - cfg2 = _make_config(axial_period_h=None) - hf_row2 = _make_hf_row(time=0.5, orbital_period_s=2.16e4) - update_satellite(hf_row2, cfg2, dt=1.0) - assert hf_row2['axial_period'] == pytest.approx(2.16e4) - - -def test_update_satellite_first_call_seeds_satellite_mass_and_sma(): - """The first-call branch must copy ``mass_sat`` and ``semimajoraxis_sat`` - from the config to ``hf_row`` verbatim.""" - cfg = _make_config(semimajoraxis_sat=1.5e9, mass_sat=2e22) - hf_row = _make_hf_row(time=0.0) - update_satellite(hf_row, cfg, dt=1.0) - assert hf_row['semimajorax_sat'] == pytest.approx(1.5e9) - assert hf_row['M_sat'] == pytest.approx(2e22) +@pytest.mark.physics_invariant +def test_evolve_orbit_satellite_conserves_spin_am_across_c_planet_change_for_ps0d(): + """The documented "figure skater" rescale: when the interior state + changes ``C_planet`` between two calls (e.g. from solidification), + ``evolve_orbit_satellite`` must rescale the planet's spin + (``axial_period``) so that ``C_planet * Omega_p`` is exactly + conserved across that structural jump -- with ``F_tidal = 0`` so + ps0d's own integration is a no-op and cannot be confused with the + rescale's effect. + + This must hold for ``model = 'ps0d'`` specifically: unlike + ps1d/ps1d_evec, ps0d has no satellite spin state of its own, but + its own AM bootstrap reads ``hf_row['C_int']`` directly, so it + needs this refreshed and angular-momentum-consistent exactly like + the other two models. + """ + hf_row = _make_evolve_hf_row() + config = _make_satellite_config('ps0d') + interior_1 = _make_interior_for_c_planet(density=5500.0) + interior_1.dt = 1.0 + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=Tides_t(), interior_o=interior_1) + c_planet_1 = hf_row['C_int'] + spin_am_1 = c_planet_1 * (2 * np.pi / hf_row['axial_period']) + + # Simulate interior solidification: a different density profile + # changes C_planet on the next call. + interior_2 = _make_interior_for_c_planet(density=6000.0) + interior_2.dt = 1.0 + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=Tides_t(), interior_o=interior_2) + c_planet_2 = hf_row['C_int'] + spin_am_2 = c_planet_2 * (2 * np.pi / hf_row['axial_period']) + + # Discrimination: C_planet must have actually changed, or the + # rescale would trivially conserve spin AM regardless of whether + # it works. + assert c_planet_2 != pytest.approx(c_planet_1, rel=1e-6) + assert spin_am_2 == pytest.approx(spin_am_1, rel=1e-9) + + +def test_evolve_orbit_satellite_c_planet_rescale_holds_with_real_ps0d_dynamics(): + """The same "figure skater" rescale checked above, but now with + real (nonzero ``F_tidal``) dynamics actually running through + ``ps0d`` on both calls -- important because ``ps0d``'s own ODE + now reads its moment-of-inertia coefficient from + ``hf_row['C_int']`` directly (matching ps1d/ps1d_evec), rather + than a fixed uniform-sphere value independent of the interior + state. + + The second call's ``interior_o.dt`` is deliberately tiny (1e-6 yr, + versus ps0d's own spin-orbit exchange timescale of hundreds of + millions of years at these parameters) so genuine tidal evolution + during that call contributes a negligible amount to the spin + change, isolating the discrete rescale jump from the (real, but + minuscule at this dt) ongoing dynamics. + """ + hf_row = _make_evolve_hf_row() + hf_row['F_tidal'] = 1e-3 + config = _make_satellite_config('ps0d') + + interior_1 = _make_interior_for_c_planet(density=5500.0) + interior_1.dt = 1e5 + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=Tides_t(), interior_o=interior_1) + c_planet_1 = hf_row['C_int'] + spin_am_1 = c_planet_1 * (2 * np.pi / hf_row['axial_period']) + + interior_2 = _make_interior_for_c_planet(density=6000.0) + interior_2.dt = 1e-6 + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=Tides_t(), interior_o=interior_2) + c_planet_2 = hf_row['C_int'] + spin_am_2 = c_planet_2 * (2 * np.pi / hf_row['axial_period']) + + assert c_planet_2 != pytest.approx(c_planet_1, rel=1e-6) + assert spin_am_2 == pytest.approx(spin_am_1, rel=1e-6) + + +@pytest.mark.parametrize('model', ['ps0d', 'ps1d', 'ps1d_evec']) +def test_evolve_orbit_satellite_populates_c_planet_for_every_model(model, _fast_hansen_table): + """All three dispatchable models need ``hf_row['C_int']`` + populated on entry (ps0d for its own AM bootstrap; ps1d/ps1d_evec + for their spin-coupling ODEs) -- confirms the refresh gate covers + all of them, not just the two it originally covered. + """ + hf_row = _make_evolve_hf_row() + hf_row['axial_period_sat'] = 2.36e6 + hf_row['evection_angle'] = 0.0 + hf_row['C_sat'] = _PS1D_CSA + hf_row['R_sat'] = _PS1D_RSA + config = _make_satellite_config(model) + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1.0 + tides_o = _make_ps1d_tides(-0.01 - 0.02j) if model != 'ps0d' else Tides_t() + + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=tides_o, interior_o=interior_o) + + assert 'C_int' in hf_row + assert hf_row['C_int'] > 0.0 + + +def test_evolve_orbit_satellite_unrecognized_model_raises_immediately(tmp_path): + """An unrecognized ``planet_satellite_model`` is now rejected + up-front by the dispatch (before the shared adaptive-substep + controller ever starts), not inside the substep's own broad + ``except Exception`` -- unlike the pre-homogenization version, + where the same ``raise ValueError`` sat inside the substep loop's + try/except and was silently swallowed and retried down to the + step-size floor before returning normally. Failing loudly on a + configuration error is the deliberate improvement from sharing the + controller with ``evolve_orbit_star``. + + Also pins that the status file is updated (to the Tides/orbit-model + error code) before the raise -- ``dirs`` must be a real directory + here (not ``{}``) since ``UpdateStatusfile`` writes to it. + """ + hf_row = _make_evolve_hf_row() + hf_row['F_tidal'] = 1e-3 + sma_before = hf_row['semimajorax_sat'] + config = _make_satellite_config('not-a-real-model') + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1.0 + dirs = {'output': str(tmp_path)} + + with pytest.raises(ValueError, match='not-a-real-model'): + evolve_orbit_satellite( + hf_row, config, dirs=dirs, tides_o=Tides_t(), interior_o=interior_o + ) + + status_path = tmp_path / 'status' + assert status_path.exists() + assert status_path.read_text().splitlines()[0] == '26' + + # Discrimination: the raise happens before any substep runs, so the + # state is exactly the pre-call snapshot, not partially evolved. + assert hf_row['semimajorax_sat'] == pytest.approx(sma_before, rel=1e-12) + + +def test_evolve_orbit_satellite_ps0d_dispatch_evolves_hf_row(): + """``model='ps0d'`` actually dispatches to and runs ``ps0d``: with + nonzero tidal power, the semimajor axis must change over the + call.""" + hf_row = _make_evolve_hf_row() + hf_row['F_tidal'] = 1e-3 + sma_before = hf_row['semimajorax_sat'] + config = _make_satellite_config('ps0d') + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1e6 # long enough for ps0d's slow spin-orbit exchange to register + + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=Tides_t(), interior_o=interior_o) + + assert abs(hf_row['semimajorax_sat'] - sma_before) > 1.0 + + +def test_evolve_orbit_satellite_ps0d_engages_substep_controller_near_its_pole(): + """Regression for a real tutorial failure: with intense early tidal + heating (large ``F_tidal``), ``ps0d``'s own equations (Korenaga + 2023, Eq. 58-60) migrate the satellite on a timescale far shorter + than a typical interior-driven outer ``dt`` -- here the state below + (mirroring an actual tutorial run's post-migration configuration) + has a semimajor-axis doubling time of ~130 yr against a 1e4 yr outer + window, the same order as PROTEUS's own ``params.dt.minimum`` floor. + ``ps0d`` must resolve this through the same accept/reject substep + controller ``ps1d``/``ps1d_evec`` already use + (``run_adaptive_orbit_substeps``), not by silently applying the + entire outer window as a single ``solve_ivp`` call. + """ + state = dict( + R_int=6.533829e6, + M_int=5.971455e24, + M_sat=7.1664e22, + semimajorax_sat=2.972962e7, + axial_period=14899.0, + plan_sat_am=3.874459e34, + F_tidal=9.581937e5, + core_density=5500.0, + C_int=7.326364e37, + ) + + # Discrimination baseline: the raw, unguarded ps0d entry point (what + # the pre-fix dispatch called directly) over the same outer dt. + raw_hf_row = dict(state, Time=100.0) + sma_before = raw_hf_row['semimajorax_sat'] + ps0d(raw_hf_row, dt=1e4, config=_SOLVER_CONFIG) + raw_rel_change = abs(raw_hf_row['semimajorax_sat'] - sma_before) / sma_before + assert raw_rel_change > 1.0 # the actual bug: >100% change in one uncontrolled step + + # The fixed, controller-routed call: same initial state, same outer dt. + hf_row = dict(state, Time=100.0, eccentricity_sat=0.05, axial_period_sat=32661.0) + config = _make_satellite_config('ps0d') + interior_o = SimpleNamespace( + radius=np.array([0.0, 3.0e6, 6.533829e6]), density=np.array([6000.0, 5500.0]), dt=1e4 + ) + tides_o = Tides_t() + + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=tides_o, interior_o=interior_o) + + assert np.isfinite(hf_row['semimajorax_sat']) + assert hf_row['semimajorax_sat'] > 0.0 + # The controller must have actually engaged: tides_o.dt_yr is its + # persisted internal step size, left untouched (None) by the old + # single-jump dispatch that bypassed run_adaptive_orbit_substeps. + assert tides_o.dt_yr is not None + # Discrimination: approaching this model's own singularity forces the + # controller to shrink its internal step far below the outer window, + # orders of magnitude smaller than the 1e4 yr requested, unlike the + # single full-window leap the raw call above took. + assert tides_o.dt_yr < 1.0 + + +def test_evolve_orbit_satellite_ps0d_caps_cumulative_drift_per_call(): + """Regression for a SECOND, deeper failure behind the same tutorial + bug: even with the per-substep accept/reject controller engaged (see + the test above), thousands of individually-compliant substeps could + still compound into an enormous TOTAL semimajor-axis change within + one call, because ``ps0d``'s tidal forcing (``F_tidal``) is a single + snapshot taken once at call-entry and never refreshed mid-call, while + the true tidal power depends steeply on the (rapidly changing) + orbital state. Reproduced with the exact state from the tutorial run + this guards against: a single call previously reached ~7.25e8 m + (~114 R_earth, a ~24x jump) from this state before this fix. + """ + state = dict( + R_int=6.533829e6, + M_int=5.971455e24, + M_sat=7.1664e22, + semimajorax_sat=2.972962e7, + axial_period=14899.0, + plan_sat_am=3.874459e34, + F_tidal=9.581937e5, + core_density=5500.0, + C_int=7.326364e37, + ) + hf_row = dict(state, Time=21.0, eccentricity_sat=0.05, axial_period_sat=32661.0) + config = _make_satellite_config('ps0d') + interior_o = SimpleNamespace( + radius=np.array([0.0, 3.0e6, 6.533829e6]), density=np.array([6000.0, 5500.0]), dt=3000.0 + ) + tides_o = Tides_t() + sma_before = hf_row['semimajorax_sat'] + + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=tides_o, interior_o=interior_o) + + rel_change = abs(hf_row['semimajorax_sat'] - sma_before) / sma_before + # Primary pin: cumulative drift since call-entry must be capped near + # solver.max_rel_da (0.01 by default), generous slack (5x) for the + # single substep that pushed it just over the line before the check + # fired, but nowhere near the ~24x (2400%) the unguarded model reaches + # from this exact state. + assert rel_change < 5.0 * config.orbit.solver.max_rel_da + # Discrimination: this is not merely "nothing happened"; real, + # bounded migration occurred. + assert rel_change > 0.0 + # The controller stopped well short of the true singularity (unlike + # the pre-cumulative-cap fix, where it collapsed dt_yr toward zero + # fighting the pole): the persisted step size stays a normal, + # order-few-tenths-of-a-year value, not the ~1e-10 floor. + assert tides_o.dt_yr > 1e-5 + + +def _make_ps1d_evolve_hf_row(): + # Generic controller-mechanics tests (accept/reject, step growth/shrink) + # exercise ps1d here rather than ps0d or ps1d_evec: it is the simplest + # model on the shared run_adaptive_orbit_substeps controller, needing no + # eccentricity-clamping or evection-band bookkeeping to reason about. + hf_row = _make_evolve_hf_row() + hf_row['axial_period_sat'] = 2.36e6 + hf_row['C_sat'] = _PS1D_CSA + hf_row['R_sat'] = _PS1D_RSA + return hf_row + + +def test_evolve_orbit_satellite_rejects_substep_exceeding_max_rel_da_and_shrinks_dt( + _fast_hansen_table, +): + """Forcing ``max_rel_da`` far below any physically achievable step + must cause EVERY substep to be rejected (state rolled back to the + pre-substep snapshot each time) until either the step size + collapses or ``max_substeps`` is exhausted -- exercising the + reject/rollback/shrink branch of the adaptive controller, not just + the accept path every other test here takes. + """ + hf_row = _make_ps1d_evolve_hf_row() + sma_before = hf_row['semimajorax_sat'] + config = _make_satellite_config('ps1d') + config.orbit.solver.max_rel_da = 1e-30 + config.orbit.solver.max_substeps = 20 + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1e7 + tides_o = _make_ps1d_tides(-0.01 - 0.02j) + + evolve_orbit_satellite( + hf_row, + config, + dirs={}, + tides_o=tides_o, + interior_o=interior_o, + ) + + # Every substep must have been rejected: semimajorax_sat is + # restored to the pre-call snapshot on every rejection, so after + # exhausting max_substeps with an unsatisfiable tolerance it must + # still equal the ORIGINAL value, not something partway evolved. + assert hf_row['semimajorax_sat'] == pytest.approx(sma_before, rel=1e-12) + + +def test_evolve_orbit_satellite_persists_controller_state_across_calls(_fast_hansen_table): + """``tides_o.dt_yr`` and ``tides_o.resonance_state`` are the + controller's own state, live on ``tides_o`` for the whole run (dt_yr + not reset to ``dt0_yr`` on the next call) -- the persistence the + function's own docstring says is load-bearing for not wasting + substeps re-growing a step size a previous call had already found + safe. Model here is ``ps1d`` (not ``ps1d_evec``), so + ``resonance_state`` itself is never mutated by ``_in_evection_band`` + -- only its presence/type on ``tides_o`` is pinned here; its real + hysteresis mutation is covered by the ps1d_evec-specific tests. + """ + hf_row = _make_ps1d_evolve_hf_row() + config = _make_satellite_config('ps1d') + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 10.0 + tides_o = _make_ps1d_tides(-0.01 - 0.02j) + + assert tides_o.dt_yr is None + assert tides_o.resonance_state == {} + assert '_orbit_dt_yr' not in hf_row + evolve_orbit_satellite(hf_row, config, dirs={}, tides_o=tides_o, interior_o=interior_o) + assert '_orbit_dt_yr' not in hf_row + assert '_orbit_resonance_state' not in hf_row + assert isinstance(tides_o.resonance_state, dict) + # Discrimination: the persisted value is a real float step size, + # not e.g. a leftover None or the untouched dt0_yr default when + # growth should have moved it (dt0_yr=1e-4 by default; a step that + # ran to completion on a 10 yr call and grew at all would leave a + # noticeably larger value, given growth=1.15 compounds quickly). + assert isinstance(tides_o.dt_yr, float) + assert tides_o.dt_yr > 0.0 + + +# --------------------------------------------------------------------------- +# ps1d_evec: planet + satellite spin/orbit plus apsidal precession and the +# evection resonance (star-forced eccentricity pumping). This is the "three +# clocks" model: PROTEUS hands evolve_orbit_satellite a coarse elapsed time +# (Clock 1); the adaptive substep controller there subdivides it into +# accepted solver calls (Clock 2, further subdivided internally by +# solve_ivp's own adaptive stepping); accepted substeps are optionally +# stored at a throttled cadence (Clock 3, via _flush_fine_evection_csv, +# already covered above). Only the mechanics are tested here -- the +# resonance-physics literature comparison (CPL model vs a published case) +# is a separate, long-running test. +# +# filter_value only gates the OSCILLATING evection-forcing term inside +# dw_dt (and the matching de_res term in orbitals()); the secular +# apsidal-precession terms (J2, tidal, stellar) are always active, so +# evection_angle keeps evolving smoothly regardless of filter_value -- +# it is never reset or frozen. Since every OTHER term that depends on +# phi (de_res in the eccentricity ODE) is gated by the SAME filter, +# phi's own secular drift never feeds back into a/e/spin when +# filter_value=0: confirmed below that the same 3-component total AM +# is still conserved to machine precision in that case, even though +# phi itself is not frozen. +# +# filter_value=1 (in band) activates the star's secular/evection torque: +# this is a genuine three-body angular-momentum exchange with the star, so +# the planet-satellite subsystem's own total AM is NOT expected to be +# conserved in this regime (checked numerically: ~0.5% drift over a 1-year +# in-band step at the parameters used here) -- this is documented as +# physical, not asserted as a false invariant. +# --------------------------------------------------------------------------- + + +def _make_ps1d_evec_tides(lnk_value: complex) -> Tides_t: + return _make_ps1d_tides(lnk_value) + + +def _make_ps1d_evec_hf_row(*, ecc=0.3, evection_angle=0.0, time=100.0): + hf_row = _make_ps1d_hf_row(ecc=ecc) + hf_row['M_star'] = 1.989e30 + hf_row['M_planet'] = _PS1D_MPL + hf_row['semimajorax'] = 1.5e11 + hf_row['evection_angle'] = evection_angle + hf_row['Time'] = time + # Only used by evolve_orbit_satellite's C_planet refresh/rescale + # block, not by ps1d_evec itself when called directly; harmless + # either way, kept for parity with the other models' hf_row shape. + hf_row['plan_sat_am'] = hf_row.get('plan_sat_am', 1.0) + return hf_row + + +def _ps1d_evec_am_components(hf_row: dict) -> tuple[float, float, float]: + return _ps1d_am_components(hf_row) -@pytest.mark.physics_invariant @pytest.mark.reference_pinned -def test_update_satellite_angular_momentum_matches_korenaga_2023_eq60(): - """Pin the planet-satellite angular-momentum bookkeeping against - Korenaga (2023) Icarus 400, 115564, Eq. 60: - - L = I_E * Omega + M_M * sqrt(G * (M_E + M_M) * a) - - where M_M is the SATELLITE mass (Moon), not M_E (Earth). This is - the M_M << M_E limit of the textbook reduced-mass orbital angular - momentum L_orb = mu * sqrt(G (M_E + M_M) a) with reduced mass - mu = M_E * M_M / (M_E + M_M); the limit's relative error is M_M / M_E - ~ 1/81 ~ 1.2% for the Earth-Moon system. - - For Earth parameters the two components of Eq. 60 evaluate to: - - spin term I_E * Omega ~ 7.05e33 kg m^2 / s - - orbital term M_M sqrt(G (M_E + M_M) a) ~ 2.89e34 kg m^2 / s - - total L_total ~ 3.60e34 kg m^2 / s - - The orbital component matches the Touma and Wisdom (1994) value - of ~2.85e34 kg m^2 / s for the present-day Earth-Moon orbit. - - See ``docs/Validation/orbit/satellite.md`` for the validation - registry entry and the re-derivation note. - """ - cfg = _make_config(semimajoraxis_sat=3.844e8, mass_sat=7.342e22, axial_period_h=24.0) - hf_row = _make_hf_row(time=0.0, R_int=6.371e6, M_int=5.972e24) - update_satellite(hf_row, cfg, dt=1.0) - I = 2 / 5 * 5.972e24 * 6.371e6**2 - omega = 2 * np.pi / (24.0 * secs_per_hour) - # Eq. 60: orbital prefactor is the satellite mass M_M. - expected = I * omega + 7.342e22 * (const_G * (5.972e24 + 7.342e22) * 3.844e8) ** 0.5 - assert hf_row['plan_sat_am'] == pytest.approx(expected, rel=1e-6) - # Sign guard: total system AM is positive for a prograde Moon. - assert hf_row['plan_sat_am'] > 0.0 - # Scale guard: Korenaga Eq. 60 evaluated on the Earth-Moon system - # lands at ~3.60e34 kg m^2 / s for the total (spin ~7.05e33 + - # orbital ~2.89e34). The orbital component matches Touma and - # Wisdom (1994) (~2.85e34). The [1e34, 1e35] bracket catches any - # SI-vs-CGS or kg-vs-g unit slip and discriminates the M_sat form - # in Eq. 60 from a substitution of M_planet, which would inflate - # the orbital term to ~2.4e36 (well above the upper bound). - assert 1e34 < hf_row['plan_sat_am'] < 1e35 +@pytest.mark.physics_invariant +def test_ps1d_evec_filter_zero_still_evolves_phi_but_conserves_am(_fast_hansen_table): + """``filter_value=0`` (out of band) does NOT freeze or reset the + evection angle: the secular apsidal-precession terms (J2, tidal, + stellar) are always active, so phi keeps evolving even out of + band. But the same 3-component total angular momentum (planet spin + + satellite spin + orbital) is still conserved to machine + precision, because the eccentricity ODE's only phi-dependent term + (de_res) is gated by the SAME filter -- phi's own secular drift + never feeds back into a/e/spin when filter_value=0, so there is no + star-torque contribution left to break the closed + two-body-plus-tides conservation law even though phi itself moves. + """ + hf_row = _make_ps1d_evec_hf_row(ecc=0.3, evection_angle=0.0) + spin_p0, spin_s0, orb0 = _ps1d_evec_am_components(hf_row) + am_before = spin_p0 + spin_s0 + orb0 + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + ps1d_evec(hf_row, tides_o, dt=1.0, config=_SOLVER_CONFIG, filter_value=0.0) + + # Discrimination: phi must have moved substantially under the + # (always-on) secular terms, not stayed at its 0.0 IC. + assert abs(hf_row['evection_angle']) > 1.0 + spin_p1, spin_s1, orb1 = _ps1d_evec_am_components(hf_row) + am_after = spin_p1 + spin_s1 + orb1 + assert am_after == pytest.approx(am_before, rel=1e-9) @pytest.mark.physics_invariant -def test_update_satellite_finite_for_long_integration_step(): - """Adversarial-but-physical: a long-baseline integration must - produce finite ``semimajorax_sat`` and ``axial_period``. - """ - cfg = _make_config() - # Bootstrap by running first-call once, then advance. - hf_row = _make_hf_row(time=0.0, F_tidal=1e-3) - update_satellite(hf_row, cfg, dt=1.0) - hf_row['Time'] = 1e3 - update_satellite(hf_row, cfg, dt=10.0) - assert np.isfinite(hf_row['semimajorax_sat']) - assert np.isfinite(hf_row['axial_period']) - assert hf_row['axial_period'] > 0.0 +def test_ps1d_evec_filter_one_evolves_phi_and_am_is_not_conserved(_fast_hansen_table): + """``filter_value=1`` additionally activates the star-forced + OSCILLATING evection term (on top of the secular precession that + is already active at ``filter_value=0``, see the test above): the + planet-satellite subsystem's own total angular momentum is NOT + expected to be conserved in this regime -- the star is a third + body exchanging angular momentum with the system during resonant + forcing, unlike the secular-only case. This pins that the drift is + real, finite, and of a physically sane (small-fraction) magnitude, + not that it vanishes. + """ + hf_row = _make_ps1d_evec_hf_row(ecc=0.3, evection_angle=0.0) + spin_p0, spin_s0, orb0 = _ps1d_evec_am_components(hf_row) + am_before = spin_p0 + spin_s0 + orb0 + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + ps1d_evec(hf_row, tides_o, dt=1.0, config=_SOLVER_CONFIG, filter_value=1.0) + # Discrimination: phi must have moved substantially, not just by + # solver-noise scale. + assert abs(hf_row['evection_angle']) > 1.0 + spin_p1, spin_s1, orb1 = _ps1d_evec_am_components(hf_row) + am_after = spin_p1 + spin_s1 + orb1 + rel_drift = abs(am_after - am_before) / am_before + assert np.isfinite(rel_drift) + # Sane magnitude: measurable (the star is doing real work) but not + # wildly unphysical for a single year of resonant forcing. + assert 1e-6 < rel_drift < 0.5 -def test_update_satellite_mutates_hf_row_in_place(): - """The orchestrator must mutate ``hf_row`` and return ``None``.""" - cfg = _make_config() - hf_row = _make_hf_row(time=0.0) - result = update_satellite(hf_row, cfg, dt=1.0) - assert result is None - # All four first-call outputs must be set. - for key in ('semimajorax_sat', 'M_sat', 'axial_period', 'plan_sat_am'): - assert key in hf_row + +def test_ps1d_evec_default_filter_value_matches_explicit_filter_one(_fast_hansen_table): + """``filter_value`` defaults to ``None`` in ``ps1d_evec``'s own + signature (used when the model is called directly rather than + through ``evolve_orbit_satellite``'s step_fn wrapper, which always + supplies an explicit 0.0/1.0) -- the docstring's contract is that + omitting it applies the full evection forcing unconditionally, i.e. + behaves exactly like ``filter_value=1.0``, not like 0.0. + """ + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + hf_row_default = _make_ps1d_evec_hf_row(ecc=0.3, evection_angle=0.0) + ps1d_evec(hf_row_default, tides_o, dt=1.0, config=_SOLVER_CONFIG) + + hf_row_explicit = _make_ps1d_evec_hf_row(ecc=0.3, evection_angle=0.0) + ps1d_evec(hf_row_explicit, tides_o, dt=1.0, config=_SOLVER_CONFIG, filter_value=1.0) + + assert hf_row_default['evection_angle'] == pytest.approx( + hf_row_explicit['evection_angle'], rel=1e-12 + ) + # Discrimination: must NOT match filter_value=0.0's result (the + # secular-only regime), confirming the default genuinely activates + # the oscillating term rather than silently falling back to it. + hf_row_zero = _make_ps1d_evec_hf_row(ecc=0.3, evection_angle=0.0) + ps1d_evec(hf_row_zero, tides_o, dt=1.0, config=_SOLVER_CONFIG, filter_value=0.0) + assert hf_row_default['evection_angle'] != pytest.approx( + hf_row_zero['evection_angle'], rel=1e-6 + ) + + +def test_ps1d_evec_default_fine_stride_keeps_every_solver_sample(_fast_hansen_table): + """``fine_stride`` defaults to 1 (no thinning): every accepted + internal solver sample must be kept in ``fine_sink``, not just + every 20th (the stride ``evolve_orbit_satellite``'s own step_fn + hard-codes for storage-clock throttling -- see the module + docstring's 'Three clocks' section). Compared directly against an + explicit ``fine_stride=20`` call on the same inputs, which must + keep strictly fewer samples. + """ + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + hf_row_default = _make_ps1d_evec_hf_row(ecc=0.3, evection_angle=0.0) + fine_sink_default = [] + ps1d_evec( + hf_row_default, tides_o, dt=1.0, config=_SOLVER_CONFIG, fine_sink=fine_sink_default + ) + + hf_row_strided = _make_ps1d_evec_hf_row(ecc=0.3, evection_angle=0.0) + fine_sink_strided = [] + ps1d_evec( + hf_row_strided, + tides_o, + dt=1.0, + config=_SOLVER_CONFIG, + fine_sink=fine_sink_strided, + fine_stride=20, + ) + + assert len(fine_sink_default) == 1 + assert len(fine_sink_strided) == 1 + n_default = len(fine_sink_default[0]['t_abs_yr']) + n_strided = len(fine_sink_strided[0]['t_abs_yr']) + assert n_default > n_strided, ( + f'default fine_stride kept {n_default} samples, stride=20 kept ' + f'{n_strided} -- the default must keep strictly more (no thinning)' + ) + + +def test_evolve_orbit_satellite_logs_evection_band_transitions(monkeypatch, caplog): + """A genuine in-band/out-of-band flip mid-call must produce exactly + one TRANSITION log line per flip (not one per substep) -- the + signal this timeline-logging exists to surface, distinct from the + per-substep ``filter_value`` wiring covered by the test above. + """ + import logging + + from proteus.orbit import satellite as sat_mod + + call_count = [0] + + def flipping_in_band(hf_row, state, **kw): + call_count[0] += 1 + return call_count[0] == 1 # True on the first substep, False after + + monkeypatch.setattr(sat_mod, '_in_evection_band', flipping_in_band) + monkeypatch.setattr( + sat_mod, + 'ps1d_evec', + lambda *a, fine_sink=None, fine_stride=1, filter_value=None, **kw: None, + ) + + hf_row = _make_ps1d_evec_hf_row(ecc=0.05) + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1.0 + config = _make_satellite_config('ps1d_evec') + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + with caplog.at_level(logging.DEBUG, logger='fwl.proteus.orbit.satellite'): + sat_mod.evolve_orbit_satellite( + hf_row, config, dirs={'output/data': '/tmp'}, tides_o=tides_o, interior_o=interior_o + ) + + assert call_count[0] > 1, 'test setup problem: fewer than 2 substeps ran' + transitions = [ + rec.message for rec in caplog.records if 'evection-band TRANSITION' in rec.message + ] + assert len(transitions) == 1 + assert 'True -> False' in transitions[0] + + +def test_evolve_orbit_satellite_skips_domega_p_when_axial_period_hits_zero(monkeypatch): + """``rel_change_fn``'s ``dOmega_p`` gradient check must be skipped + (not raise a ZeroDivisionError) when the planet's spin period comes + back exactly zero from the model -- a degenerate but technically + ``np.isfinite`` value that ``_state_is_valid`` does not itself + reject (unlike ``semimajorax_sat``, spin period has no explicit + positivity/range check, only a finiteness one). + + ``ps1d`` is replaced with a controlled stand-in here because no + real tidal model actually drives spin to exactly zero -- this + isolates the shared controller's own gradient-check robustness + from whether real physics would ever produce this input, mirroring + the existing ``filter_value``-wiring test's use of a fake model. + """ + from proteus.orbit import satellite as sat_mod + + def fake_ps1d(hf_row, tides_o, dt_yr, config): + hf_row['axial_period'] = 0.0 + + monkeypatch.setattr(sat_mod, 'ps1d', fake_ps1d) + + hf_row = _make_ps1d_hf_row() + hf_row['Time'] = 100.0 + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 0.5 + config = _make_satellite_config('ps1d') + config.orbit.solver.dt0_yr = 0.5 # completes in exactly one substep + tides_o = _make_ps1d_tides(-0.01 - 0.02j) + + sat_mod.evolve_orbit_satellite( + hf_row, config, dirs={}, tides_o=tides_o, interior_o=interior_o + ) + + assert hf_row['axial_period'] == 0.0 + # Discrimination: the substep was actually ACCEPTED (not stuck + # retrying/rejecting forever) -- a broken guard that raised + # ZeroDivisionError inside rel_change_fn would be caught by the + # substep's own try/except and masquerade as an ordinary rejection, + # never completing the call and never persisting controller state. + assert tides_o.dt_yr is not None + + +def test_evolve_orbit_satellite_skips_domega_s_when_axial_period_sat_hits_zero(monkeypatch): + """Counterpart to the planet-spin case above, for the satellite's + own spin period (``dOmega_s``).""" + from proteus.orbit import satellite as sat_mod + + def fake_ps1d(hf_row, tides_o, dt_yr, config): + hf_row['axial_period_sat'] = 0.0 + + monkeypatch.setattr(sat_mod, 'ps1d', fake_ps1d) + + hf_row = _make_ps1d_hf_row() + hf_row['Time'] = 100.0 + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 0.5 + config = _make_satellite_config('ps1d') + config.orbit.solver.dt0_yr = 0.5 + tides_o = _make_ps1d_tides(-0.01 - 0.02j) + + sat_mod.evolve_orbit_satellite( + hf_row, config, dirs={}, tides_o=tides_o, interior_o=interior_o + ) + + assert hf_row['axial_period_sat'] == 0.0 + assert tides_o.dt_yr is not None + + +def test_evolve_orbit_satellite_threads_in_band_result_as_filter_value(monkeypatch): + """Wiring check: ``evolve_orbit_satellite`` must pass + ``_in_evection_band``'s live result through to ``ps1d_evec`` as + ``filter_value`` -- forced True and forced False separately, both + observed directly from the call ``ps1d_evec`` actually receives + (not inferred from a downstream effect), so a regression that + hardcoded ``filter_value`` or read a stale/cached band state would + be caught regardless of what it happened to hardcode. + """ + from proteus.orbit import satellite as sat_mod + + captured_filter_values = [] + + def fake_ps1d_evec( + hf_row, tides_o, dt, config, fine_sink=None, fine_stride=1, filter_value=None, **kw + ): + # Deliberately NOT the real physics: this test verifies only + # that evolve_orbit_satellite threads the live _in_evection_band + # result through as filter_value, not ps1d_evec's own dynamics + # (covered by the dedicated ps1d_evec tests above). A trivial + # no-op keeps every substep cheap and instantly accepted + # regardless of how many the controller wants to take. + captured_filter_values.append(filter_value) + + monkeypatch.setattr(sat_mod, 'ps1d_evec', fake_ps1d_evec) + + for forced_band, expected_filter in [(True, 1.0), (False, 0.0)]: + monkeypatch.setattr( + sat_mod, '_in_evection_band', lambda hf_row, state, **kw: forced_band + ) + captured_filter_values.clear() + + hf_row = _make_ps1d_evec_hf_row(ecc=0.05) + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1.0 + config = _make_satellite_config('ps1d_evec') + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + sat_mod.evolve_orbit_satellite( + hf_row, config, dirs={'output/data': '/tmp'}, tides_o=tides_o, interior_o=interior_o + ) + + assert len(captured_filter_values) > 0 + assert all(fv == expected_filter for fv in captured_filter_values) + + +def test_evolve_orbit_satellite_activates_evection_zone_from_near_band_ahead_of_in_band( + monkeypatch, +): + """``tides_o.evection_zone_active`` must go True from the wider + ``resonance_margin_approach`` margin alone, independently of (and + firing before) the tighter/hysteretic in-band detector -- a state + whose raw, undebounced distance sits inside the approach margin but + outside the entry margin must still activate the zone, and moving far + outside even the approach margin must clear it again. + """ + from proteus.orbit import satellite as sat_mod + + monkeypatch.setattr(sat_mod, 'ps1d_evec', lambda *a, **kw: None) + + def _fake_in_band(d_a_rel_now): + def _inner(hf_row, resonance_state, **kw): + resonance_state['d_a_rel_now'] = d_a_rel_now + resonance_state['active'] = False + return False + + return _inner + + config = _make_satellite_config('ps1d_evec') + config.orbit.solver.resonance_margin_approach = 0.30 + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1.0 + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + # Inside the 0.30 approach margin but outside a (typical, tighter) + # entry margin: the zone must activate even though the tight/hysteretic + # detector never does. + monkeypatch.setattr(sat_mod, '_in_evection_band', _fake_in_band(0.20)) + hf_row_near = _make_ps1d_evec_hf_row(ecc=0.05) + sat_mod.evolve_orbit_satellite( + hf_row_near, + config, + dirs={'output/data': '/tmp'}, + tides_o=tides_o, + interior_o=interior_o, + ) + assert tides_o.evection_zone_active is True + + # Discrimination: pushing the raw distance outside even the wider + # approach margin must clear the zone too, not just leave it stuck on + # from the previous call. + monkeypatch.setattr(sat_mod, '_in_evection_band', _fake_in_band(0.50)) + hf_row_far = _make_ps1d_evec_hf_row(ecc=0.05) + sat_mod.evolve_orbit_satellite( + hf_row_far, config, dirs={'output/data': '/tmp'}, tides_o=tides_o, interior_o=interior_o + ) + assert tides_o.evection_zone_active is False + + +def test_evolve_orbit_satellite_exports_a_single_evection_dt_cap_yr_column(monkeypatch): + """``hf_row`` must carry ``evection_dt_cap_yr`` and nothing else from + the evection mechanism -- ``in_evection_band``/``near_evection_band`` + are gone from the helpfile entirely; the zone state they used to carry + now lives only on ``tides_o.evection_zone_active`` (internal, not + exported), and the timestep controller reads this one precomputed + column instead of recomputing anything from flags. + """ + from proteus.orbit import satellite as sat_mod + + monkeypatch.setattr(sat_mod, 'ps1d_evec', lambda *a, **kw: None) + + config = _make_satellite_config('ps1d_evec') + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1.0 + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + hf_row = _make_ps1d_evec_hf_row(ecc=0.05) + + sat_mod.evolve_orbit_satellite( + hf_row, config, dirs={'output/data': '/tmp'}, tides_o=tides_o, interior_o=interior_o + ) + + assert 'in_evection_band' not in hf_row + assert 'near_evection_band' not in hf_row + assert 'evection_dt_cap_yr' in hf_row + assert isinstance(hf_row['evection_dt_cap_yr'], float) + + +def test_evolve_orbit_satellite_ps1d_evec_stores_dense_samples_in_band( + tmp_path, monkeypatch, _fast_hansen_table +): + """With the resonance band forced active for the whole call, EVERY + accepted solver-clock sample must reach + ``fine_evection_data.csv`` (Clock 3 == Clock 2, no throttling) -- + the in-band storage policy documented in + ``_flush_fine_evection_csv``, now exercised through the real + ``evolve_orbit_satellite`` -> ``ps1d_evec`` pipeline rather than + called directly. + """ + from proteus.orbit import satellite as sat_mod + + monkeypatch.setattr(sat_mod, '_in_evection_band', lambda hf_row, state, **kw: True) + + hf_row = _make_ps1d_evec_hf_row(ecc=0.3) + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1.0 + config = _make_satellite_config('ps1d_evec') + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + sat_mod.evolve_orbit_satellite( + hf_row, + config, + dirs={'output/data': str(tmp_path)}, + tides_o=tides_o, + interior_o=interior_o, + ) + + csv_path = tmp_path / 'fine_evection_data.csv' + assert csv_path.exists() + lines = csv_path.read_text().splitlines() + # Discrimination: a real multi-substep, in-band run stores far more + # than a handful of rows (dense solver-clock sampling), not just + # one row per accepted macro-substep. + assert len(lines) - 1 > 20 + + +def test_evolve_orbit_satellite_ps1d_evec_throttles_samples_out_of_band( + tmp_path, monkeypatch, _fast_hansen_table +): + """With the resonance band forced OFF for the whole call, stored + samples must be throttled to the storage-clock target spacing + (``fine_csv_target_rel_dt``), landing on far fewer rows than the + in-band case above for a comparable elapsed time and solver + activity. + """ + from proteus.orbit import satellite as sat_mod + + monkeypatch.setattr(sat_mod, '_in_evection_band', lambda hf_row, state, **kw: False) + + hf_row = _make_ps1d_evec_hf_row(ecc=0.3) + interior_o = _make_interior_for_c_planet(density=5500.0) + interior_o.dt = 1.0 + config = _make_satellite_config('ps1d_evec') + config.orbit.solver.fine_csv_target_rel_dt = 0.1 + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + sat_mod.evolve_orbit_satellite( + hf_row, + config, + dirs={'output/data': str(tmp_path)}, + tides_o=tides_o, + interior_o=interior_o, + ) + + csv_path = tmp_path / 'fine_evection_data.csv' + if csv_path.exists(): + lines = csv_path.read_text().splitlines() + # Discrimination: far sparser than the in-band case (>20 rows + # there for the same 1 yr span); out-of-band throttling to + # ~10 target points (1/0.1) keeps this in the single digits. + assert len(lines) - 1 < 15 + # else: zero out-of-band samples happened to cross a storage + # target within this short a span -- also a valid (sparse) outcome + # for the throttling policy, not a failure. + + +def test_ps1d_evec_finite_output_for_high_eccentricity_in_band(_fast_hansen_table): + """Adversarial-but-physical: high initial eccentricity with the + evection term active must not produce non-finite output.""" + hf_row = _make_ps1d_evec_hf_row(ecc=0.7) + tides_o = _make_ps1d_evec_tides(-0.002 - 0.004j) + + ps1d_evec(hf_row, tides_o, dt=0.5, config=_SOLVER_CONFIG, filter_value=1.0) + + assert np.isfinite(hf_row['eccentricity_sat']) + assert np.isfinite(hf_row['semimajorax_sat']) + assert np.isfinite(hf_row['evection_angle']) + assert hf_row['eccentricity_sat'] >= 0.0 diff --git a/tests/orbit/test_timestep.py b/tests/orbit/test_timestep.py new file mode 100644 index 000000000..bc55dff86 --- /dev/null +++ b/tests/orbit/test_timestep.py @@ -0,0 +1,470 @@ +"""Unit tests for the evection dt-cap logic (``proteus.orbit.timestep``). + +Mirrors ``src/proteus/orbit/timestep.py``, which was split out of +``proteus.orbit.satellite`` (and, before that, out of +``proteus.interior_energetics.timestep.next_step`` -- see +``_estimate_evection_dt_cap_yr``'s own docstring for that lineage) so this +dt-cap machinery reads as one coherent unit. + +Exercises: + +- ``_evection_rate_cap_yr``: the secular-rate half of the cap (bounding + the fractional change in ``eccentricity_sat`` per macro-step, not a + fixed number of years) -- disable routes (ceiling zero, zone inactive, + no history), the closed-form rate scaling with a discrimination guard + against using ``e_prev`` instead of ``e_now``, the ``de_floor`` clamp + at capture onset, the zero-rate fallback at eccentricity peak, and the + ``evection_rate_window``'s oscillation-smoothing behaviour (a pure + sinusoid's two-point aliasing vs. a full-period average recovering the + ceiling, and a genuine secular trend still tracked through the window). +- ``_estimate_evection_dt_cap_yr``: the growth-limiter half (bounding dt + to ``dt_prev_actual_yr * evection_growth_factor`` while the zone is + active or during the ``evection_cooldown_iters`` tail after leaving + it), its ``tides_o.evection_cooldown_remaining`` counter refresh/decay, + the opt-in-by-default disable, and that the two halves are actually + folded together via ``min()`` rather than one silently overriding the + other. + +``proteus.orbit.satellite.evolve_orbit_satellite``'s own use of these +functions (maintaining ``tides_o.evection_ecc_history``, exporting the +single ``hf_row['evection_dt_cap_yr']`` column) is covered in +``tests/orbit/test_satellite.py`` instead. + +See also: +- docs/How-to/test_infrastructure.md +- docs/How-to/test_building.md +- docs/How-to/test_categorization.md +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import numpy as np +import pytest + +from proteus.orbit.common import Tides_t +from proteus.orbit.timestep import _estimate_evection_dt_cap_yr, _evection_rate_cap_yr + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +# --------------------------------------------------------------------------- +# _evection_rate_cap_yr: the secular-rate half of the evection dt cap +# (the other half is the growth limiter, see the section below). Ported +# from tests/interior_energetics/test_timestep_evection_event.py (now +# deleted -- that logic lived through timestep.next_step before the whole +# mechanism moved to orbit; see _estimate_evection_dt_cap_yr's own +# docstring). The closed-form math is unchanged; only the history source +# changed, from a `hf_all` DataFrame to `tides_o.evection_ecc_history`, +# and zone-active detection is now the CALLER's job (a plain bool +# parameter), not recomputed internally from hf_row flags -- those flags +# no longer exist at all; see +# tests/orbit/test_satellite.py::test_evolve_orbit_satellite_exports_a_single_evection_dt_cap_yr_column +# for that removal. +# --------------------------------------------------------------------------- + + +def _dt_cap_config( + evection_maximum=10.0, + evection_target_rel_de=0.05, + evection_de_floor=0.02, + evection_rate_window=2, + evection_growth_factor=None, + evection_cooldown_iters=None, +) -> Any: + return cast( + Any, + SimpleNamespace( + params=SimpleNamespace( + dt=SimpleNamespace( + evection_maximum=evection_maximum, + evection_target_rel_de=evection_target_rel_de, + evection_de_floor=evection_de_floor, + evection_rate_window=evection_rate_window, + evection_growth_factor=evection_growth_factor, + evection_cooldown_iters=evection_cooldown_iters, + ) + ) + ), + ) + + +def _tides_with_ecc_history(times, eccs) -> Tides_t: + return Tides_t(evection_ecc_history=list(zip(times, eccs))) + + +def _oscillating_ecc_history( + n_points=11, period_yr=50.0, amplitude=0.05, e0=0.5, secular_rate=0.0 +): + """A synthetic eccentricity_sat history driven by a pure sinusoidal + oscillation -- standing in for the evection angle's own circulation/ + libration (``de_res`` in ``ps1d_evec``'s ``orbitals()``, whose + ``dphi/dt`` is generically comparable to or faster than ``n_star``, + not slow/adiabatic) -- optionally with a genuine secular trend added + on top. Sampled every 10 yr; the default 11 points span exactly two + full periods of the default 50 yr period. + """ + t = np.arange(n_points, dtype=float) * 10.0 + e = e0 + amplitude * np.sin(2 * np.pi * t / period_yr) + secular_rate * t + return list(t), list(e) + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_disabled_when_ceiling_is_none(): + """``evection_maximum=None`` (the schema default, 'none' in TOML) + disables the whole mechanism regardless of zone-active or the + eccentricity history -- the source's opt-out sentinel. + """ + tides_o = _tides_with_ecc_history([0.0, 10.0], [0.10, 0.15]) + + disabled = _dt_cap_config(evection_maximum=None) + assert _evection_rate_cap_yr(tides_o, True, disabled) == np.inf + + # Discrimination: the same history/zone_active with a positive ceiling + # is constrained, so the sentinel above follows from the disable + # switch, not from this history never constraining anything. + enabled = _dt_cap_config(evection_maximum=10.0) + assert np.isfinite(_evection_rate_cap_yr(tides_o, True, enabled)) + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_disabled_when_zone_inactive(): + """A positive ceiling with rich history but ``zone_active=False`` must + not constrain the timestep -- this cap only ever applies inside (or + approaching) the evection zone. + """ + config = _dt_cap_config(evection_maximum=10.0) + tides_o = _tides_with_ecc_history([0.0, 10.0], [0.10, 0.20]) + assert _evection_rate_cap_yr(tides_o, False, config) == np.inf + assert np.isfinite(_evection_rate_cap_yr(tides_o, True, config)) + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_falls_back_to_ceiling_without_history(): + """No usable history (empty, or a single sample right at band entry) + must return the ceiling, not crash and not silently disable the cap. + """ + config = _dt_cap_config(evection_maximum=10.0) + assert _evection_rate_cap_yr(Tides_t(), True, config) == pytest.approx(10.0) + + one_sample = _tides_with_ecc_history([0.0], [0.10]) + assert _evection_rate_cap_yr(one_sample, True, config) == pytest.approx(10.0) + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_scales_inversely_with_observed_rate(): + """A faster observed |de/dt| must produce a SMALLER cap than a slower + one at the same eccentricity level and configuration -- this is the + entire point of replacing the flat ceiling with a rate-based bound: + fast capture gets a tight cap, a slowly-decaying quasi-resonant tail + does not. + + Pinned against the closed-form formula + ``dt_cap = target_rel_de * max(e_now, de_floor) / de_dt`` with + target_rel_de=0.05, de_floor=0.02: e goes 0.10 -> 0.12 over 10 yr + (de_dt=0.002/yr) gives dt_cap = 0.05*0.12/0.002 = 3.0 yr. + """ + config = _dt_cap_config( + evection_maximum=100.0, evection_target_rel_de=0.05, evection_de_floor=0.02 + ) + fast = _tides_with_ecc_history([0.0, 10.0], [0.10, 0.12]) # de/dt = 2.0e-3 /yr + slow = _tides_with_ecc_history([0.0, 10.0], [0.10, 0.101]) # de/dt = 1.0e-4 /yr + + cap_fast = _evection_rate_cap_yr(fast, True, config) + cap_slow = _evection_rate_cap_yr(slow, True, config) + + assert cap_fast == pytest.approx(3.0, rel=1e-9) + assert cap_fast < cap_slow + + # Discrimination: using e_prev (0.10) instead of e_now (0.12) in the + # numerator would give 0.05*0.10/0.002 = 2.5 yr, off by 0.5 yr -- well + # outside the tolerance below. + wrong_uses_e_prev = 0.05 * 0.10 / 2.0e-3 + assert abs(cap_fast - wrong_uses_e_prev) > 0.1 + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_uses_de_floor_for_tiny_eccentricity(): + """Right at capture onset, e_now can be far smaller than de_floor; the + ratio's denominator must clamp to de_floor rather than blow the + allowed step down toward zero for a physically unremarkable reason + (a tiny starting e, not a fast rate). + """ + config = _dt_cap_config( + evection_maximum=100.0, evection_target_rel_de=0.05, evection_de_floor=0.02 + ) + # e: 0.001 -> 0.002 over 10 yr (de_dt = 1.0e-4 /yr), e_now << de_floor. + tides_o = _tides_with_ecc_history([0.0, 10.0], [0.001, 0.002]) + + cap = _evection_rate_cap_yr(tides_o, True, config) + expected = 0.05 * 0.02 / 1.0e-4 # de_floor used, not e_now=0.002 + assert cap == pytest.approx(expected, rel=1e-9) + + # Discrimination: using e_now directly (0.002) instead of the floor + # would give a cap 10x smaller (1.0 yr vs 10.0 yr) -- clearly distinct. + wrong_uses_e_now = 0.05 * 0.002 / 1.0e-4 + assert abs(cap - wrong_uses_e_now) > 1.0 + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_falls_back_to_ceiling_at_zero_rate(): + """Exactly at peak eccentricity, de/dt crosses zero by definition. The + rate-based estimate is undefined there, NOT infinite/huge: the cap + must fall back to the ceiling rather than let a naive e/de_dt blow up + at the most dynamically sensitive point in the trajectory. + """ + config = _dt_cap_config(evection_maximum=7.0) + plateau = _tides_with_ecc_history([0.0, 10.0], [0.60, 0.60]) # de/dt == 0 exactly + + assert _evection_rate_cap_yr(plateau, True, config) == pytest.approx(7.0) + + # Discrimination: a nonzero rate at the same ceiling gives a value + # strictly below it. + rising = _tides_with_ecc_history([0.0, 10.0], [0.60, 0.68]) + assert _evection_rate_cap_yr(rising, True, config) < 7.0 + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_falls_back_to_ceiling_at_zero_time_span(): + """Two (or more) history samples recorded at the same timestamp give a + zero-width window, so the secular slope ``de/dt`` is undefined (a + ``0/0`` division, not merely small). The cap must fall back to the + ceiling here too, the same as the zero-history and zero-rate cases, + rather than raise a ``ZeroDivisionError`` or propagate a NaN/inf cap. + """ + config = _dt_cap_config(evection_maximum=9.0) + same_instant = _tides_with_ecc_history([5.0, 5.0], [0.30, 0.34]) + + assert _evection_rate_cap_yr(same_instant, True, config) == pytest.approx(9.0) + + # Discrimination: the same eccentricity change over a nonzero span is + # constrained well below the ceiling, so the fallback above follows + # from the degenerate time span, not from this de being too small to + # ever constrain anything. + spread_out = _tides_with_ecc_history([0.0, 10.0], [0.30, 0.34]) + assert _evection_rate_cap_yr(spread_out, True, config) < 9.0 + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_default_window_aliases_onto_pure_oscillation(): + """With the default ``evection_rate_window=2`` (a plain two-point + diff), a PURE oscillation in eccentricity with zero net secular trend + still produces a small, clearly-below-ceiling cap -- the two-point + estimate aliases onto the oscillation's local slope rather than the + (here exactly zero) secular trend. This is the failure mode reported + for a real evection-band run: staying stuck at a tiny dt for the + whole resonance episode, not just near genuine capture. + """ + config = _dt_cap_config(evection_maximum=50.0, evection_rate_window=2) + tides_o = _tides_with_ecc_history(*_oscillating_ecc_history()) + + cap = _evection_rate_cap_yr(tides_o, True, config) + assert cap < 10.0 + assert cap > 0.0 + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_wide_window_averages_out_pure_oscillation(): + """The SAME pure-oscillation history as above, but with + ``evection_rate_window`` covering the whole 2-period span: the + least-squares secular slope is close to zero (a full-period average + of a sinusoid vanishes), so the cap must relax back to the ceiling + instead of staying pinned at the two-point aliased value. + """ + times, eccs = _oscillating_ecc_history() + tides_wide = _tides_with_ecc_history(times, eccs) + tides_narrow = _tides_with_ecc_history(times, eccs) + + wide_config = _dt_cap_config(evection_maximum=50.0, evection_rate_window=11) + cap_wide = _evection_rate_cap_yr(tides_wide, True, wide_config) + assert cap_wide == pytest.approx(50.0, rel=1e-9) + + # Discrimination: the SAME history/config except for the window size + # gives a value more than 5x smaller -- the ceiling recovery above + # follows from widening the window, not from this history/config + # combination never constraining anything in the first place. + narrow_config = _dt_cap_config(evection_maximum=50.0, evection_rate_window=2) + cap_narrow = _evection_rate_cap_yr(tides_narrow, True, narrow_config) + assert cap_wide > 5.0 * cap_narrow + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_wide_window_still_tracks_a_genuine_secular_trend(): + """A genuine secular trend (0.002/yr) superimposed on the SAME + oscillation must still produce a finite cap comfortably below the + ceiling, of the right order of magnitude for that trend -- the + windowing must not mask a real approach to capture just because it + also rejects the oscillation's own contribution. + """ + config = _dt_cap_config(evection_maximum=200.0, evection_rate_window=11) + tides_o = _tides_with_ecc_history(*_oscillating_ecc_history(secular_rate=0.002)) + + cap = _evection_rate_cap_yr(tides_o, True, config) + # Pure-trend closed form: target_rel_de * e_now / secular_rate + # = 0.05 * 0.7 / 0.002 = 17.5 yr; the fitted value should land within + # roughly a factor of 2 of that. + assert cap > 5.0 + # Discrimination: nowhere near the 200 yr ceiling, which is what a fit + # swamped by the oscillation into reporting an essentially zero rate + # would produce instead. + assert cap < 40.0 + + +@pytest.mark.physics_invariant +def test_evection_rate_cap_yr_uses_available_samples_when_shorter_than_window(): + """A configured window larger than the available history (e.g. early + in a run, just after the zone flag first activates) must use + whatever samples exist rather than raising or silently disabling the + cap. + """ + config = _dt_cap_config(evection_maximum=10.0, evection_rate_window=50) + tides_o = _tides_with_ecc_history([0.0, 10.0, 20.0], [0.10, 0.11, 0.13]) + + cap = _evection_rate_cap_yr(tides_o, True, config) + assert np.isfinite(cap) + assert cap > 0.0 + + # Discrimination: matches the least-squares fit over exactly the 3 + # available samples (not, say, silently falling back to a two-point + # diff over the last two only). + slope, _ = np.polyfit([0.0, 10.0, 20.0], [0.10, 0.11, 0.13], 1) + expected = min(10.0, 0.05 * max(0.13, 0.02) / abs(slope)) + assert cap == pytest.approx(expected, rel=1e-9) + + +# --------------------------------------------------------------------------- +# _estimate_evection_dt_cap_yr: the growth-limiter half (folded with +# _evection_rate_cap_yr above via min()) plus the cooldown counter. Ported +# from tests/interior_energetics/test_timestep_evection_event.py (now +# deleted), which used to test this through timestep.next_step and +# Interior_t.evection_cooldown_remaining before the growth limiter moved +# into orbit alongside the rate cap. ``dt_prev_actual_yr`` (the actual +# elapsed Time of the call whose end this cap is computed at) stands in +# for the old ``hf_all['Time']`` two-row gap; the cooldown counter now +# lives on ``tides_o`` instead of ``interior_o``. +# --------------------------------------------------------------------------- + + +@pytest.mark.physics_invariant +def test_estimate_evection_dt_cap_yr_growth_limiter_caps_regrowth_after_exit(): + """After the band is left (zone_active=False) but the cooldown counter + is still armed, the cap must bound dt to + ``dt_prev_actual_yr * evection_growth_factor`` rather than leaving it + unconstrained -- otherwise leaving the band would be indistinguishable, + dt-wise, from never having entered it. + """ + config = _dt_cap_config( + evection_maximum=10.0, evection_growth_factor=1.3, evection_cooldown_iters=5 + ) + tides_o = Tides_t(evection_cooldown_remaining=3) + + # Last accepted macro-step was 8 yr. + cap = _estimate_evection_dt_cap_yr(tides_o, False, 8.0, config) + assert cap == pytest.approx(8.0 * 1.3, rel=1e-9) + # The counter must count down by exactly one, not reset or freeze. + assert tides_o.evection_cooldown_remaining == 2 + + # Discrimination: with the counter already at zero and the zone + # inactive, the limiter must be fully disengaged -- np.inf (unbound). + expired = Tides_t(evection_cooldown_remaining=0) + cap_expired = _estimate_evection_dt_cap_yr(expired, False, 8.0, config) + assert cap_expired == np.inf + + +@pytest.mark.physics_invariant +def test_estimate_evection_dt_cap_yr_cooldown_rearms_while_zone_is_active(): + """Every call where the zone is active must refresh + ``evection_cooldown_remaining`` to ``evection_cooldown_iters``, not + merely leave a stale counter in place -- a long stay in the band must + not exhaust the cooldown tail before the system actually exits. + """ + config = _dt_cap_config( + evection_maximum=10.0, evection_growth_factor=1.3, evection_cooldown_iters=6 + ) + tides_o = Tides_t(evection_cooldown_remaining=1) + + _estimate_evection_dt_cap_yr(tides_o, True, 8.0, config) + assert tides_o.evection_cooldown_remaining == 6 + + # Discrimination: had the zone been inactive, the same starting + # counter would only decrement by one, not jump up to the refresh value. + inactive = Tides_t(evection_cooldown_remaining=1) + _estimate_evection_dt_cap_yr(inactive, False, 8.0, config) + assert inactive.evection_cooldown_remaining == 0 + + +@pytest.mark.physics_invariant +def test_estimate_evection_dt_cap_yr_growth_limiter_disabled_by_default(): + """``evection_growth_factor=None`` (the schema default) must leave the + growth-limiter half unconstrained even with an active zone and a small + previous step -- opt-in only, matching the global + ``max_growth_factor``'s own disabled-by-default convention. + """ + config = _dt_cap_config( + evection_maximum=None, evection_growth_factor=None, evection_cooldown_iters=None + ) + cap = _estimate_evection_dt_cap_yr(Tides_t(), True, 0.5, config) + assert cap == np.inf + + # Discrimination: the identical scenario WITH a positive growth factor + # does constrain the cap to dt_prev*factor (0.5*1.3=0.65 yr), so the + # unconstrained value above follows from the disable switch, not from + # this scenario never triggering the limiter at all. + config_on = _dt_cap_config( + evection_maximum=None, evection_growth_factor=1.3, evection_cooldown_iters=None + ) + cap_on = _estimate_evection_dt_cap_yr(Tides_t(), True, 0.5, config_on) + assert cap_on == pytest.approx(0.5 * 1.3, rel=1e-9) + + +@pytest.mark.physics_invariant +def test_estimate_evection_dt_cap_yr_growth_limiter_needs_a_positive_prior_step(): + """The growth limiter is keyed to ``dt_prev_actual_yr * growth_factor``, + so it must stay unconstrained (np.inf) when there is no usable prior + step size to scale from -- ``None`` (first-ever call, nothing accepted + yet) or a non-positive value -- even though the growth factor is + enabled and the zone is active. A regression that dropped this inner + guard would raise (``None * float``) or produce a nonsensical + zero/negative cap instead of simply not constraining anything yet. + """ + config = _dt_cap_config( + evection_maximum=None, evection_growth_factor=1.3, evection_cooldown_iters=None + ) + assert _estimate_evection_dt_cap_yr(Tides_t(), True, None, config) == np.inf + assert _estimate_evection_dt_cap_yr(Tides_t(), True, 0.0, config) == np.inf + + # Discrimination: the identical config/zone state WITH a genuine + # positive prior step DOES constrain the cap, so the np.inf results + # above follow from the missing/non-positive dt_prev_actual_yr, not + # from this scenario never engaging the growth limiter at all. + assert _estimate_evection_dt_cap_yr(Tides_t(), True, 0.5, config) == pytest.approx( + 0.5 * 1.3, rel=1e-9 + ) + + +@pytest.mark.physics_invariant +def test_estimate_evection_dt_cap_yr_folds_rate_and_growth_caps_via_min(): + """When BOTH the rate cap and the growth-limiter cap would bind, the + combined function returns the smaller of the two -- confirms the + two halves are actually folded together, not one silently + overriding the other. + """ + config = _dt_cap_config( + evection_maximum=100.0, + evection_target_rel_de=0.05, + evection_de_floor=0.02, + evection_growth_factor=1.0, + evection_cooldown_iters=1, + ) + # Rate cap: e 0.10 -> 0.12 over 10 yr -> 0.05*0.12/0.002 = 3.0 yr. + tides_o = _tides_with_ecc_history([0.0, 10.0], [0.10, 0.12]) + # Growth cap: dt_prev_actual_yr * 1.0 = 20.0 yr -- looser than the rate cap. + cap_rate_binds = _estimate_evection_dt_cap_yr(tides_o, True, 20.0, config) + assert cap_rate_binds == pytest.approx(3.0, rel=1e-9) + + # Growth cap: dt_prev_actual_yr * 1.0 = 1.0 yr -- tighter than the rate cap. + tides_o2 = _tides_with_ecc_history([0.0, 10.0], [0.10, 0.12]) + cap_growth_binds = _estimate_evection_dt_cap_yr(tides_o2, True, 1.0, config) + assert cap_growth_binds == pytest.approx(1.0, rel=1e-9) diff --git a/tests/orbit/test_wrapper.py b/tests/orbit/test_wrapper.py index f111d0456..d48287277 100644 --- a/tests/orbit/test_wrapper.py +++ b/tests/orbit/test_wrapper.py @@ -11,6 +11,9 @@ from __future__ import annotations +import types +from unittest.mock import MagicMock, patch + import numpy as np import pytest @@ -20,12 +23,33 @@ update_period, update_rochelimit, update_separation, + update_separation_sat, ) from proteus.utils.constants import AU, M_earth, M_sun, R_earth, const_G pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] +def _make_satellite_config_stub(): + """Stand-in for ``config.orbit.satellite`` matching the real + ``Satellite`` attrs-class field names and defaults in + ``src/proteus/config/_orbit.py``. ``run_orbit``'s init branch + unconditionally reads ``config.orbit.satellite.mass_sat`` etc. -- + it is always the ``Satellite`` object, never a bare bool, even + when no satellite is modeled (``include_satellite=False``). + """ + return types.SimpleNamespace( + include_satellite=False, + mass_sat=0.012, + radius_sat=0.273, + axial_period_sat=None, + semimajoraxis_sat=0.133, + eccentricity_sat=0.0, + evection_angle=0.0, + c_factor_sat=0.4, + ) + + # --------------------------------------------------------------------------- # update_separation # --------------------------------------------------------------------------- @@ -76,13 +100,22 @@ def test_perihelion_is_sma_times_one_minus_eccentricity(): @pytest.mark.physics_invariant -def test_perigee_passes_through_satellite_sma(): - """Periapsis around the planet is currently the satellite SMA - (circular-orbit approximation). The value must pass through - unmodified for a downstream consumer.""" - hf_row = {'semimajorax': AU, 'eccentricity': 0.1, 'semimajorax_sat': 3.5e8} - update_separation(hf_row) - assert hf_row['perigee'] == pytest.approx(3.5e8, rel=1e-12) +def test_update_separation_sat_perigee_uses_eccentric_periapsis_formula(): + """Periapsis around the planet (``perigee``) is now computed with + the same periapsis formula as the planet-star ``perihelion`` + (``sma * (1 - ecc)``), not a circular-orbit sma passthrough -- + ``update_separation_sat`` is the satellite analogue of + ``update_separation``, now split into its own function since the + two use independent (semimajorax_sat, eccentricity_sat) inputs. + """ + hf_row = {'semimajorax_sat': 3.5e8, 'eccentricity_sat': 0.1} + update_separation_sat(hf_row) + expected_perigee = 3.5e8 * (1 - 0.1) + assert hf_row['perigee'] == pytest.approx(expected_perigee, rel=1e-12) + # Discrimination: the old circular-orbit passthrough (perigee == sma + # exactly) would miss the eccentricity correction entirely. + assert hf_row['perigee'] != pytest.approx(3.5e8, rel=1e-6) + assert hf_row['separation_sat'] == pytest.approx(3.5e8 * (1 + 0.5 * 0.1**2), rel=1e-12) # Positivity guard: perigee is a distance, must be > 0. assert hf_row['perigee'] > 0.0 @@ -298,6 +331,24 @@ def test_update_period_logs_error_on_unphysical_low_total_mass(caplog): assert hf_row['orbital_period'] > 1.0e20 +def test_update_period_sat_logs_error_on_unphysical_low_total_mass(caplog): + """Counterpart to ``update_period``'s sanity check, for the + planet+satellite total mass.""" + import logging + + from proteus.orbit.wrapper import update_period_sat + + hf_row = { + 'M_planet': 1.0e2, + 'M_sat': 1.0e2, + 'semimajorax_sat': 1.0 * AU, + } + with caplog.at_level(logging.ERROR, logger='fwl.proteus.orbit.wrapper'): + update_period_sat(hf_row) + assert any('Unreasonable planet+satellite mass' in rec.message for rec in caplog.records) + assert hf_row['orbital_period_sat'] > 1.0e20 + + # --------------------------------------------------------------------------- # run_orbit dispatch branches: init_orbit, satellite, lovepy, dummy, Hill # limit and Roche limit warnings. The full dispatch pulls in interior_o @@ -313,7 +364,7 @@ def test_init_orbit_short_circuits_when_module_is_none_string(): Python literal) would fall through to the lovepy import. Patch lovepy.import_lovepy to MagicMock and confirm it stays uncalled. """ - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock from proteus.orbit.wrapper import init_orbit @@ -334,7 +385,7 @@ def test_init_orbit_invokes_lovepy_import_when_module_is_lovepy(): the lovepy-import branch at lines 31-34. """ import logging - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock from proteus.orbit.wrapper import init_orbit @@ -356,7 +407,7 @@ def test_init_orbit_invokes_lovepy_import_when_module_is_lovepy(): def test_run_orbit_dummy_module_sets_imk2_via_dummy_orbit(): - """The dummy tides path computes Imk2 via run_dummy_orbit and + """The dummy tides path computes Imk2 via run_dummy_tides and zeroes interior_o.tides at the top of run_orbit. Discriminating: the lovepy and "no module" branches return @@ -364,7 +415,7 @@ def test_run_orbit_dummy_module_sets_imk2_via_dummy_orbit(): dummy branch's Imk2 to the mocked return so a dispatch-swap is caught. """ - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock from proteus.orbit.wrapper import run_orbit @@ -373,7 +424,7 @@ def test_run_orbit_dummy_module_sets_imk2_via_dummy_orbit(): config.orbit.evolve = False config.orbit.eccentricity = 0.0 config.orbit.semimajoraxis = 1.0 - config.orbit.satellite = False + config.orbit.satellite = _make_satellite_config_stub() config.orbit.semimajoraxis_sat = 1.0e8 config.orbit.axial_period = None config.orbit.instellation_method = 'sep' @@ -388,15 +439,32 @@ def test_run_orbit_dummy_module_sets_imk2_via_dummy_orbit(): 'R_int': R_earth, 'R_obs': R_earth, 'R_xuv': R_earth, - # update_separation reads this on the satellite=False path - # before run_orbit sets it; seed it upstream. + # Superseded by run_orbit's init branch (which always sets + # semimajorax_sat from config.orbit.satellite.semimajoraxis_sat); + # harmless placeholder, kept for a defensive default. 'semimajorax_sat': 1.0e8, + # Time <= 1 keeps run_orbit on its initial-setup branch, which + # none of these tests need to escape: the alternative (evolved) + # branch calls evolve_orbit_star/evolve_orbit_satellite, which + # are not mocked here. + 'Time': 0.0, + # plan_sat_am is only ever written by evolve_orbit_star / + # evolve_orbit_satellite (the evolved-timestep branch this + # test never reaches), yet run_orbit's satellite logging block + # reads it unconditionally. Pre-seeded so that unrelated + # (already-latent) gap doesn't fail these dispatch/Roche tests. + 'plan_sat_am': 0.0, + # plan_star_am is only ever written by sp1d (star_planet_model + # == 'sp1d', on an evolved timestep), yet run_orbit logs it + # unconditionally for every model. Pre-seeded for the same + # reason as plan_sat_am above. + 'plan_star_am': 0.0, } interior_o = MagicMock() interior_o.dt = 1.0 interior_o.phi = np.zeros(5) - with patch('proteus.orbit.dummy.run_dummy_orbit', return_value=0.0042) as mock_dummy: - run_orbit(hf_row, config, dirs={}, interior_o=interior_o) + with patch('proteus.orbit.dummy.run_dummy_tides', return_value=0.0042) as mock_dummy: + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) mock_dummy.assert_called_once() assert hf_row['Imk2'] == pytest.approx(0.0042, rel=1e-12) # Dispatch guard: the dummy branch must NOT call lovepy. @@ -405,13 +473,52 @@ def test_run_orbit_dummy_module_sets_imk2_via_dummy_orbit(): assert hf_row['axial_period'] == pytest.approx(hf_row['orbital_period'], rel=1e-12) +def test_run_orbit_lovepy_module_sets_imk2_via_run_lovepy(): + """The lovepy tides path computes Imk2 via run_lovepy -- the + counterpart to the already-tested dummy-module dispatch.""" + from proteus.orbit.wrapper import run_orbit + + config = MagicMock() + config.orbit.module = 'lovepy' + config.orbit.evolve = False + config.orbit.eccentricity = 0.0 + config.orbit.semimajoraxis = 1.0 + config.orbit.satellite = _make_satellite_config_stub() + config.orbit.semimajoraxis_sat = 1.0e8 + config.orbit.axial_period = None + config.orbit.instellation_method = 'sep' + config.star.module = 'mors' + config.params.stop.disint.offset_spin = 0.0 + config.params.stop.disint.offset_roche = 0.0 + + hf_row = { + 'M_star': M_sun, + 'M_planet': M_earth, + 'M_int': M_earth, + 'R_int': R_earth, + 'R_obs': R_earth, + 'R_xuv': R_earth, + 'semimajorax_sat': 1.0e8, + 'Time': 0.0, + 'plan_sat_am': 0.0, + 'plan_star_am': 0.0, + } + interior_o = MagicMock() + interior_o.dt = 1.0 + interior_o.phi = np.zeros(4) + with patch('proteus.orbit.lovepy.run_lovepy', return_value=0.0033) as mock_lovepy: + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) + mock_lovepy.assert_called_once() + assert hf_row['Imk2'] == pytest.approx(0.0033, rel=1e-12) + + def test_run_orbit_no_module_sets_imk2_to_zero(): """When config.orbit.module is None (not 'dummy', not 'lovepy'), Imk2 is set to 0.0; no tide submodule is invoked. Edge: limit-input case for "tides disabled". """ - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock from proteus.orbit.wrapper import run_orbit @@ -420,7 +527,7 @@ def test_run_orbit_no_module_sets_imk2_to_zero(): config.orbit.evolve = False config.orbit.eccentricity = 0.0 config.orbit.semimajoraxis = 1.0 - config.orbit.satellite = False + config.orbit.satellite = _make_satellite_config_stub() config.orbit.semimajoraxis_sat = 1.0e8 config.orbit.axial_period = 24.0 # hours; exercises the non-None branch config.orbit.instellation_method = 'sep' @@ -434,17 +541,34 @@ def test_run_orbit_no_module_sets_imk2_to_zero(): 'R_int': R_earth, 'R_obs': R_earth, 'R_xuv': R_earth, - # update_separation reads this on the satellite=False path - # before run_orbit sets it; seed it upstream. + # Superseded by run_orbit's init branch (which always sets + # semimajorax_sat from config.orbit.satellite.semimajoraxis_sat); + # harmless placeholder, kept for a defensive default. 'semimajorax_sat': 1.0e8, + # Time <= 1 keeps run_orbit on its initial-setup branch, which + # none of these tests need to escape: the alternative (evolved) + # branch calls evolve_orbit_star/evolve_orbit_satellite, which + # are not mocked here. + 'Time': 0.0, + # plan_sat_am is only ever written by evolve_orbit_star / + # evolve_orbit_satellite (the evolved-timestep branch this + # test never reaches), yet run_orbit's satellite logging block + # reads it unconditionally. Pre-seeded so that unrelated + # (already-latent) gap doesn't fail these dispatch/Roche tests. + 'plan_sat_am': 0.0, + # plan_star_am is only ever written by sp1d (star_planet_model + # == 'sp1d', on an evolved timestep), yet run_orbit logs it + # unconditionally for every model. Pre-seeded for the same + # reason as plan_sat_am above. + 'plan_star_am': 0.0, } interior_o = MagicMock() interior_o.dt = 1.0 interior_o.phi = np.zeros(3) - with patch('proteus.orbit.dummy.run_dummy_orbit') as mock_dummy: - run_orbit(hf_row, config, dirs={}, interior_o=interior_o) + with patch('proteus.orbit.dummy.run_dummy_tides') as mock_dummy: + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) # The no-module branch sets Imk2 to exactly 0.0 and does NOT - # call run_dummy_orbit. + # call run_dummy_tides. assert hf_row['Imk2'] == pytest.approx(0.0, abs=1e-12) assert mock_dummy.call_count == 0 # axial_period was specified in hours; confirm conversion to s. @@ -453,6 +577,106 @@ def test_run_orbit_no_module_sets_imk2_to_zero(): assert hf_row['axial_period'] == pytest.approx(24.0 * secs_per_hour, rel=1e-12) +def test_init_orbit_invokes_obliqua_import_when_module_is_obliqua(): + """A non-None module that names obliqua must call ``import_obliqua`` + exactly once -- the counterpart to the existing lovepy dispatch test. + Also sets up Obliqua's own logging once here (not on every run_orbit + call, which no longer touches it at all -- see + test_run_orbit_obliqua_module_uses_degree_2_love_number_when_n_is_only_2), + passing through the configured verbosity. + """ + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import init_orbit + + handler = MagicMock() + handler.config.orbit.module = 'obliqua' + handler.config.interior_energetics.heat_tidal = True + handler.config.orbit.obliqua.verbosity = 2 + with ( + patch('proteus.orbit.obliqua.import_obliqua') as mock_import, + patch('proteus.orbit.obliqua.setup_logging') as mock_setup_logging, + # Discrimination: dispatch must not ALSO import lovepy for this module. + patch('proteus.orbit.lovepy.import_lovepy') as mock_lovepy_import, + ): + init_orbit(handler) + mock_import.assert_called_once_with(handler.directories) + assert mock_lovepy_import.call_count == 0 + mock_setup_logging.assert_called_once_with(handler.directories, 2) + + +def test_init_orbit_also_imports_obliqua_for_lovepy_ps1d(): + """orbit.module='lovepy' with planet_satellite_model in ('ps1d', + 'ps1d_evec') is a valid, supported combination (``orbit_requires_tides`` + in config/_config.py accepts either 'lovepy' or 'obliqua' for those + models). ps1d/ps1d_evec read the satellite's tidal response from + Obliqua's lookup table regardless of which module handles the + planet's own tides, so init_orbit must import BOTH lovepy (for the + planet) and Obliqua (for the satellite) in this combination, not + lovepy alone. + """ + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import init_orbit + + handler = MagicMock() + handler.config.orbit.module = 'lovepy' + handler.config.orbit.planet_satellite_model = 'ps1d' + handler.config.interior_energetics.heat_tidal = True + with ( + patch('proteus.orbit.lovepy.import_lovepy') as mock_lovepy_import, + patch('proteus.orbit.obliqua.import_obliqua') as mock_obliqua_import, + patch('proteus.orbit.obliqua.setup_logging') as mock_obliqua_setup_logging, + ): + init_orbit(handler) + assert mock_lovepy_import.call_count == 1 + mock_obliqua_import.assert_called_once_with(handler.directories) + mock_obliqua_setup_logging.assert_called_once_with( + handler.directories, handler.config.orbit.obliqua.verbosity + ) + + +# --------------------------------------------------------------------------- +# read_tides_data +# --------------------------------------------------------------------------- + + +def test_read_tides_data_returns_empty_list_for_no_times(): + """Limit-input edge case: an empty ``times`` list must short-circuit + to an empty result without dispatching on ``model`` at all.""" + from proteus.orbit.wrapper import read_tides_data + + with patch('proteus.orbit.obliqua.read_ncdfs') as mock_read: + result = read_tides_data('/tmp/out', 'obliqua', []) + assert result == [] + # Discrimination: the empty-times guard must fire BEFORE any + # model-specific dispatch, even for model='obliqua'. + assert mock_read.call_count == 0 + + +def test_read_tides_data_dispatches_to_read_ncdfs_for_obliqua(): + """model='obliqua' with non-empty times must call + ``obliqua.read_ncdfs`` and return its result unmodified.""" + + from proteus.orbit.wrapper import read_tides_data + + with patch('proteus.orbit.obliqua.read_ncdfs', return_value=['ds1', 'ds2']) as mock_read: + result = read_tides_data('/tmp/out', 'obliqua', [1000, 2000]) + mock_read.assert_called_once_with('/tmp/out', [1000, 2000]) + assert result == ['ds1', 'ds2'] + + +def test_read_tides_data_returns_empty_list_for_non_obliqua_model(): + """A non-'obliqua' model (e.g. no tidal-response module producing + per-time snapshots) returns an empty list rather than raising.""" + from proteus.orbit.wrapper import read_tides_data + + assert read_tides_data('/tmp/out', 'dummy', [1000, 2000]) == [] + # A different non-obliqua model name must fall into the same branch, + # not just the specific 'dummy' string. + assert read_tides_data('/tmp/out', 'lovepy', [1000, 2000]) == [] + + def test_run_orbit_warns_when_planet_inside_roche_limit(): """When separation < roche_limit, run_orbit must log a warning. @@ -462,7 +686,7 @@ def test_run_orbit_warns_when_planet_inside_roche_limit(): the inside-Roche warning fires but NOT the partial-perihelion one. """ import logging - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock from proteus.orbit.wrapper import run_orbit @@ -471,7 +695,7 @@ def test_run_orbit_warns_when_planet_inside_roche_limit(): config.orbit.evolve = False config.orbit.eccentricity = 0.0 # circular -> perihelion == separation config.orbit.semimajoraxis = 1.0e-3 # 0.001 AU - config.orbit.satellite = False + config.orbit.satellite = _make_satellite_config_stub() config.orbit.semimajoraxis_sat = 1.0e8 config.orbit.axial_period = None config.orbit.instellation_method = 'sep' @@ -486,6 +710,19 @@ def test_run_orbit_warns_when_planet_inside_roche_limit(): 'R_obs': 5.0e6, 'R_xuv': 5.0e6, 'semimajorax_sat': 1.0e8, + # Time <= 1 keeps run_orbit on its initial-setup branch, which + # none of these tests need to escape: the alternative (evolved) + # branch calls evolve_orbit_star/evolve_orbit_satellite, which + # are not mocked here. + 'Time': 0.0, + # plan_sat_am is only ever written by evolve_orbit_star / + # evolve_orbit_satellite (the evolved-timestep branch this + # test never reaches), yet run_orbit's satellite logging block + # reads it unconditionally. Pre-seeded so that unrelated + # (already-latent) gap doesn't fail this Roche-limit test. + 'plan_sat_am': 0.0, + # plan_star_am: see the comment in the other two tests above. + 'plan_star_am': 0.0, } interior_o = MagicMock() interior_o.dt = 1.0 @@ -494,10 +731,502 @@ def test_run_orbit_warns_when_planet_inside_roche_limit(): target_logger = 'fwl.proteus.orbit.wrapper' with patch('logging.Logger.warning') as mock_warn: logging.getLogger(target_logger).setLevel(logging.WARNING) - run_orbit(hf_row, config, dirs={}, interior_o=interior_o) + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) # At least one warning fired. Pin separation < roche_limit as the # invariant we are exercising; the assertion does not require a # specific message string (those are reformatted often), but the # geometry must support the warning's truth. assert hf_row['separation'] < hf_row['roche_limit'] assert mock_warn.call_count >= 1 + + +def test_run_orbit_warns_when_satellite_orbit_exceeds_hill_radius(): + """When a satellite is modelled (include_satellite=True) and its + semi-major axis exceeds the planet's Hill radius, run_orbit must warn: + an orbit that wide is not gravitationally bound to the planet against + the star's perturbation. Also pins that the satellite-specific + breakup/Roche offsets (params.stop.disint_sat.*) are read here, not + the planet's own params.stop.disint.* -- a copy-paste of the wrong + offset would still execute (both are floats) but silently ignore + whatever the user set for the satellite. + """ + import logging + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + + config = MagicMock() + config.orbit.module = None + config.orbit.evolve = False + config.orbit.eccentricity = 0.0 + config.orbit.semimajoraxis = 1.0 # AU, wide star-planet separation + config.orbit.satellite = _make_satellite_config_stub() + config.orbit.satellite.include_satellite = True + # The init branch (Time<=1) sets hf_row['semimajorax_sat'] FROM this + # config field (in R_earth units), overwriting any hf_row value passed + # in below -- 1e4 R_earth is far beyond any physically bound Hill + # radius around a Sun-like star at 1 AU (~1.5e9 m, ~0.01 AU). + config.orbit.satellite.semimajoraxis_sat = 1.0e4 + config.orbit.satellite.axial_period_sat = 24.0 # hours + config.orbit.axial_period = None + config.orbit.instellation_method = 'sep' + config.star.module = 'mors' + config.params.stop.disint.offset_spin = 0.0 + config.params.stop.disint.offset_roche = 0.0 + config.params.stop.disint_sat.offset_spin = 0.0 + config.params.stop.disint_sat.offset_roche = 0.0 + hf_row = { + 'M_star': M_sun, + 'M_planet': M_earth, + 'M_int': M_earth, + 'R_int': R_earth, + 'R_obs': R_earth, + 'R_xuv': R_earth, + 'Time': 0.0, # init branch + 'plan_sat_am': 0.0, + 'plan_star_am': 0.0, + } + interior_o = MagicMock() + interior_o.dt = 1.0 + interior_o.phi = np.zeros(3) + + target_logger = 'fwl.proteus.orbit.wrapper' + with patch('logging.Logger.warning') as mock_warn: + logging.getLogger(target_logger).setLevel(logging.WARNING) + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) + + assert hf_row['semimajorax_sat'] > hf_row['hill_radius'] + warned_messages = [call.args[0] for call in mock_warn.call_args_list] + assert any('beyond the Hill radius of its planet' in msg for msg in warned_messages) + + +# --------------------------------------------------------------------------- +# run_orbit: init branch (Time<=1), satellite bootstrap + Hansen-table setup +# --------------------------------------------------------------------------- + + +def _make_init_branch_config(*, planet_satellite_model, satellite): + config = MagicMock() + config.orbit.module = None + config.orbit.evolve = False + config.orbit.eccentricity = 0.0 + config.orbit.semimajoraxis = 1.0 + config.orbit.instellation_method = 'sep' + config.orbit.axial_period = None + config.orbit.planet_satellite_model = planet_satellite_model + config.orbit.satellite = satellite + config.star.module = 'mors' + config.params.stop.disint.offset_spin = 0.0 + config.params.stop.disint.offset_roche = 0.0 + return config + + +def _make_init_branch_hf_row(): + return { + 'M_star': M_sun, + 'M_planet': M_earth, + 'M_int': M_earth, + 'R_int': R_earth, + 'R_obs': R_earth, + 'R_xuv': R_earth, + 'Time': 0.0, + 'plan_sat_am': 0.0, + 'plan_star_am': 0.0, + } + + +def test_run_orbit_bootstraps_satellite_params_and_hansen_table_for_ps1d(): + """Time<=1 init branch, with a satellite configured and + ``planet_satellite_model='ps1d'``: run_orbit must (a) set every + independent satellite orbital parameter from config in SI units, + (b) put the satellite spin into 1:1 spin-orbit resonance when + ``axial_period_sat`` is unset, and (c) trigger the one-time + Hansen-coefficient table setup (patched here -- the real sweep is + a ~minute one-time cost, out of the unit tier's budget) and the + Love-number lookup extraction (patched -- reads an external file). + """ + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + + satellite = types.SimpleNamespace( + include_satellite=True, + mass_sat=0.0123, + radius_sat=0.273, + c_factor_sat=0.4, + semimajoraxis_sat=0.00257, + eccentricity_sat=0.05, + evection_angle=10.0, + axial_period_sat=None, + ) + config = _make_init_branch_config(planet_satellite_model='ps1d', satellite=satellite) + hf_row = _make_init_branch_hf_row() + interior_o = MagicMock() + interior_o.dt = 1.0 + interior_o.phi = np.zeros(3) + + with ( + patch('proteus.orbit.hansen.init_hansen_table') as mock_init_hansen, + patch('proteus.orbit.hansen.init_k_range_table') as mock_init_krange, + patch('proteus.orbit.obliqua.LN_from_lookup') as mock_ln_lookup, + ): + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) + + assert mock_init_krange.call_count == 1 + assert mock_init_hansen.call_count == 1 + assert mock_ln_lookup.call_count == 1 + + assert hf_row['M_sat'] == pytest.approx(0.0123 * M_earth, rel=1e-12) + assert hf_row['R_sat'] == pytest.approx(0.273 * R_earth, rel=1e-12) + assert hf_row['C_sat'] == pytest.approx( + 0.4 * hf_row['M_sat'] * hf_row['R_sat'] ** 2, rel=1e-12 + ) + # semimajoraxis_sat is interpreted in R_earth, not AU (matching + # radius_sat/R_sat's own convention on the same config section). + assert hf_row['semimajorax_sat'] == pytest.approx(0.00257 * R_earth, rel=1e-12) + assert hf_row['eccentricity_sat'] == pytest.approx(0.05, rel=1e-12) + assert hf_row['evection_angle'] == pytest.approx(np.deg2rad(10.0), rel=1e-12) + # 1:1 spin-orbit resonance: axial_period_sat == orbital_period_sat. + assert hf_row['axial_period_sat'] == pytest.approx(hf_row['orbital_period_sat'], rel=1e-12) + + +def test_run_orbit_converts_numeric_satellite_axial_period_from_hours(): + """When ``axial_period_sat`` is set to a float (not None), it is + interpreted as hours and converted to seconds -- the counterpart to + the None (1:1 resonance) branch covered above.""" + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + from proteus.utils.constants import secs_per_hour + + satellite = types.SimpleNamespace( + include_satellite=True, + mass_sat=0.0123, + radius_sat=0.273, + c_factor_sat=0.4, + semimajoraxis_sat=0.00257, + eccentricity_sat=0.05, + evection_angle=0.0, + axial_period_sat=48.0, # hours + ) + config = _make_init_branch_config(planet_satellite_model=None, satellite=satellite) + hf_row = _make_init_branch_hf_row() + interior_o = MagicMock() + interior_o.dt = 1.0 + interior_o.phi = np.zeros(3) + + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) + + assert hf_row['axial_period_sat'] == pytest.approx(48.0 * secs_per_hour, rel=1e-12) + # Discrimination: must NOT equal the 1:1 spin-orbit-resonance value + # (the other branch's result, covered by the sibling test above) -- + # confirms the numeric-hours branch actually ran, not the None branch. + assert hf_row['axial_period_sat'] != pytest.approx(hf_row['orbital_period_sat'], rel=1e-6) + + +# --------------------------------------------------------------------------- +# run_orbit: evolved branch (Time>1) -- model dispatch and instellation +# --------------------------------------------------------------------------- + + +def test_run_orbit_evolved_branch_dispatches_to_star_and_satellite_models(): + """Time>1: with both star_planet_model and planet_satellite_model + set, run_orbit must dispatch to evolve_orbit_star and + evolve_orbit_satellite exactly once each, instead of the init + branch's config-driven bootstrap. + """ + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + + config = MagicMock() + config.orbit.module = None + config.orbit.evolve = False + config.orbit.instellation_method = 'sep' + config.orbit.star_planet_model = 'sp0d' + config.orbit.planet_satellite_model = 'ps0d' + config.orbit.satellite.include_satellite = False + config.star.module = 'mors' + config.params.stop.disint.offset_spin = 0.0 + config.params.stop.disint.offset_roche = 0.0 + + hf_row = { + 'M_star': M_sun, + 'M_planet': M_earth, + 'M_int': M_earth, + 'R_int': R_earth, + 'R_obs': R_earth, + 'R_xuv': R_earth, + 'semimajorax': AU, + 'eccentricity': 0.05, + 'axial_period': 86400.0, + 'semimajorax_sat': 3.8e8, + 'M_sat': 7.342e22, + 'Time': 100.0, # > 1: evolved branch + 'plan_sat_am': 0.0, + 'plan_star_am': 0.0, + } + interior_o = MagicMock() + interior_o.dt = 1e6 + interior_o.phi = np.zeros(3) + + with ( + patch('proteus.orbit.orbit.evolve_orbit_star') as mock_star, + patch('proteus.orbit.satellite.evolve_orbit_satellite') as mock_sat, + ): + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) + + mock_star.assert_called_once() + mock_sat.assert_called_once() + + +def test_run_orbit_reraises_when_evolve_orbit_star_raises(): + """Time>1, star_planet_model set: a failure inside evolve_orbit_star + (e.g. Obliqua/lovepy erroring on a degenerate orbit) must not surface + as a bare, unattributed exception. run_orbit wraps it in a RuntimeError + naming the model and Time, and flags the run's status file, mirroring + the same contract as run_adaptive_orbit_substeps's C_planet-refresh + re-raise (tests/orbit/test_common.py).""" + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + + config = MagicMock() + config.orbit.module = None + config.orbit.evolve = False + config.orbit.instellation_method = 'sep' + config.orbit.star_planet_model = 'sp1d' + config.orbit.planet_satellite_model = None + config.orbit.satellite.include_satellite = False + config.star.module = 'mors' + config.params.stop.disint.offset_spin = 0.0 + config.params.stop.disint.offset_roche = 0.0 + + hf_row = { + 'M_star': M_sun, + 'M_planet': M_earth, + 'M_int': M_earth, + 'R_int': R_earth, + 'R_obs': R_earth, + 'R_xuv': R_earth, + 'semimajorax': AU, + 'eccentricity': 0.05, + 'axial_period': 86400.0, + 'Time': 100.0, # > 1: evolved branch + 'plan_sat_am': 0.0, + 'plan_star_am': 0.0, + } + interior_o = MagicMock() + interior_o.dt = 1e6 + interior_o.phi = np.zeros(3) + dirs = {'output': '/tmp/unused'} + original_err = RuntimeError('obliqua degree mismatch') + + with ( + patch('proteus.orbit.orbit.evolve_orbit_star', side_effect=original_err), + patch('proteus.orbit.wrapper.UpdateStatusfile') as mock_update_status, + pytest.raises(RuntimeError, match='Star-Planet orbital evolution failed') as excinfo, + ): + run_orbit(hf_row, config, dirs=dirs, tides_o=MagicMock(), interior_o=interior_o) + + assert excinfo.value.__cause__ is original_err + mock_update_status.assert_called_once_with(dirs, 26) + + +def test_run_orbit_reraises_when_evolve_orbit_satellite_raises(): + """Time>1, planet_satellite_model set: the same wrap-and-flag contract + as the star-planet path above, but for evolve_orbit_satellite. A + distinct message ("Planet-Satellite orbital evolution failed") proves + the two except-blocks are not aliasing one another's error text.""" + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + + config = MagicMock() + config.orbit.module = None + config.orbit.evolve = False + config.orbit.instellation_method = 'sep' + config.orbit.star_planet_model = None + config.orbit.planet_satellite_model = 'ps0d' + config.orbit.satellite.include_satellite = False + config.star.module = 'mors' + config.params.stop.disint.offset_spin = 0.0 + config.params.stop.disint.offset_roche = 0.0 + + hf_row = { + 'M_star': M_sun, + 'M_planet': M_earth, + 'M_int': M_earth, + 'R_int': R_earth, + 'R_obs': R_earth, + 'R_xuv': R_earth, + 'semimajorax': AU, + 'eccentricity': 0.05, + 'axial_period': 86400.0, + 'semimajorax_sat': 3.8e8, + 'M_sat': 7.342e22, + 'Time': 100.0, # > 1: evolved branch + 'plan_sat_am': 0.0, + 'plan_star_am': 0.0, + } + interior_o = MagicMock() + interior_o.dt = 1e6 + interior_o.phi = np.zeros(3) + dirs = {'output': '/tmp/unused'} + original_err = ValueError('non-finite eccentricity_sat') + + with ( + patch('proteus.orbit.satellite.evolve_orbit_satellite', side_effect=original_err), + patch('proteus.orbit.wrapper.UpdateStatusfile') as mock_update_status, + pytest.raises( + RuntimeError, match='Planet-Satellite orbital evolution failed' + ) as excinfo, + ): + run_orbit(hf_row, config, dirs=dirs, tides_o=MagicMock(), interior_o=interior_o) + + assert excinfo.value.__cause__ is original_err + mock_update_status.assert_called_once_with(dirs, 26) + + +def test_run_orbit_evolved_branch_inst_method_sets_sma_from_dummy_star_luminosity(): + """Time>1, no star_planet_model configured: falls back to setting + ``semimajorax`` from the instellation-flux target using the dummy + star's blackbody luminosity, mirroring the init branch's identical + (already-tested) block. + """ + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + + config = MagicMock() + config.orbit.module = None + config.orbit.evolve = False + config.orbit.star_planet_model = None + config.orbit.planet_satellite_model = None + config.orbit.satellite.include_satellite = False + config.orbit.instellation_method = 'inst' + config.orbit.instellationflux = 1.0 + config.star.module = 'dummy' + config.star.dummy.Teff = 5772.0 + config.params.stop.disint.offset_spin = 0.0 + config.params.stop.disint.offset_roche = 0.0 + + hf_row = { + 'M_star': M_sun, + 'M_planet': M_earth, + 'M_int': M_earth, + 'R_int': R_earth, + 'R_obs': R_earth, + 'R_xuv': R_earth, + 'semimajorax': AU, + 'eccentricity': 0.0, + 'axial_period': 86400.0, + 'semimajorax_sat': 3.8e8, + 'M_sat': 7.342e22, + 'Time': 100.0, + 'plan_sat_am': 0.0, + 'plan_star_am': 0.0, + } + interior_o = MagicMock() + interior_o.dt = 1e6 + interior_o.phi = np.zeros(3) + + with patch('proteus.star.dummy.get_star_radius', return_value=1.0) as mock_radius: + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) + + mock_radius.assert_called_once() + # Solar-like Teff and R_star=1 R_sun at S_0=1 S_earth must recover + # ~1 AU, not some arbitrary/unconverted value. + assert hf_row['semimajorax'] == pytest.approx(AU, rel=0.1) + + +# --------------------------------------------------------------------------- +# run_orbit: obliqua tidal-response module dispatch +# --------------------------------------------------------------------------- + + +def _make_obliqua_config(*, n): + config = MagicMock() + config.orbit.module = 'obliqua' + config.orbit.evolve = False + config.orbit.instellation_method = 'sep' + config.orbit.star_planet_model = None + config.orbit.planet_satellite_model = None + config.orbit.satellite.include_satellite = False + config.orbit.obliqua.verbosity = 'info' + config.orbit.obliqua.n = n + config.star.module = 'mors' + config.params.stop.disint.offset_spin = 0.0 + config.params.stop.disint.offset_roche = 0.0 + return config + + +def _make_obliqua_hf_row(): + return { + 'M_star': M_sun, + 'M_planet': M_earth, + 'M_int': M_earth, + 'R_int': R_earth, + 'R_obs': R_earth, + 'R_xuv': R_earth, + 'semimajorax': AU, + 'eccentricity': 0.0, + 'axial_period': 86400.0, + 'semimajorax_sat': 3.8e8, + 'M_sat': 7.342e22, + 'Time': 100.0, + 'plan_sat_am': 0.0, + 'plan_star_am': 0.0, + } + + +def test_run_orbit_obliqua_module_uses_degree_2_love_number_when_n_is_only_2(): + """model='obliqua' with n=[2] (degree-2 only) must populate Imk2 + from run_obliqua's returned value directly. Obliqua's logging setup + is a one-time call from init_orbit now (see + test_init_orbit_invokes_obliqua_import_when_module_is_obliqua), not + something run_orbit itself repeats every iteration.""" + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + + config = _make_obliqua_config(n=[2]) + hf_row = _make_obliqua_hf_row() + interior_o = MagicMock() + interior_o.dt = 1e6 + interior_o.phi = np.zeros(3) + interior_o.tides = np.zeros(3) + + with patch('proteus.orbit.obliqua.run_obliqua', return_value=0.0071) as mock_run_obliqua: + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) + + mock_run_obliqua.assert_called_once() + assert hf_row['Imk2'] == pytest.approx(0.0071, rel=1e-12) + + +def test_run_orbit_obliqua_module_zeroes_imk2_for_other_degrees(): + """model='obliqua' with n != [2] (e.g. degree-3 only) must set + Imk2 to exactly 0.0 -- the returned Love number belongs to a + different degree and must not be confused with Imk2.""" + from unittest.mock import MagicMock + + from proteus.orbit.wrapper import run_orbit + + config = _make_obliqua_config(n=[3]) + hf_row = _make_obliqua_hf_row() + interior_o = MagicMock() + interior_o.dt = 1e6 + interior_o.phi = np.zeros(3) + interior_o.tides = np.zeros(3) + + with ( + patch('proteus.orbit.obliqua.setup_logging'), + patch('proteus.orbit.obliqua.run_obliqua', return_value=0.0071) as mock_run_obliqua, + ): + run_orbit(hf_row, config, dirs={}, tides_o=MagicMock(), interior_o=interior_o) + + assert hf_row['Imk2'] == pytest.approx(0.0, abs=1e-12) + # Discrimination: run_obliqua still runs (its degree-3 result is just + # not the one written to Imk2) -- this isn't a "module skipped" + # no-op, but a deliberate discard of the wrong-degree value. + mock_run_obliqua.assert_called_once() diff --git a/tests/plot/test_cpl_orbit.py b/tests/plot/test_cpl_orbit.py index da5bfbcb3..804bfc2f1 100644 --- a/tests/plot/test_cpl_orbit.py +++ b/tests/plot/test_cpl_orbit.py @@ -18,7 +18,7 @@ import pytest import proteus.plot.cpl_orbit as orbit_mod -from proteus.utils.constants import AU, secs_per_hour +from proteus.utils.constants import AU, R_earth, secs_per_hour pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] @@ -35,13 +35,31 @@ def _make_hf_all(n: int = 5, t_start: float = 1e2, t_end: float = 1e8) -> pd.Dat 'Time': np.logspace(np.log10(t_start), np.log10(t_end), n), 'semimajorax': np.linspace(1.5e11, 1.6e11, n), 'eccentricity': np.linspace(0.01, 0.05, n), - 'semimajorax_sat': np.linspace(3.8e8, 4.0e8, n), + 'orbital_period': np.linspace(3e7, 3.2e7, n), 'axial_period': np.linspace(24 * 3600, 30 * 3600, n), + 'semimajorax_sat': np.linspace(3.8e8, 4.0e8, n), + 'eccentricity_sat': np.linspace(0.02, 0.06, n), + 'orbital_period_sat': np.linspace(2.3e6, 2.4e6, n), + 'axial_period_sat': np.linspace(2.3e6, 2.4e6, n), 'roche_limit': np.full(n, 1.0e10), + 'roche_limit_sat': np.full(n, 1.0e7), } ) +def _make_axs_3x2() -> np.ndarray: + """Build a (3, 2) object array of MagicMock axes matching plot_orbit's + real ``plt.subplots(3, 2, ...)`` grid, with ``twinx()`` wired on every + mock so the timescales-panel right axis is reachable.""" + axs = np.empty((3, 2), dtype=object) + for i in range(3): + for j in range(2): + ax = MagicMock() + ax.twinx.return_value = MagicMock() + axs[i, j] = ax + return axs + + def _install_mock_plt(monkeypatch): """Patch the module's ``plt`` binding with a MagicMock and return it.""" mock_plt = MagicMock() @@ -52,6 +70,35 @@ def _install_mock_plt(monkeypatch): return mock_plt +def _make_axs_4x1() -> np.ndarray: + """Build a (4,) object array of MagicMock axes matching plot_evection's + real ``plt.subplots(4, 1, ...)`` grid.""" + axs = np.empty(4, dtype=object) + for i in range(4): + axs[i] = MagicMock() + return axs + + +def _make_evection_hf_all(n: int = 6, t_start: float = 1e2, t_end: float = 6e4) -> pd.DataFrame: + """Build a minimal runtime helpfile DataFrame for ``plot_evection``. + + Semimajor axis, eccentricity and spin values are picked so + ``_solve_e_stationary`` (the real, un-mocked root-finder) has a + genuine solution for at least some rows -- this exercises the real + numerics rather than a stub, matching how the driver is actually used. + """ + return pd.DataFrame( + { + 'Time': np.logspace(np.log10(t_start), np.log10(t_end), n), + 'semimajorax_sat': np.linspace(7.0, 12.0, n) * 6.371e6, + 'eccentricity_sat': np.linspace(0.05, 0.4, n), + 'axial_period': np.linspace(2 * 3600, 5 * 3600, n), + 'evection_angle': np.linspace(0.0, 3.0, n), + 'plan_sat_am': np.linspace(3.5e34, 3.6e34, n), + } + ) + + # --------------------------------------------------------------------------- # plot_orbit # --------------------------------------------------------------------------- @@ -74,7 +121,7 @@ def test_plot_orbit_returns_early_when_time_below_t0(tmp_path, monkeypatch): } ) - result = orbit_mod.plot_orbit(hf_all, str(tmp_path), plot_format='png', t0=100.0) + result = orbit_mod.plot_orbit(hf_all, str(tmp_path), True, plot_format='png', t0=100.0) assert result is None # Discriminating check: the early-return path must not touch matplotlib. @@ -83,29 +130,29 @@ def test_plot_orbit_returns_early_when_time_below_t0(tmp_path, monkeypatch): def test_plot_orbit_draws_and_saves_with_sufficient_time(tmp_path, monkeypatch): - """When the helpfile contains times above t0, ``plot_orbit`` produces a - 2-row figure, applies a log x-scale to both axes, and saves the figure. + """When the helpfile contains times above t0, ``plot_orbit`` produces + the 3-row (semi-major axis, eccentricity, timescales) by 2-column + (planet, satellite) grid, applies a log x-scale to every panel via + ``axs.flat``, and saves the figure. """ mock_fig = MagicMock() - ax_t = MagicMock() - ax_b = MagicMock() - # twinx returns an independent axis for the right-side series - ax_t.twinx.return_value = MagicMock() - ax_b.twinx.return_value = MagicMock() + axs = _make_axs_3x2() mock_plt = _install_mock_plt(monkeypatch) - mock_plt.subplots.return_value = (mock_fig, [ax_t, ax_b]) + mock_plt.subplots.return_value = (mock_fig, axs) hf_all = _make_hf_all(n=6, t_start=1e2, t_end=1e8) - orbit_mod.plot_orbit(hf_all, str(tmp_path), plot_format='png', t0=100.0) + orbit_mod.plot_orbit(hf_all, str(tmp_path), True, plot_format='png', t0=100.0) # The figure was saved once with the expected target path. assert mock_fig.savefig.call_count == 1 saved_path = mock_fig.savefig.call_args[0][0] assert saved_path.endswith('plot_orbit.png') - # Both x-axes set log scale on the bottom plot (shared x). A regression - # that swaps the scale to linear would leave this call missing. - ax_b.set_xscale.assert_called_with('log') - ax_t.set_xscale.assert_called_with('log') + # Every panel in the grid gets a log x-scale (the shared-x loop over + # axs.flat). A regression that only touched a subset of panels, or + # swapped the scale to linear, would leave one of these missing. + for i in range(3): + for j in range(2): + axs[i, j].set_xscale.assert_called_with('log') def test_plot_orbit_passes_correct_units_to_axes(tmp_path, monkeypatch): @@ -115,28 +162,26 @@ def test_plot_orbit_passes_correct_units_to_axes(tmp_path, monkeypatch): forgotten unit conversion (raw SI would be ~1e11 not ~1). """ mock_fig = MagicMock() - ax_t = MagicMock() - ax_b = MagicMock() - ax_tr = MagicMock() - ax_br = MagicMock() - ax_t.twinx.return_value = ax_tr - ax_b.twinx.return_value = ax_br + axs = _make_axs_3x2() mock_plt = _install_mock_plt(monkeypatch) - mock_plt.subplots.return_value = (mock_fig, [ax_t, ax_b]) + mock_plt.subplots.return_value = (mock_fig, axs) hf_all = _make_hf_all(n=4, t_start=1e3, t_end=1e7) - orbit_mod.plot_orbit(hf_all, str(tmp_path), plot_format='pdf', t0=100.0) + orbit_mod.plot_orbit(hf_all, str(tmp_path), True, plot_format='pdf', t0=100.0) - # ax_t.plot is called with (time, semimajorax/AU); extract the second positional - # argument and verify the magnitude lies in plausible AU range, not raw metres. - y_planet = ax_t.plot.call_args[0][1] + # Panel [0, 0] (planet semi-major axis) is called with (time, + # semimajorax/AU); extract the second positional argument and verify + # the magnitude lies in plausible AU range, not raw metres. + y_planet = axs[0, 0].plot.call_args[0][1] assert np.amax(y_planet) == pytest.approx(1.6e11 / AU, rel=1e-9) # Scale guard: AU-scaled values are O(1) for an Earth-like orbit. A # forgotten /AU would land at O(1e11). assert 0.5 < np.amax(y_planet) < 5.0 - # ax_br.plot receives (time, axial_period/secs_per_hour). Verify hour scaling. - y_period = ax_br.plot.call_args[0][1] + # Panel [2, 0]'s twinx (planet axial-spin-period right axis) receives + # (time, axial_period/secs_per_hour). Verify hour scaling. + ax_spin = axs[2, 0].twinx.return_value + y_period = ax_spin.plot.call_args[0][1] assert np.amax(y_period) == pytest.approx((30 * 3600) / secs_per_hour, rel=1e-9) # Scale guard: 24-30 h spans an Earth-day. A forgotten /secs_per_hour # would land at ~1e5. @@ -146,27 +191,23 @@ def test_plot_orbit_passes_correct_units_to_axes(tmp_path, monkeypatch): def test_plot_orbit_yaxis_lower_bound_above_zero_when_min_eccentricity_positive( tmp_path, monkeypatch ): - """For the right-axis eccentricity panel, ``plot_orbit`` sets + """For the planet-eccentricity panel [1, 0], ``plot_orbit`` sets ``ymin = amin(e) / yext``. With strictly positive eccentricities the lower y-bound must therefore be strictly positive: a regression that flipped the divide to a multiply would push ymin above amin(e). """ mock_fig = MagicMock() - ax_t = MagicMock() - ax_b = MagicMock() - ax_tr = MagicMock() - ax_t.twinx.return_value = ax_tr - ax_b.twinx.return_value = MagicMock() + axs = _make_axs_3x2() mock_plt = _install_mock_plt(monkeypatch) - mock_plt.subplots.return_value = (mock_fig, [ax_t, ax_b]) + mock_plt.subplots.return_value = (mock_fig, axs) hf_all = _make_hf_all(n=5, t_start=1e3, t_end=1e6) # Force a strictly positive minimum eccentricity. hf_all['eccentricity'] = np.linspace(0.10, 0.20, 5) - orbit_mod.plot_orbit(hf_all, str(tmp_path), plot_format='png', t0=100.0) + orbit_mod.plot_orbit(hf_all, str(tmp_path), True, plot_format='png', t0=100.0) - ymin_called, _ymax_called = ax_tr.set_ylim.call_args[0] + ymin_called, _ymax_called = axs[1, 0].set_ylim.call_args[0] # ymin = 0.10 / 1.05 ~ 0.0952 with positive sign. assert ymin_called == pytest.approx(0.10 / 1.05, rel=1e-9) # Sign guard: a sign flip ( - amin / yext ) lands at ~-0.0952; the actual @@ -174,6 +215,35 @@ def test_plot_orbit_yaxis_lower_bound_above_zero_when_min_eccentricity_positive( assert ymin_called > 0 +def test_plot_orbit_notates_blank_satellite_panels_when_no_satellite_data( + tmp_path, monkeypatch +): + """When the helpfile has no ``semimajorax_sat`` column (no satellite + simulated), the right-hand column's 3 panels must be notated + ('No Satellite Data') rather than raising a KeyError trying to plot + columns that don't exist.""" + mock_fig = MagicMock() + axs = _make_axs_3x2() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + # Satellite columns ARE present (mirroring the real helpfile schema), + # but has_sat=False must still win. + hf_all = _make_hf_all(n=5, t_start=1e2, t_end=1e8) + orbit_mod.plot_orbit(hf_all, str(tmp_path), False, plot_format='png', t0=100.0) + + for row in range(3): + axs[row, 1].text.assert_called_once() + args, kwargs = axs[row, 1].text.call_args + assert args[2] == 'No Satellite Data' + # Discrimination: the left (planet) column must still be drawn + # normally, not also skipped. + assert axs[0, 0].plot.call_count == 1 + # Discrimination: the satellite columns present in hf_all must never + # have been touched. + assert axs[0, 1].plot.call_count == 0 + + # --------------------------------------------------------------------------- # plot_orbit_system # --------------------------------------------------------------------------- @@ -195,18 +265,19 @@ def test_plot_orbit_system_returns_early_when_below_t0(tmp_path, monkeypatch): } ) - result = orbit_mod.plot_orbit_system(hf_all, str(tmp_path), plot_format='png', t0=1e3) + result = orbit_mod.plot_orbit_system(hf_all, str(tmp_path), True, plot_format='png', t0=1e3) assert result is None # Same discrimination as the plot_orbit early-return: t0 guard must hold. assert not mock_plt.subplots.called -def test_plot_orbit_system_draws_planet_satellite_and_roche(tmp_path, monkeypatch): - """With times that span well past ``t0`` and a small set of orbital - snapshots, the system plot loops over every row and calls ax.plot for - each (planet orbit + satellite orbit), plus the Roche-limit dashed - line and two dummy legend entries. +def test_plot_orbit_system_draws_star_planet_view(tmp_path, monkeypatch): + """``is_satellite_system=False`` draws the planet orbiting the star: + one ellipse per row plus the Roche-limit dashed ring plus a single + "Planet orbit" dummy legend entry -- no satellite trace at all, since + ``star_planet_model``/``planet_satellite_model`` are mutually + exclusive and this view is for the former. """ mock_fig = MagicMock() mock_ax = MagicMock() @@ -220,25 +291,76 @@ def test_plot_orbit_system_draws_planet_satellite_and_roche(tmp_path, monkeypatc n = 4 hf_all = _make_hf_all(n=n, t_start=1e3, t_end=1e6) - orbit_mod.plot_orbit_system(hf_all, str(tmp_path), plot_format='png', t0=1e3) + orbit_mod.plot_orbit_system(hf_all, str(tmp_path), False, plot_format='png', t0=1e3) - # Per-row: planet ellipse plot + satellite plot = 2 ax.plot calls. - # Plus one Roche-limit dashed line and two dummy-label plot([], []) calls. - expected_plot_calls = 2 * n + 1 + 2 + # Per-row: one orbit-ellipse ax.plot call, plus one Roche-limit dashed + # line, plus one dummy-label plot([], []) call. + expected_plot_calls = n + 1 + 1 assert mock_ax.plot.call_count == expected_plot_calls - # Distinguishing guard: a regression that skipped the satellite ring would - # land at n+3 calls (~7), not 2n+3 (~11). - assert mock_ax.plot.call_count > n + 3 + + labels = [c.kwargs.get('label') for c in mock_ax.plot.call_args_list] + assert 'Planet orbit' in labels + assert 'Satellite orbit' not in labels + assert mock_ax.set_ylabel.call_args[0][0] == 'Distance [AU]' + mock_ax.scatter.assert_called_once_with( + 0, 0, color='orange', s=60, zorder=4, label='Star', marker='*' + ) mock_fig.savefig.assert_called_once() fpath = mock_fig.savefig.call_args[0][0] assert fpath.endswith('plot_orbit_system.png') -def test_plot_orbit_system_roche_radius_scaled_to_AU(tmp_path, monkeypatch): - """The Roche-limit dashed ring is plotted at ``roche_limit / AU``. The - x-component magnitude must therefore land at metres / AU, not at raw - metres. +def test_plot_orbit_system_draws_planet_satellite_view(tmp_path, monkeypatch): + """``is_satellite_system=True`` draws the satellite orbiting the + planet, in R_earth (not AU) -- the fix for the satellite's orbit + (hundreds of times smaller than a 1 AU star-planet separation) + rendering as an invisible speck when both were drawn to the same + AU-scaled panel. + """ + mock_fig = MagicMock() + mock_ax = MagicMock() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, mock_ax) + mock_plt.cm.ScalarMappable.return_value = MagicMock() + monkeypatch.setattr(orbit_mod, 'make_axes_locatable', lambda _ax: MagicMock()) + + n = 4 + hf_all = _make_hf_all(n=n, t_start=1e3, t_end=1e6) + orbit_mod.plot_orbit_system(hf_all, str(tmp_path), True, plot_format='png', t0=1e3) + + expected_plot_calls = n + 1 + 1 + assert mock_ax.plot.call_count == expected_plot_calls + + labels = [c.kwargs.get('label') for c in mock_ax.plot.call_args_list] + assert 'Satellite orbit' in labels + assert 'Planet orbit' not in labels + assert mock_ax.set_ylabel.call_args[0][0] == 'Distance [R_earth]' + mock_ax.scatter.assert_called_once_with( + 0, 0, color='tab:blue', s=60, zorder=4, label='Planet', marker='o' + ) + + # Discrimination: the orbit ellipse must be scaled by semimajorax_sat / + # R_earth, not by semimajorax / AU -- a regression that left the + # star-view columns/unit in place would plot a wildly different + # (AU-scaled, semimajorax-derived) ellipse size instead. + ellipse_calls = [ + c for c in mock_ax.plot.call_args_list if c.kwargs.get('ls') != 'dashed' and c.args + ] + x_vals = ellipse_calls[0][0][0] + t_arr = np.linspace(0, np.pi * 2, 80) + a = hf_all['semimajorax_sat'].iloc[0] / R_earth + e = hf_all['eccentricity_sat'].iloc[0] + expected_x = a * np.cos(t_arr) - a * e + assert np.amax(np.abs(x_vals)) == pytest.approx(np.amax(np.abs(expected_x)), rel=1e-9) + + mock_fig.savefig.assert_called_once() + + +def test_plot_orbit_system_roche_radius_scaled_to_AU_for_star_view(tmp_path, monkeypatch): + """The Roche-limit dashed ring in the star-planet view is plotted at + ``roche_limit / AU``. The x-component magnitude must therefore land + at metres / AU, not at raw metres. """ mock_fig = MagicMock() mock_ax = MagicMock() @@ -251,11 +373,8 @@ def test_plot_orbit_system_roche_radius_scaled_to_AU(tmp_path, monkeypatch): # Roche limit large enough to be the visible feature; AU-converted value ~6.68e-2 AU. hf_all['roche_limit'] = np.full(3, 1.0e10) - orbit_mod.plot_orbit_system(hf_all, str(tmp_path), plot_format='png', t0=1e3) + orbit_mod.plot_orbit_system(hf_all, str(tmp_path), False, plot_format='png', t0=1e3) - # The Roche-limit plot call is the (2 * nrows + 1)-th plot call when counted - # over the planet (n) + sat (n) loop. Easier: scan all plot calls for the - # one with the 'dashed' linestyle. dashed_calls = [c for c in mock_ax.plot.call_args_list if c.kwargs.get('ls') == 'dashed'] assert len(dashed_calls) == 1 x_vals = dashed_calls[0][0][0] @@ -266,6 +385,324 @@ def test_plot_orbit_system_roche_radius_scaled_to_AU(tmp_path, monkeypatch): assert 1e-4 < np.amax(x_vals) < 1.0 +def test_plot_orbit_system_roche_radius_scaled_to_R_earth_for_satellite_view( + tmp_path, monkeypatch +): + """The Roche-limit dashed ring in the planet-satellite view is + plotted at ``roche_limit_sat / R_earth``, not ``roche_limit / AU`` -- + the satellite view must read the satellite's own Roche limit and + natural length unit, not the star-planet ones. + """ + mock_fig = MagicMock() + mock_ax = MagicMock() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, mock_ax) + mock_plt.cm.ScalarMappable.return_value = MagicMock() + monkeypatch.setattr(orbit_mod, 'make_axes_locatable', lambda _ax: MagicMock()) + + hf_all = _make_hf_all(n=3, t_start=1e3, t_end=1e6) + hf_all['roche_limit_sat'] = np.full(3, 1.0e7) + + orbit_mod.plot_orbit_system(hf_all, str(tmp_path), True, plot_format='png', t0=1e3) + + dashed_calls = [c for c in mock_ax.plot.call_args_list if c.kwargs.get('ls') == 'dashed'] + assert len(dashed_calls) == 1 + x_vals = dashed_calls[0][0][0] + expected = 1.0e7 / R_earth + assert np.amax(x_vals) == pytest.approx(expected, rel=1e-9) + + +# --------------------------------------------------------------------------- +# plot_evection +# --------------------------------------------------------------------------- + + +def test_plot_evection_returns_early_when_time_below_t0(monkeypatch): + """Must skip without raising, and without touching matplotlib, when + the simulation has not yet advanced past ``t0`` years -- mirrors + ``plot_orbit``'s equivalent guard.""" + mock_plt = _install_mock_plt(monkeypatch) + hf_all = pd.DataFrame( + { + 'Time': np.array([1.0, 5.0, 20.0]), + 'semimajorax_sat': np.full(3, 8.0 * 6.371e6), + 'eccentricity_sat': np.full(3, 0.1), + 'axial_period': np.full(3, 3 * 3600.0), + 'evection_angle': np.full(3, 0.5), + 'plan_sat_am': np.full(3, 3.5e34), + } + ) + + result = orbit_mod.plot_evection(hf_all, '/tmp/out', plot_format='png', t0=100.0) + + assert result is None + assert not mock_plt.subplots.called + + +def test_plot_evection_draws_a_four_panel_figure_and_saves(monkeypatch): + """With sufficient time coverage, must build a 4-row shared-x figure + and save it under the expected filename.""" + mock_fig = MagicMock() + axs = _make_axs_4x1() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + hf_all = _make_evection_hf_all() + orbit_mod.plot_evection(hf_all, '/tmp/out', plot_format='png', t0=100.0) + + mock_plt.subplots.assert_called_once() + args, kwargs = mock_plt.subplots.call_args + assert args[:2] == (4, 1) + assert mock_fig.savefig.call_count == 1 + saved_path = mock_fig.savefig.call_args[0][0] + assert saved_path.endswith('plot_evection.png') + + +def test_plot_evection_uses_fine_phi_trace_when_provided(monkeypatch): + """Panel (c) must plot the supplied fine (t, phi) trace, mod 2*pi, + in preference to the coarse ``evection_angle`` helpfile column -- + the fine trace is the real per-substep resonance-angle data, the + coarse column is a potentially-aliased fallback (see the module's + own log message for the un-mocked branch). + """ + mock_fig = MagicMock() + axs = _make_axs_4x1() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + hf_all = _make_evection_hf_all() + fine_t = np.array([1e2, 2e2, 3e2]) + fine_phi = np.array([0.1, 7.0, -1.0]) # includes values outside [0, 2*pi) + + orbit_mod.plot_evection( + hf_all, '/tmp/out', plot_format='png', t0=100.0, fine_phi=fine_phi, fine_t=fine_t + ) + + panel_c_call = axs[2].plot.call_args + plotted_t, plotted_y = panel_c_call[0] + np.testing.assert_allclose(plotted_t, fine_t) + np.testing.assert_allclose(plotted_y, np.mod(fine_phi, 2 * np.pi)) + # Discrimination: must NOT match the coarse evection_angle column + # (the fallback this branch is supposed to override). + coarse = np.mod(hf_all['evection_angle'].to_numpy(), 2 * np.pi) + assert not np.allclose(plotted_y, coarse[: len(plotted_y)]) + + +def test_plot_evection_falls_back_to_coarse_evection_angle_without_fine_trace(monkeypatch): + """Without a fine trace, panel (c) must fall back to the coarse + ``evection_angle`` helpfile column, plotted against the full + ``Time`` array (not some other subset).""" + mock_fig = MagicMock() + axs = _make_axs_4x1() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + hf_all = _make_evection_hf_all() + orbit_mod.plot_evection(hf_all, '/tmp/out', plot_format='png', t0=100.0) + + plotted_t, plotted_y = axs[2].plot.call_args[0] + np.testing.assert_allclose(plotted_t, hf_all['Time'].to_numpy()) + assert len(plotted_y) == len(hf_all) + + +def test_plot_evection_wraps_coarse_angle_when_it_has_circulated(monkeypatch): + """When the coarse ``evection_angle`` column spans more than a full + turn (genuine circulation, not bounded libration), panel (c) must + wrap it into ``[0, 2*pi)`` -- otherwise the raw, unwrapped angle + would blow past the panel's fixed ``[-0.1, 2*pi+0.1]`` y-limits.""" + mock_fig = MagicMock() + axs = _make_axs_4x1() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + hf_all = _make_evection_hf_all() + # ptp = 20.0 > 2*pi: genuine circulation, not pure libration. + hf_all['evection_angle'] = np.linspace(0.0, 20.0, len(hf_all)) + orbit_mod.plot_evection(hf_all, '/tmp/out', plot_format='png', t0=100.0) + + _plotted_t, plotted_y = axs[2].plot.call_args[0] + assert np.all(plotted_y >= 0.0) and np.all(plotted_y < 2 * np.pi) + # Discrimination: the raw (unwrapped) column reaches 20.0, well + # outside [0, 2*pi) -- confirms wrapping actually happened, not + # that the input already happened to be in range. + assert np.amax(hf_all['evection_angle'].to_numpy()) > 2 * np.pi + + +@pytest.mark.parametrize('xscale', ['log', 'linear']) +def test_plot_evection_xscale_applies_to_every_panel(monkeypatch, xscale): + """Both the log and linear x-scale branches must apply their scale + (and their own distinct axis-limit convention) to every panel via + ``axs.flat``, not just a subset.""" + mock_fig = MagicMock() + axs = _make_axs_4x1() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + hf_all = _make_evection_hf_all() + orbit_mod.plot_evection(hf_all, '/tmp/out', plot_format='png', t0=100.0, xscale=xscale) + + for ax in axs: + ax.set_xscale.assert_called_with(xscale) + # Discrimination: the two branches use distinct xlim conventions + # (log: [t0, t_max]; linear: [0.0, t_max]) -- confirms the right + # branch actually ran, not just that SOME xscale string was passed. + _, xlim_kwargs = axs[0].set_xlim.call_args + assert xlim_kwargs['left'] == (100.0 if xscale == 'log' else 0.0) + + +def test_plot_evection_filter_toggle_t_draws_vlines_on_all_time_panels(monkeypatch): + """When ``filter_toggle_t`` is given, a vertical marker line must be + drawn on panels (a), (b), (c) and (d) -- confirms the toggle + annotation is wired to every time-series panel, not just the one + it was first added to.""" + mock_fig = MagicMock() + axs = _make_axs_4x1() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + hf_all = _make_evection_hf_all() + orbit_mod.plot_evection( + hf_all, '/tmp/out', plot_format='png', t0=100.0, filter_toggle_t=2.5e4 + ) + + for ax in axs: + assert ax.axvline.called + # Discrimination: panel (c) additionally gets a labeled legend entry + # for the toggle marker (the other three panels get the bare vline + # only) -- confirms the annotation isn't a blanket, undifferentiated + # call across all four axes. + axs[2].legend.assert_called_with(loc='upper right', fontsize=9, framealpha=0.9) + + +# --------------------------------------------------------------------------- +# plot_lovenumber +# --------------------------------------------------------------------------- + + +def _make_lovenumber_ds(real_vals, imag_vals, *, n=2, m=0, k=1, sigma=0.5): + """A single-mode (n, m, k) tidal-response snapshot, matching the + dict-of-arrays interface ``plot_Lovenumber`` reads (``ds['n'][:]``, + ``ds['knms_total']`` as a (2, n_modes) real/imag pair).""" + return { + 'n': np.array([n]), + 'm': np.array([m]), + 'k': np.array([k]), + 'sigma_range': np.array([sigma]), + 'knms_total': np.array([[real_vals], [imag_vals]]), + } + + +def test_plot_lovenumber_returns_early_for_no_times(monkeypatch): + """Empty or ``None`` times must short-circuit without touching + matplotlib.""" + mock_plt = _install_mock_plt(monkeypatch) + assert orbit_mod.plot_lovenumber('/tmp/out', [], []) is None + assert orbit_mod.plot_lovenumber('/tmp/out', None, []) is None + assert not mock_plt.subplots.called + + +def test_plot_lovenumber_returns_early_when_max_time_below_threshold(monkeypatch): + """Times all below the 2 yr minimum-data threshold must also + short-circuit without touching matplotlib.""" + mock_plt = _install_mock_plt(monkeypatch) + data = [_make_lovenumber_ds(1e-2, 2e-3)] + result = orbit_mod.plot_lovenumber('/tmp/out', [1.0], data) + assert result is None + assert not mock_plt.subplots.called + + +def test_plot_lovenumber_returns_early_when_no_nonzero_love_numbers(monkeypatch, caplog): + """If every recorded Love number is exactly zero (real and imaginary), + there is nothing to plot -- must warn and return rather than + building a figure with degenerate (all -inf) colour bounds.""" + import logging + + mock_plt = _install_mock_plt(monkeypatch) + data = [_make_lovenumber_ds(0.0, 0.0), _make_lovenumber_ds(0.0, 0.0)] + with caplog.at_level(logging.WARNING, logger='fwl.proteus.plot.cpl_orbit'): + result = orbit_mod.plot_lovenumber('/tmp/out', [1000, 2000], data) + assert result is None + assert not mock_plt.subplots.called + assert any('No valid non-zero Love numbers' in rec.message for rec in caplog.records) + + +def test_plot_lovenumber_draws_and_saves_with_valid_data(monkeypatch): + """With genuine non-zero Love-number data across two snapshots, + must build the 2-panel (real, imaginary) scatter figure and save + it under the expected filename.""" + mock_fig = MagicMock() + axs = np.empty(2, dtype=object) + axs[0] = MagicMock() + axs[1] = MagicMock() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + mock_plt.get_cmap.return_value = MagicMock() + + data = [_make_lovenumber_ds(1e-2, 2e-3), _make_lovenumber_ds(1.5e-2, 3e-3)] + orbit_mod.plot_lovenumber('/tmp/out', [1000, 2000], data, plot_format='png') + + assert axs[0].scatter.call_count == 1 + assert axs[1].scatter.call_count == 1 + assert mock_fig.savefig.call_count == 1 + saved_path = mock_fig.savefig.call_args[0][0] + assert saved_path.endswith('plot_lovenumber.png') + + +def test_plot_lovenumber_flags_seismic_resonance_points(monkeypatch): + """A Love number with Re(k) > 1.5 or Im(k) > 1 signals a normal-mode + (seismic) resonance response rather than a physically plausible value; + the plot must ring that point on both panels in addition to the + ordinary colour-coded scatter, and add exactly one legend entry + explaining the marker.""" + mock_fig = MagicMock() + axs = np.empty(2, dtype=object) + axs[0] = MagicMock() + axs[1] = MagicMock() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + # Same mode across two snapshots: the first stays within both + # thresholds, the second breaches only the real-part threshold + # (2.0 > 1.5) while its imaginary part (1e-3) stays well inside its + # own threshold -- so the two conditions must be OR-ed, not AND-ed. + data = [ + _make_lovenumber_ds(1e-2, 2e-3, n=2, m=0, k=1), + _make_lovenumber_ds(2.0, 1e-3, n=2, m=0, k=1), + ] + orbit_mod.plot_lovenumber('/tmp/out', [1000, 2000], data, plot_format='png') + + # One colour-coded scatter plus one ring-marker scatter per panel. + assert axs[0].scatter.call_count == 2 + assert axs[1].scatter.call_count == 2 + ring_call = axs[0].scatter.call_args_list[-1] + assert ring_call.kwargs['edgecolors'] == 'red' + assert ring_call.kwargs['facecolors'] == 'none' + # Only the breaching sample (index 1) should be ringed, not both. + assert len(ring_call.args[0]) == 1 + mock_fig.legend.assert_called_once() + + +def test_plot_lovenumber_does_not_flag_values_within_bounds(monkeypatch): + """Values that stay strictly inside both seismic-resonance thresholds must not + trigger the extra ring-marker scatter call, so the indicator does not + fire on ordinary, well-behaved Love numbers near the boundary.""" + mock_fig = MagicMock() + axs = np.empty(2, dtype=object) + axs[0] = MagicMock() + axs[1] = MagicMock() + mock_plt = _install_mock_plt(monkeypatch) + mock_plt.subplots.return_value = (mock_fig, axs) + + # Just under each threshold (1.4 < 1.5, 0.9 < 1) -- a discriminating + # near-boundary case, not a value trivially far from either cutoff. + data = [_make_lovenumber_ds(1.0, 0.5), _make_lovenumber_ds(1.4, 0.9)] + orbit_mod.plot_lovenumber('/tmp/out', [1000, 2000], data, plot_format='png') + + assert axs[0].scatter.call_count == 1 + assert axs[1].scatter.call_count == 1 + mock_fig.legend.assert_called_once() + + # --------------------------------------------------------------------------- # plot_orbit_entry # --------------------------------------------------------------------------- @@ -274,17 +711,26 @@ def test_plot_orbit_system_roche_radius_scaled_to_AU(tmp_path, monkeypatch): def test_plot_orbit_entry_reads_helpfile_and_calls_both_plots(monkeypatch, tmp_path): """The entry wrapper reads ``runtime_helpfile.csv`` from the run output directory and dispatches to both ``plot_orbit`` and - ``plot_orbit_system`` with the configured plot format. + ``plot_orbit_system`` with the configured plot format. ``plot_orbit`` + receives ``config.orbit.satellite.include_satellite`` (whether + satellite DATA exists to plot as a column); ``plot_orbit_system`` + receives ``config.orbit.planet_satellite_model is not None`` (which + single system view -- star-planet or planet-satellite -- is the one + actually evolving). These are deliberately different flags. """ fake_hf = _make_hf_all(n=4, t_start=1e3, t_end=1e6) captured_calls = [] - def fake_plot_orbit(hf_all, output_dir, plot_format='pdf', t0=100.0): - captured_calls.append(('plot_orbit', hf_all, output_dir, plot_format)) + def fake_plot_orbit(hf_all, output_dir, has_sat, plot_format='pdf', t0=100.0): + captured_calls.append(('plot_orbit', hf_all, output_dir, has_sat, plot_format)) - def fake_plot_orbit_system(hf_all, output_dir, plot_format='pdf', t0=1e3): - captured_calls.append(('plot_orbit_system', hf_all, output_dir, plot_format)) + def fake_plot_orbit_system( + hf_all, output_dir, is_satellite_system, plot_format='pdf', t0=1e3 + ): + captured_calls.append( + ('plot_orbit_system', hf_all, output_dir, is_satellite_system, plot_format) + ) monkeypatch.setattr(orbit_mod.pd, 'read_csv', lambda *a, **kw: fake_hf) monkeypatch.setattr(orbit_mod, 'plot_orbit', fake_plot_orbit) @@ -293,6 +739,8 @@ def fake_plot_orbit_system(hf_all, output_dir, plot_format='pdf', t0=1e3): handler = MagicMock() handler.directories = {'output': str(tmp_path)} handler.config.params.out.plot_fmt = 'png' + handler.config.orbit.satellite.include_satellite = True + handler.config.orbit.planet_satellite_model = 'ps1d' orbit_mod.plot_orbit_entry(handler) @@ -301,4 +749,145 @@ def fake_plot_orbit_system(hf_all, output_dir, plot_format='pdf', t0=1e3): assert names == ['plot_orbit', 'plot_orbit_system'] # Both received the configured format. A regression hardcoding 'pdf' # would fail this check. - assert all(c[3] == 'png' for c in captured_calls) + assert all(c[4] == 'png' for c in captured_calls) + # Both flags happen to be True here, but via their own distinct source. + assert all(c[3] is True for c in captured_calls) + + +def test_plot_orbit_entry_dispatches_to_plot_evection_for_ps1d_evec(monkeypatch, tmp_path): + """When ``planet_satellite_model == 'ps1d_evec'``, the entry wrapper + must also call ``plot_evection`` -- with ``fine_t``/``fine_phi`` left + ``None`` when no fine-evection CSV exists yet for this run (an early + call, before the resonance band has produced any fine samples).""" + fake_hf = _make_hf_all(n=4, t_start=1e3, t_end=1e6) + captured = {} + + monkeypatch.setattr(orbit_mod.pd, 'read_csv', lambda *a, **kw: fake_hf) + monkeypatch.setattr(orbit_mod, 'plot_orbit', lambda *a, **kw: None) + monkeypatch.setattr(orbit_mod, 'plot_orbit_system', lambda *a, **kw: None) + monkeypatch.setattr( + orbit_mod, + 'plot_evection', + lambda *a, **kw: captured.update(kw), + ) + + handler = MagicMock() + handler.directories = {'output': str(tmp_path), 'output/data': str(tmp_path / 'data')} + handler.config.params.out.plot_fmt = 'png' + handler.config.orbit.planet_satellite_model = 'ps1d_evec' + handler.config.orbit.module = 'dummy' + + orbit_mod.plot_orbit_entry(handler) + + assert captured['fine_t'] is None + assert captured['fine_phi'] is None + assert captured['t0'] == pytest.approx(1e1) + assert captured['xscale'] == 'linear' + + +def test_plot_orbit_entry_loads_fine_evection_data_when_present(monkeypatch, tmp_path): + """When ``fine_evection_data.csv`` already exists for this run, the + entry wrapper must load it and pass the real (t, phi) trace through + to ``plot_evection`` as ``fine_t``/``fine_phi``, not leave them + ``None``.""" + fake_hf = _make_hf_all(n=4, t_start=1e3, t_end=1e6) + captured = {} + + data_dir = tmp_path / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + fine_path = data_dir / 'fine_evection_data.csv' + t_vals = np.array([1.0e2, 2.0e2, 3.0e2]) + phi_vals = np.array([0.1, 0.5, 1.0]) + with open(fine_path, 'w') as f: + f.write('t_abs_yr,phi\n') + for t, phi in zip(t_vals, phi_vals): + f.write(f'{t},{phi}\n') + + monkeypatch.setattr(orbit_mod.pd, 'read_csv', lambda *a, **kw: fake_hf) + monkeypatch.setattr(orbit_mod, 'plot_orbit', lambda *a, **kw: None) + monkeypatch.setattr(orbit_mod, 'plot_orbit_system', lambda *a, **kw: None) + monkeypatch.setattr(orbit_mod, 'plot_evection', lambda *a, **kw: captured.update(kw)) + + handler = MagicMock() + handler.directories = {'output': str(tmp_path), 'output/data': str(data_dir)} + handler.config.params.out.plot_fmt = 'png' + handler.config.orbit.planet_satellite_model = 'ps1d_evec' + handler.config.orbit.module = 'dummy' + + orbit_mod.plot_orbit_entry(handler) + + np.testing.assert_allclose(captured['fine_t'], t_vals) + np.testing.assert_allclose(captured['fine_phi'], phi_vals) + + +def test_plot_orbit_entry_warns_and_continues_when_fine_evection_data_is_malformed( + monkeypatch, tmp_path, caplog +): + """A present but unparseable ``fine_evection_data.csv`` must log a + warning and fall back to ``fine_t=fine_phi=None``, not crash the + whole plotting entry point over one malformed diagnostic file.""" + import logging + + fake_hf = _make_hf_all(n=4, t_start=1e3, t_end=1e6) + captured = {} + + data_dir = tmp_path / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + fine_path = data_dir / 'fine_evection_data.csv' + with open(fine_path, 'w') as f: + f.write('not,valid,columns\nfor,this,loader\nextra\n') + + monkeypatch.setattr(orbit_mod.pd, 'read_csv', lambda *a, **kw: fake_hf) + monkeypatch.setattr(orbit_mod, 'plot_orbit', lambda *a, **kw: None) + monkeypatch.setattr(orbit_mod, 'plot_orbit_system', lambda *a, **kw: None) + monkeypatch.setattr(orbit_mod, 'plot_evection', lambda *a, **kw: captured.update(kw)) + + handler = MagicMock() + handler.directories = {'output': str(tmp_path), 'output/data': str(data_dir)} + handler.config.params.out.plot_fmt = 'png' + handler.config.orbit.planet_satellite_model = 'ps1d_evec' + handler.config.orbit.module = 'dummy' + + with caplog.at_level(logging.WARNING, logger='fwl.proteus.plot.cpl_orbit'): + orbit_mod.plot_orbit_entry(handler) + + assert captured['fine_t'] is None + assert captured['fine_phi'] is None + assert any('Failed to load fine evection data' in rec.message for rec in caplog.records) + + +def test_plot_orbit_entry_dispatches_to_plot_lovenumber_for_obliqua(monkeypatch, tmp_path): + """When ``config.orbit.module == 'obliqua'``, the entry wrapper must + sample the available snapshot times, load their tidal data via + ``read_tides_data``, and dispatch to ``plot_lovenumber`` with that + data threaded through.""" + fake_hf = _make_hf_all(n=4, t_start=1e3, t_end=1e6) + captured = {} + + monkeypatch.setattr(orbit_mod.pd, 'read_csv', lambda *a, **kw: fake_hf) + monkeypatch.setattr(orbit_mod, 'plot_orbit', lambda *a, **kw: None) + monkeypatch.setattr(orbit_mod, 'plot_orbit_system', lambda *a, **kw: None) + monkeypatch.setattr(orbit_mod, 'sample_output', lambda *a, **kw: ([1000, 2000], None)) + monkeypatch.setattr( + orbit_mod, 'read_tides_data', lambda *a, **kw: captured.setdefault('data', a) or [] + ) + monkeypatch.setattr( + orbit_mod, + 'plot_lovenumber', + lambda *a, **kw: captured.update(kw), + ) + + handler = MagicMock() + handler.directories = {'output': str(tmp_path), 'output/data': str(tmp_path / 'data')} + handler.config.params.out.plot_fmt = 'png' + handler.config.orbit.planet_satellite_model = None + handler.config.orbit.module = 'obliqua' + + orbit_mod.plot_orbit_entry(handler) + + assert captured['times'] == [1000, 2000] + assert captured['plot_format'] == 'png' + # read_tides_data was called with ('obliqua', [1000, 2000]) as the + # model/times pair (output_dir is the first positional argument). + assert captured['data'][1] == 'obliqua' + assert captured['data'][2] == [1000, 2000] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index bfe32cb12..4249f7056 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -22,6 +22,8 @@ from proteus.doctor import ( _SUPPORT_EMAIL, FAIL, + GIT_MODULES, + OPTIONAL_GIT_MODULES, PASS, PYTHON_PACKAGES, WARN, @@ -80,6 +82,24 @@ def test_python_packages_excludes_optional_backends(): ) +@pytest.mark.unit +def test_git_modules_excludes_optional_obliqua(): + """`proteus doctor` must not check Obliqua as a mandatory git module. + + Obliqua is an optional tidal-heating backend (orbit.module defaults to + 'none'); listing it in GIT_MODULES would make doctor report a missing + checkout for every user who has never touched tidal heating. It belongs + in OPTIONAL_GIT_MODULES instead, which check_git_module skips entirely + when the checkout is absent (see check_git_module's `required` param). + """ + assert 'Obliqua' not in GIT_MODULES + assert 'Obliqua' in OPTIONAL_GIT_MODULES + # Discrimination: AGNI/SOCRATES must remain mandatory, not accidentally + # migrated to the optional list alongside Obliqua. + assert {'AGNI', 'SOCRATES'} <= set(GIT_MODULES) + assert {'AGNI', 'SOCRATES'}.isdisjoint(OPTIONAL_GIT_MODULES) + + @pytest.mark.unit def test_python_packages_covers_every_pinned_fwl_dependency(): """Every FWL package pinned in pyproject must be one doctor checks. @@ -865,6 +885,55 @@ def test_socrates_on_pin_passes_with_no_fix(self, tmp_path, monkeypatch): # the on-pin path must not. assert 'differs' not in r.message + def test_obliqua_on_pin_reports_resolved_version(self, tmp_path): + """An on-pin Obliqua checkout reports its real version, not a placeholder. + + check_git_module dispatches to a per-module version getter; Obliqua's + getter (_get_obliqua_version) must actually be wired into that dispatch, + not silently fall through to the generic '?' placeholder used for + modules with no getter at all. + """ + pins = {'obliqua': {'ref': 'a' * 40}} + with ( + patch('proteus.doctor._module_pins', return_value=pins), + patch('proteus.doctor._git_head', return_value='a' * 40), + patch('proteus.doctor._get_obliqua_version', return_value='0.1.0'), + ): + r = check_git_module('Obliqua', {'obliqua': str(tmp_path)}, required=False) + assert r.status == PASS + assert '0.1.0' in r.message + # Discrimination: the placeholder used when a module has no version + # getter wired up must not appear once the getter is actually called. + assert r.message != '?' + + def test_unrecognized_module_name_falls_back_to_placeholder_version(self, tmp_path): + """A module name with no dedicated version getter degrades to '?'. + + The name dispatch (AGNI/SOCRATES/Obliqua) is exhaustive over today's + git-pinned modules, but the trailing ``else`` exists so a future + module added to GIT_MODULES/OPTIONAL_GIT_MODULES without a matching + version-getter branch fails soft with a placeholder instead of + raising or reporting a fabricated version. + """ + pins = {'futuremodule': {'ref': 'a' * 40}} + with ( + patch('proteus.doctor._module_pins', return_value=pins), + patch('proteus.doctor._git_head', return_value='a' * 40), + ): + r = check_git_module('FutureModule', {'futuremodule': str(tmp_path)}) + assert r.status == PASS + assert r.message == '? (aaaaaaaa)' + # Discrimination: Obliqua's dedicated getter must still resolve a + # real-looking version rather than also degrading to the fallback, + # proving the placeholder is specific to unrecognized names. + with ( + patch('proteus.doctor._module_pins', return_value={'obliqua': {'ref': 'a' * 40}}), + patch('proteus.doctor._git_head', return_value='a' * 40), + patch('proteus.doctor._get_obliqua_version', return_value='0.1.0'), + ): + obliqua_r = check_git_module('Obliqua', {'obliqua': str(tmp_path)}, required=False) + assert obliqua_r.message != '?' + def test_socrates_version_unreadable_still_classifies(self, tmp_path, monkeypatch): """An unreadable SOCRATES version degrades to '?' instead of crashing. @@ -893,6 +962,132 @@ def test_socrates_version_unreadable_still_classifies(self, tmp_path, monkeypatc assert 'get_socrates.sh' in r.fix_cmd +class TestCheckGitModuleOptional: + """check_git_module(required=False): Obliqua's not-required contract.""" + + def test_missing_optional_module_is_silently_skipped(self): + """``required=False`` with no checkout on disk returns None, not a + FAIL CheckResult -- the whole point of the flag, so doctor does not + report Obliqua as broken for a user who has never installed it. + + Discrimination: the SAME missing checkout, with ``required=True``, + must still return a real FAIL result -- proving the None above + comes from the flag, not from some other reason the function + happens to return nothing (a bug that broke the check entirely + would make both calls return None). + """ + with patch('proteus.doctor._module_pins', return_value={}): + optional_result = check_git_module('Obliqua', {}, required=False) + required_result = check_git_module('Obliqua', {}, required=True) + assert optional_result is None + assert required_result is not None + assert required_result.status == FAIL + + def test_missing_required_module_still_fails(self): + """Default (``required=True``) behaviour is unchanged: a missing + checkout is still a FAIL, not silently skipped -- the new + parameter must not weaken AGNI/SOCRATES's existing contract. + """ + with patch('proteus.doctor._module_pins', return_value={}): + r = check_git_module('AGNI', {}, required=True) + assert r is not None + assert r.status == FAIL + assert r.message == 'not installed' + + def test_present_optional_module_on_pin_still_reports_pass(self, tmp_path): + """Once actually installed, an optional module IS checked and + pin-compared normally (PASS on a matching HEAD) -- ``required`` + only controls the missing-checkout case, not whether an existing + checkout gets verified. + """ + pins = {'obliqua': {'ref': 'a' * 40}} + with ( + patch('proteus.doctor._module_pins', return_value=pins), + patch('proteus.doctor._git_head', return_value='a' * 40), + ): + r = check_git_module('Obliqua', {'obliqua': str(tmp_path)}, required=False) + assert r is not None + assert r.status == PASS + assert r.fix_cmd is None + + def test_present_optional_module_off_pin_still_warns(self, tmp_path): + """An installed-but-off-pin optional module still gets the normal + WARN + refresh suggestion, not a silent pass.""" + pins = {'obliqua': {'ref': 'a' * 40}} + with ( + patch('proteus.doctor._module_pins', return_value=pins), + patch('proteus.doctor._git_head', return_value='b' * 40), + ): + r = check_git_module('Obliqua', {'obliqua': str(tmp_path)}, required=False) + assert r is not None + assert r.status == WARN + assert 'get_obliqua.sh' in r.fix_cmd + + def test_run_all_checks_omits_obliqua_when_not_installed(self, tmp_path): + """End to end: with no Obliqua checkout, run_all_checks's result + list must contain no 'Obliqua' entry at all (not a FAIL/WARN one + either) -- confirms the skip actually reaches the top-level report, + not just the unit-level check_git_module call. + """ + with ( + patch('proteus.doctor._dependency_specs', return_value={}), + patch('proteus.doctor._module_pins', return_value={}), + patch( + 'proteus.doctor.get_proteus_directories', + return_value={'proteus': str(tmp_path)}, + ), + ): + results = run_all_checks() + assert not any(r.name == 'Obliqua' for r in results) + # Discrimination: AGNI (mandatory, also missing here) must still be + # reported as a real FAIL -- proving the loop that skips Obliqua + # did not also silently swallow the mandatory git-module checks. + agni_results = [r for r in results if r.name == 'AGNI'] + assert len(agni_results) == 1 + assert agni_results[0].status == FAIL + + def test_run_all_checks_includes_obliqua_when_installed(self, tmp_path): + """End to end: with a real Obliqua checkout present, run_all_checks + must include its CheckResult in the top-level report -- the + counterpart to the omitted-when-absent case above, confirming + ``required=False`` only changes the missing-checkout behaviour, + not whether an installed optional module is reported at all. + """ + pins = {'obliqua': {'ref': 'a' * 40}} + with ( + patch('proteus.doctor._dependency_specs', return_value={}), + patch('proteus.doctor._module_pins', return_value=pins), + patch('proteus.doctor._git_head', return_value='a' * 40), + patch( + 'proteus.doctor.get_proteus_directories', + return_value={'proteus': str(tmp_path), 'obliqua': str(tmp_path)}, + ), + ): + results = run_all_checks() + obliqua_results = [r for r in results if r.name == 'Obliqua'] + assert len(obliqua_results) == 1 + assert obliqua_results[0].status == PASS + + def test_run_all_checks_reports_check_error_for_optional_module_that_raises(self): + """A crash inside the optional-module loop must degrade to a FAIL + CheckResult (the same recovery pattern the mandatory-module loop + already has), not propagate and abort every other check. + """ + with ( + patch('proteus.doctor._dependency_specs', return_value={}), + patch('proteus.doctor._module_pins', return_value={}), + patch( + 'proteus.doctor.check_git_module', + side_effect=RuntimeError('boom_optional_xyz'), + ), + ): + results = run_all_checks() + obliqua_results = [r for r in results if r.name == 'Obliqua'] + assert len(obliqua_results) == 1 + assert obliqua_results[0].status == FAIL + assert 'boom_optional_xyz' in obliqua_results[0].message + + def _mixed_results() -> list[CheckResult]: """A pass + a fixable fail + a fixable warn, mirroring a real diagnose run.""" return [ diff --git a/tests/test_proteus.py b/tests/test_proteus.py index b5245135a..ae97be9ff 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -1779,6 +1779,27 @@ def test_plot_cadence_is_independent_of_write_snapshot_gate(tmp_path): ) +def test_it_timing_records_orbit_module_wall_time(tmp_path, monkeypatch, caplog): + """With the opt-in ``PROTEUS_TIMING`` instrumentation enabled (here + patched directly on the frozen module constant, since it is normally + read from the environment once at import time), the main loop must + record the orbit stage's wall-time in ``_t_mod`` and surface it in + the per-iteration ``[IT_TIMING]`` log line -- not just the other + instrumented stages. + """ + import logging + + monkeypatch.setattr('proteus.proteus._IT_TIMING_ENABLED', True) + p = _make_main_loop_proteus(tmp_path, plot_mod=1, write_mod=1, dt_write_rel=0.0) + + with caplog.at_level(logging.INFO, logger='fwl.proteus.proteus'): + _run_main_loop_capturing_plots(p, stop_at_loop=4) + + timing_records = [rec.message for rec in caplog.records if '[IT_TIMING]' in rec.message] + assert len(timing_records) > 0, 'no [IT_TIMING] log line was emitted' + assert any('orbit=' in msg for msg in timing_records) + + # ======================================================================================= # SECTION: mass conservation across a multi-iteration run # ======================================================================================= diff --git a/tests/tools/test_generate_config_reference.py b/tests/tools/test_generate_config_reference.py index dda9934f3..d53b98c62 100644 --- a/tests/tools/test_generate_config_reference.py +++ b/tests/tools/test_generate_config_reference.py @@ -83,7 +83,7 @@ def test_unannotated_fields_survive_with_declared_types(schema): assert by_path['planet.R_int_override']['type'] == 'float or none' assert by_path['planet.R_int_override']['bounds'] == [{'op': '>', 'value': 0}] # The override list must not rot: every entry still lacks an annotation. - assert len(_cs.ANNOTATION_OVERRIDES) == 7 + assert len(_cs.ANNOTATION_OVERRIDES) == 8 def test_enum_and_bound_extraction_pins_real_validator_sets(schema): diff --git a/tests/tools/test_migrate_config_v2_to_v3.py b/tests/tools/test_migrate_config_v2_to_v3.py index 8d03e906a..a95611b4b 100644 --- a/tests/tools/test_migrate_config_v2_to_v3.py +++ b/tests/tools/test_migrate_config_v2_to_v3.py @@ -122,7 +122,9 @@ def _v3(): 'interior_energetics.boundary.logging', 'interior_energetics.boundary.nusselt_exponent', 'interior_energetics.boundary.silicate_density', + 'interior_energetics.boundary.core_bulk', 'interior_energetics.boundary.core_density', + 'interior_energetics.boundary.core_shear', 'interior_energetics.boundary.silicate_heat_capacity', 'interior_energetics.boundary.thermal_conductivity', 'interior_energetics.boundary.thermal_diffusivity', @@ -186,6 +188,87 @@ def _v3(): 'observe.reference_pressure', 'observe.source', 'observe.spectrum_type', + # orbit.evolve/orbit.satellite (2.0 bools) are handled dynamically by + # _handle_orbit_dispatch, not a static OVERRIDES pin, so their 3.0 + # destinations land here rather than in mig.OVERRIDES. + 'orbit.star_planet_model', + 'orbit.planet_satellite_model', + 'orbit.perturber', + 'orbit.satellite.include_satellite', + 'orbit.satellite.mass_sat', + 'orbit.satellite.radius_sat', + 'orbit.satellite.semimajoraxis_sat', + 'orbit.satellite.eccentricity_sat', + 'orbit.satellite.evection_angle', + 'orbit.satellite.c_factor_sat', + 'orbit.satellite.axial_period_sat', + 'orbit.satellite.love_number_sat', + # Obliqua (orbit.obliqua.*) has no 2.0 analogue at all; every field + # is new and stays at its 3.0 default for a migrated config. + 'orbit.obliqua.store_3D', + 'orbit.obliqua.enforce_ec', + 'orbit.obliqua.optimize_scales', + 'orbit.obliqua.solid_shell', + 'orbit.obliqua.cap_LN', + 'orbit.obliqua.min_frac', + 'orbit.obliqua.visc_lus', + 'orbit.obliqua.visc_sus', + 'orbit.obliqua.n', + 'orbit.obliqua.m', + 'orbit.obliqua.k_min', + 'orbit.obliqua.k_max', + 'orbit.obliqua.evection_padding_factor', + 'orbit.obliqua.material_mu', + 'orbit.obliqua.material_k', + 'orbit.obliqua.alpha', + 'orbit.obliqua.verbosity', + 'orbit.obliqua.module_solid', + 'orbit.obliqua.module_mushy', + 'orbit.obliqua.module_fluid', + 'orbit.obliqua.solid.ncalc', + 'orbit.obliqua.solid.dr_min', + 'orbit.obliqua.solid.dr_max', + 'orbit.obliqua.solid.core', + 'orbit.obliqua.solid.core_props', + 'orbit.obliqua.solid.inertial_terms', + 'orbit.obliqua.solid.bulk_l', + 'orbit.obliqua.solid.porosity_thresh', + 'orbit.obliqua.solid.dbulk_power', + 'orbit.obliqua.mushy.b_width', + 'orbit.obliqua.mushy.t_width', + 'orbit.obliqua.fluid.sigma_R', + 'orbit.obliqua.fluid.sigma_R_factor', + 'orbit.obliqua.fluid.sigma_R_prf', + 'orbit.obliqua.fluid.H_R', + 'orbit.obliqua.fluid.efficiency', + # orbit.solver.* has no 2.0 analogue: the adaptive-substep + # controller and its shared solve_ivp tolerances did not exist as + # config in 2.0 at all. + 'orbit.solver.method', + 'orbit.solver.rtol', + 'orbit.solver.atol', + 'orbit.solver.dt0_yr', + 'orbit.solver.dt_max_yr', + 'orbit.solver.growth', + 'orbit.solver.shrink', + 'orbit.solver.max_rel_da', + 'orbit.solver.max_rel_de', + 'orbit.solver.max_rel_dOmega', + 'orbit.solver.de_floor', + 'orbit.solver.max_substeps', + 'orbit.solver.resonance_margin_enter', + 'orbit.solver.resonance_margin_exit', + 'orbit.solver.resonance_margin_approach', + 'orbit.solver.fine_csv_target_rel_dt', + # params.stop.satellite has no 2.0 analogue; stays at its 3.0 + # default (disabled) for a migrated config. + 'params.stop.satellite.enabled', + 'params.stop.satellite.sma_max', + 'params.stop.disint_sat.enabled', + 'params.stop.disint_sat.roche_enabled', + 'params.stop.disint_sat.offset_roche', + 'params.stop.disint_sat.spin_enabled', + 'params.stop.disint_sat.offset_spin', 'outgas.atmodeller.eos_CH4', 'outgas.atmodeller.eos_CO', 'outgas.atmodeller.eos_CO2', @@ -222,6 +305,12 @@ def _v3(): 'params.dt.max_growth_factor', 'params.dt.mushy_maximum', 'params.dt.mushy_upper', + 'params.dt.evection_maximum', + 'params.dt.evection_target_rel_de', + 'params.dt.evection_de_floor', + 'params.dt.evection_rate_window', + 'params.dt.evection_growth_factor', + 'params.dt.evection_cooldown_iters', 'params.dt.scale_decr', # The unconverged-atmosphere criterion applies at its measured # default, so a migrated config needs no explicit value for it. @@ -259,7 +348,7 @@ def _unhandled_v2_fields(paths): continue if path in mig.RENAMES or path in interior_targets: continue - if path in mig._ELEMENT_FIELDS or path in mig._IC_FIELDS: + if path in mig._ELEMENT_FIELDS or path in mig._IC_FIELDS or path in mig._ORBIT_FIELDS: continue if path in atmos_shared_src: continue @@ -347,9 +436,10 @@ def test_new_field_classified(): f'new (unclassified): {sorted((pm - overridden) - _REVIEWED_NEUTRAL)}; ' f'stale allowlist entries: {sorted(_REVIEWED_NEUTRAL - (pm - overridden))}' ) - # The two known behaviour-changing new fields are pinned, not neutral. + # The known behaviour-changing new fields are pinned, not neutral. assert 'interior_energetics.kappah_floor' in overridden assert 'params.dt.maximum_rel' in overridden + assert 'interior_energetics.tmagma_tides_step' in overridden def test_kappah_floor_and_maximum_rel_overrides(): @@ -394,6 +484,25 @@ def test_bol_scale_window_override_reproduces_unwindowed_2_0_scaling(): assert v3_defaults['star.bol_scale_start'] is None +def test_tmagma_tides_step_override_reproduces_hardcoded_2_0_cap(): + """2.0 hardcoded a 4.0 K poststep-change cap whenever tidal heating was + active (not user-configurable). The live 3.0 default of 10.0 K would + relax that cap for a migrated tidal-heating run, so the override must + pin the field to the stricter 2.0 value rather than let it default. + """ + assert mig.OVERRIDES['interior_energetics.tmagma_tides_step'] == pytest.approx(4.0) + # Discrimination: the pin must be strictly tighter than the live 3.0 + # default; a regression that pinned the 3.0 default value itself (a + # no-op override) would still satisfy an unconstrained equality check + # but relax the tidal-heating dT cap for migrated 2.0 runs. + v3_defaults, _ = _v3() + assert v3_defaults['interior_energetics.tmagma_tides_step'] == pytest.approx(10.0) + assert ( + mig.OVERRIDES['interior_energetics.tmagma_tides_step'] + < v3_defaults['interior_energetics.tmagma_tides_step'] + ) + + def _translate(v2_dict): nested, report = mig.translate(v2_dict) return mig._flatten(nested), report @@ -665,6 +774,34 @@ def test_radius_int_converts_earth_radii_to_metres(): assert any('radius-specified' in w for w in report.warnings) +def test_orbit_satellite_sma_converts_metres_to_earth_radii(): + """A 2.0 orbit.semimajoraxis_sat (metres) converts to 3.0's R_earth-valued + orbit.satellite.semimajoraxis_sat, not AU. + + 2.0 consumes semimajoraxis_sat as a metre quantity; 3.0's + orbit.satellite.semimajoraxis_sat is in R_earth (see the Satellite class + docstring in src/proteus/config/_orbit.py and its use as + ``semimajoraxis_sat * R_earth`` in src/proteus/orbit/wrapper.py). Dividing + by AU instead of R_earth would be off by a factor of AU/R_earth (~23600x). + """ + v2 = _minimal_spider_v2() + v2['orbit']['satellite'] = True + v2['orbit']['mass_sat'] = 7.347e22 # kg + v2['orbit']['semimajoraxis_sat'] = 3.0e8 # m + flat, report = _translate(v2) + + assert flat['orbit.satellite.semimajoraxis_sat'] == pytest.approx(47.35268, rel=1e-5) + # Discrimination guard: the stale AU-divisor result (3e8 / 1.495978707e11) + # differs from the correct R_earth-divisor result by more than four orders + # of magnitude, so a regression to the old divisor cannot pass silently. + au_divisor_result = 3.0e8 / 1.495978707e11 + assert abs(flat['orbit.satellite.semimajoraxis_sat'] - au_divisor_result) > 1.0 + + # mass_sat's M_earth conversion is unaffected by this fix and stays pinned. + assert flat['orbit.satellite.mass_sat'] == pytest.approx(0.01230241, rel=1e-5) + assert flat['orbit.satellite.include_satellite'] is True + + def test_albedo_lookup_table_is_dropped_with_a_warning(): """A 2.0 config whose ``albedo_pl`` names a CSV lookup table migrates to a valid 3.0 config, leaving the field at its default and saying so. diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 7d976baa1..4d4887574 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -1360,6 +1360,32 @@ def test_get_agni_version_with_mock(): assert version != 'AGNI' +@pytest.mark.unit +def test_get_obliqua_version_with_mock(): + """Test that _get_obliqua_version reads TOML file (mirrors + _get_agni_version: Obliqua is Julia-backed and cloned/instantiated by + tools/get_obliqua.sh, so its version is read from the checkout's own + Project.toml, not from Python package metadata). + """ + from proteus.utils.coupler import _get_obliqua_version + + with tempfile.TemporaryDirectory() as tmpdir: + toml_content = b'name = "Obliqua"\nversion = "0.1.0"\n' + toml_path = os.path.join(tmpdir, 'Project.toml') + with open(toml_path, 'wb') as f: + f.write(toml_content) + + dirs = {'obliqua': tmpdir} + version = _get_obliqua_version(dirs) + + assert version == '0.1.0' + # Discrimination: a regression that returned the 'name' field + # ('Obliqua') instead of the version key would still be a + # non-empty string. Pin the dotted-version shape explicitly. + assert version.count('.') == 2 + assert version != 'Obliqua' + + @pytest.mark.unit def test_get_lavatmos_version_with_mock(): """Test that _get_lavatmos_version reports the LAVA_DIR checkout's git hash.""" @@ -2063,6 +2089,7 @@ def test_get_proteus_directories_has_required_keys(): 'aragog', 'zalmoxis', 'vulcan', + 'obliqua', 'tools', 'utils', 'input', @@ -2084,23 +2111,26 @@ def test_get_proteus_directories_has_required_keys(): def test_get_proteus_directories_editable_submodule_paths(): """Each editable FWL submodule maps to its on-disk sibling directory. - Aragog / Zalmoxis / VULCAN are installed via the ``tools/get_*.sh`` - scripts as editable sibling checkouts inside the PROTEUS root. The - paths are case-sensitive on Linux: Aragog clones to ``aragog/``, - Zalmoxis to ``Zalmoxis/``, VULCAN to ``VULCAN/``. Pin the case here - so a doctor command or runtime path-resolver does not silently look - in the wrong directory. + Aragog / Zalmoxis / VULCAN / Obliqua are installed via the + ``tools/get_*.sh`` scripts as editable sibling checkouts inside the + PROTEUS root. The paths are case-sensitive on Linux: Aragog clones to + ``aragog/``, Zalmoxis to ``Zalmoxis/``, VULCAN to ``VULCAN/``, Obliqua + to ``Obliqua/`` (per ``tools/get_obliqua.sh``'s own default ``dest``). + Pin the case here so a doctor command or runtime path-resolver does + not silently look in the wrong directory. """ dirs = get_proteus_directories(outdir='unit-test') # Path basename must match the on-disk casing the get_*.sh scripts use. assert os.path.basename(dirs['aragog']) == 'aragog' assert os.path.basename(dirs['zalmoxis']) == 'Zalmoxis' assert os.path.basename(dirs['vulcan']) == 'VULCAN' + assert os.path.basename(dirs['obliqua']) == 'Obliqua' # Each path is anchored at the PROTEUS root (the parent of the # editable checkout), not somewhere else like /tmp or site-packages. assert os.path.dirname(dirs['aragog']) == dirs['proteus'] assert os.path.dirname(dirs['zalmoxis']) == dirs['proteus'] assert os.path.dirname(dirs['vulcan']) == dirs['proteus'] + assert os.path.dirname(dirs['obliqua']) == dirs['proteus'] # ============================================================================ @@ -3029,6 +3059,7 @@ def rec(name): atm_common.read_atmosphere_data = lambda *_a, **_k: [{'ok': True}] int_wrap = types.ModuleType('proteus.interior_energetics.wrapper') int_wrap.read_interior_data = lambda *_a, **_k: {'int': True} + int_wrap.run_interior = lambda *_a, **_k: None monkeypatch.setitem(sys.modules, 'proteus.atmos_clim.common', atm_common) monkeypatch.setitem(sys.modules, 'proteus.interior_energetics.wrapper', int_wrap) @@ -3052,16 +3083,17 @@ def rec(name): 'proteus.plot.cpl_global': 'plot_global', 'proteus.plot.cpl_interior': 'plot_interior', 'proteus.plot.cpl_interior_cmesh': 'plot_interior_cmesh', - 'proteus.plot.cpl_orbit': 'plot_orbit', + 'proteus.plot.cpl_orbit': ('plot_orbit', 'plot_orbit_system', 'plot_lovenumber'), 'proteus.plot.cpl_sflux': 'plot_sflux', 'proteus.plot.cpl_sflux_cross': 'plot_sflux_cross', 'proteus.plot.cpl_spectra': 'plot_spectra', 'proteus.plot.cpl_structure': 'plot_structure', 'proteus.plot.cpl_visual': 'plot_visual', } - for mod_name, fn_name in plot_map.items(): + for mod_name, fn_names in plot_map.items(): mod = types.ModuleType(mod_name) - setattr(mod, fn_name, rec(fn_name)) + for fn_name in (fn_names,) if isinstance(fn_names, str) else fn_names: + setattr(mod, fn_name, rec(fn_name)) monkeypatch.setitem(sys.modules, mod_name, mod) pop_mod = types.ModuleType('proteus.plot.cpl_population') @@ -3092,7 +3124,12 @@ def test_update_plots_covers_runtime_and_end_branches(monkeypatch, tmp_path): atmos_clim=types.SimpleNamespace(module='agni'), interior_energetics=types.SimpleNamespace(module='aragog'), observe=types.SimpleNamespace(module='petitRADTRANS'), - orbit=types.SimpleNamespace(evolve=True, satellite=False), + orbit=types.SimpleNamespace( + module='dummy', + star_planet_model='sp0d', + planet_satellite_model=None, + satellite=types.SimpleNamespace(include_satellite=False), + ), star=types.SimpleNamespace(module='mors', mors=types.SimpleNamespace(age_now=4.5)), atmos_chem=types.SimpleNamespace(module='vulcan'), params=types.SimpleNamespace(out=types.SimpleNamespace(plot_fmt='png')), @@ -3108,6 +3145,7 @@ def test_update_plots_covers_runtime_and_end_branches(monkeypatch, tmp_path): assert 'plot_global' in called_names assert 'plot_escape' in called_names assert 'plot_orbit' in called_names + assert 'plot_orbit_system' in called_names assert 'plot_interior' in called_names assert 'plot_atmosphere' in called_names assert 'plot_structure' in called_names @@ -3117,6 +3155,57 @@ def test_update_plots_covers_runtime_and_end_branches(monkeypatch, tmp_path): assert 'plot_emission' in called_names +@pytest.mark.unit +def test_update_plots_obliqua_module_calls_lovenumber_plot(monkeypatch, tmp_path): + """When the tidal-response module is Obliqua, UpdatePlots must glob the + per-time ``*_obliqua.nc`` snapshots, load their tidal data, and dispatch + to ``plot_lovenumber`` -- the branch this PR's Obliqua integration added, + previously untested (dummy_atm/orbit.module='dummy' in the other + UpdatePlots tests never reaches it). + """ + calls = [] + _install_updateplots_fakes(monkeypatch, calls) + + wrapper_mod = types.ModuleType('proteus.orbit.wrapper') + wrapper_mod.read_tides_data = lambda *_a, **_k: [{'ok': True}, {'ok': True}] + monkeypatch.setitem(sys.modules, 'proteus.orbit.wrapper', wrapper_mod) + + monkeypatch.setattr( + 'proteus.utils.coupler.glob.glob', + lambda _p: [ + str(tmp_path / 'data' / '1000_obliqua.nc'), + str(tmp_path / 'data' / '2000_obliqua.nc'), + ], + ) + + cfg = types.SimpleNamespace( + atmos_clim=types.SimpleNamespace(module='dummy'), + interior_energetics=types.SimpleNamespace(module='dummy'), + observe=types.SimpleNamespace(module=None), + orbit=types.SimpleNamespace( + module='obliqua', star_planet_model=None, planet_satellite_model=None + ), + star=types.SimpleNamespace(module='dummy', mors=types.SimpleNamespace(age_now=4.5)), + atmos_chem=types.SimpleNamespace(module='dummy'), + params=types.SimpleNamespace(out=types.SimpleNamespace(plot_fmt='png')), + ) + hf_all = pd.DataFrame({'Time': [1.0, 2.0]}) + dirs = {'output': str(tmp_path), 'fwl': str(tmp_path / 'fwl')} + + from proteus.utils.coupler import UpdatePlots + + UpdatePlots(hf_all, dirs, cfg, end=True, num_snapshots=1) + + called_names = [c[0] for c in calls] + assert 'plot_lovenumber' in called_names + # Discrimination: a regression that skipped the glob/parse step (e.g. + # passed the raw '*_obliqua.nc' pattern through unparsed) would still + # call plot_lovenumber, but with zero times -- pin that real nc_times + # were parsed and threaded through. + lovenumber_call = next(c for c in calls if c[0] == 'plot_lovenumber') + assert lovenumber_call[2] == ('data', 'output_dir', 'plot_format', 'times') + + @pytest.mark.unit def test_set_directories_errors_without_fwl_data(monkeypatch): """Cover set_directories failure when FWL_DATA is absent.""" @@ -3207,6 +3296,7 @@ def test_validate_module_versions_spider_stack_passes_with_unpinned_dep(monkeypa outgas=types.SimpleNamespace(module='calliope'), escape=types.SimpleNamespace(module='zephyrus'), star=types.SimpleNamespace(module='mors'), + orbit=types.SimpleNamespace(module='dummy'), ) requires = [ 'numpy', @@ -3252,6 +3342,7 @@ def test_validate_module_versions_raises_for_old_janus_in_spider_stack(monkeypat outgas=types.SimpleNamespace(module='dummy'), escape=types.SimpleNamespace(module='dummy'), star=types.SimpleNamespace(module='dummy'), + orbit=types.SimpleNamespace(module='dummy'), ) monkeypatch.setitem(sys.modules, 'janus', types.SimpleNamespace(__version__='0.1.0')) @@ -3264,6 +3355,105 @@ def test_validate_module_versions_raises_for_old_janus_in_spider_stack(monkeypat mock_update.assert_called_once() +@pytest.mark.unit +def test_validate_module_versions_raises_for_old_obliqua(tmp_path): + """Obliqua is Julia-backed (no pip package metadata), so its check + mirrors AGNI's: version read from the checkout's own Project.toml via + ``_get_obliqua_version``, compared against the hardcoded + ``OBLIQUA_MIN_VERSION`` rather than a ``requires()`` pin. + """ + from proteus.utils.coupler import validate_module_versions + + config = types.SimpleNamespace( + interior_energetics=types.SimpleNamespace(module='dummy'), + interior_struct=types.SimpleNamespace(module='dummy'), + atmos_clim=types.SimpleNamespace(module='dummy'), + outgas=types.SimpleNamespace(module='dummy'), + escape=types.SimpleNamespace(module='dummy'), + star=types.SimpleNamespace(module='dummy'), + orbit=types.SimpleNamespace(module='obliqua'), + ) + obliqua_dir = tmp_path / 'Obliqua' + obliqua_dir.mkdir() + (obliqua_dir / 'Project.toml').write_text('name = "Obliqua"\nversion = "0.0.1"\n') + + with ( + patch('importlib.metadata.requires', return_value=[]), + patch('proteus.utils.coupler.UpdateStatusfile') as mock_update, + ): + with pytest.raises(EnvironmentError, match='Out-of-date modules'): + validate_module_versions( + {'rad': str(tmp_path), 'obliqua': str(obliqua_dir)}, config + ) + mock_update.assert_called_once() + + +@pytest.mark.unit +def test_validate_module_versions_accepts_current_obliqua(tmp_path): + """The boundary case: an installed Obliqua exactly at + ``OBLIQUA_MIN_VERSION`` passes (the comparison is inclusive), and no + other module's check must be disturbed by the addition of the orbit + branch. + """ + from proteus.utils.coupler import OBLIQUA_MIN_VERSION, validate_module_versions + + config = types.SimpleNamespace( + interior_energetics=types.SimpleNamespace(module='dummy'), + interior_struct=types.SimpleNamespace(module='dummy'), + atmos_clim=types.SimpleNamespace(module='dummy'), + outgas=types.SimpleNamespace(module='dummy'), + escape=types.SimpleNamespace(module='dummy'), + star=types.SimpleNamespace(module='dummy'), + orbit=types.SimpleNamespace(module='obliqua'), + ) + obliqua_dir = tmp_path / 'Obliqua' + obliqua_dir.mkdir() + (obliqua_dir / 'Project.toml').write_text( + f'name = "Obliqua"\nversion = "{OBLIQUA_MIN_VERSION}"\n' + ) + + with ( + patch('importlib.metadata.requires', return_value=[]), + patch('proteus.utils.coupler.UpdateStatusfile') as mock_update, + ): + result = validate_module_versions( + {'rad': str(tmp_path), 'obliqua': str(obliqua_dir)}, config + ) + assert result is None + assert mock_update.call_count == 0 + + +@pytest.mark.unit +def test_print_module_configuration_logs_obliqua_version_and_julia(monkeypatch): + """orbit.module == 'obliqua' must print Obliqua's own version (read + via _get_obliqua_version) on the 'Orbit module' line, plus the Julia + sub-line -- the same treatment 'lovepy' already gets, since Obliqua is + equally Julia-backed. + """ + config = types.SimpleNamespace( + interior_energetics=types.SimpleNamespace(module='dummy'), + atmos_clim=types.SimpleNamespace(module='dummy'), + outgas=types.SimpleNamespace(module='dummy', vapourise=False), + escape=types.SimpleNamespace(module='dummy'), + star=types.SimpleNamespace(module='dummy'), + orbit=types.SimpleNamespace(module='obliqua'), + accretion=types.SimpleNamespace(module='dummy'), + atmos_chem=types.SimpleNamespace(module='dummy'), + observe=types.SimpleNamespace(module='dummy'), + ) + dirs = {'proteus': '/tmp/proteus', 'output': '/tmp/out', 'rad': '/tmp/rad'} + + monkeypatch.setattr(coupler_mod, '_get_git_revision', lambda _d: 'abc123') + monkeypatch.setattr(coupler_mod, '_get_obliqua_version', lambda _d: '0.1.0') + monkeypatch.setattr(coupler_mod, '_get_julia_version', lambda: '1.10.3') + + with patch('proteus.utils.coupler.log') as mock_log: + print_module_configuration(dirs, config, '/tmp/cfg.toml') + messages = [str(call) for call in mock_log.info.call_args_list] + assert any('Orbit module obliqua version 0.1.0' in m for m in messages) + assert any('Julia' in m and '1.10.3' in m for m in messages) + + @pytest.mark.unit def test_print_citation_agni_and_manual_mode_cover_noop_cases(): """Cover print_citation match-case no-op arms and chemistry manual gate.""" @@ -3298,7 +3488,9 @@ def test_update_plots_spider_dummy_atm_covers_skip_branches(monkeypatch, tmp_pat atmos_clim=types.SimpleNamespace(module='dummy'), interior_energetics=types.SimpleNamespace(module='spider'), observe=types.SimpleNamespace(module=None), - orbit=types.SimpleNamespace(evolve=False, satellite=False), + orbit=types.SimpleNamespace( + module='dummy', star_planet_model=None, planet_satellite_model=None + ), star=types.SimpleNamespace(module='dummy', mors=types.SimpleNamespace(age_now=4.5)), atmos_chem=types.SimpleNamespace(module='dummy'), params=types.SimpleNamespace(out=types.SimpleNamespace(plot_fmt='png')), @@ -3315,6 +3507,7 @@ def test_update_plots_spider_dummy_atm_covers_skip_branches(monkeypatch, tmp_pat assert 'plot_escape' in called_names assert 'plot_interior' in called_names assert 'plot_orbit' not in called_names + assert 'plot_orbit_system' not in called_names assert 'plot_atmosphere' not in called_names assert 'plot_spectra' not in called_names diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index 6ba1ae52c..b238c6926 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -352,6 +352,24 @@ def test_status_volatiles_escaped(self): # pinning the volatiles-escaped qualifier substring. assert 'volatiles' in CommentFromStatus(15) + @pytest.mark.unit + def test_status_satellite_escaped(self): + """Status 17: Completed (satellite escaped).""" + assert CommentFromStatus(17) == 'Completed (satellite escaped)' + # Discrimination: differentiate from 18 (satellite disintegrated) + # by pinning the escaped-specific qualifier substring. + assert 'escaped' in CommentFromStatus(17) + assert 'disintegrated' not in CommentFromStatus(17) + + @pytest.mark.unit + def test_status_satellite_disintegrated(self): + """Status 18: Completed (satellite disintegrated).""" + assert CommentFromStatus(18) == 'Completed (satellite disintegrated)' + # Discrimination: differentiate from 16 (planet disintegrated) by + # pinning that the qualifier names the satellite, not the planet. + assert 'satellite disintegrated' in CommentFromStatus(18) + assert CommentFromStatus(18) != CommentFromStatus(16) + @pytest.mark.unit def test_status_generic_error(self): """Status 20: Generic error.""" diff --git a/tests/utils/test_terminate.py b/tests/utils/test_terminate.py index f799246e8..c277c4d32 100644 --- a/tests/utils/test_terminate.py +++ b/tests/utils/test_terminate.py @@ -14,6 +14,7 @@ import pytest import proteus.utils.terminate as terminate +from proteus.utils.constants import R_earth pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] @@ -35,6 +36,14 @@ def ns(**kw): offset_roche=0.0, offset_spin=0.0, ), + disint_sat=ns( + enabled=False, + roche_enabled=True, + spin_enabled=True, + offset_roche=0.0, + offset_spin=0.0, + ), + satellite=ns(enabled=False, sma_max=0.0), time=ns(enabled=True, maximum=100.0, minimum=0.0), iters=ns(enabled=True, total_loops=5, total_min=1), clock=ns(enabled=True, maximum=600.0), @@ -198,6 +207,176 @@ def test_check_spinrate_triggers_breakup(patch_statusfile): assert patch_statusfile[-1][1] == 16 +@pytest.mark.unit +def test_check_satellite_triggers_escape_sma(patch_statusfile): + """Satellite escape: semimajor axis at/above sma_max exits with status 17.""" + cfg = _cfg() + cfg.params.stop.satellite.enabled = True + cfg.params.stop.satellite.sma_max = 10.0 + h = _handler(cfg) + h.hf_row['semimajorax_sat'] = 10.0 * R_earth + assert terminate._check_satellite(h) is True + assert patch_statusfile[-1][1] == 17 + + +@pytest.mark.unit +def test_check_satellite_not_triggered_below_escape_sma(patch_statusfile): + """Satellite escape: semimajor axis comfortably below sma_max keeps the + simulation running -- edge case for the >= boundary above.""" + cfg = _cfg() + cfg.params.stop.satellite.enabled = True + cfg.params.stop.satellite.sma_max = 10.0 + h = _handler(cfg) + h.hf_row['semimajorax_sat'] = 5.0 * R_earth + assert terminate._check_satellite(h) is False + assert patch_statusfile == [] + + +@pytest.mark.unit +def test_check_satellite_separation_triggers_roche_limit(patch_statusfile): + """Satellite disintegration: the satellite's own time-averaged + separation from the planet, below the satellite's own Roche limit, + exits with status 18. + """ + cfg = _cfg() + h = _handler(cfg) + h.hf_row['separation_sat'] = 0.9 + h.hf_row['roche_limit_sat'] = 1.0 + assert terminate._check_satellite_separation(h) is True + assert patch_statusfile[-1][1] == 18 + + +@pytest.mark.unit +def test_check_satellite_separation_not_triggered_outside_roche_limit(patch_statusfile): + """Edge case for the boundary above: satellite separation comfortably + outside the satellite's Roche limit keeps the simulation running.""" + cfg = _cfg() + h = _handler(cfg) + h.hf_row['separation_sat'] = 5.0 + h.hf_row['roche_limit_sat'] = 1.0 + h.hf_row['separation'] = 1.5e11 # ~1 AU; must not leak into this check + assert terminate._check_satellite_separation(h) is False + assert patch_statusfile == [] + + +@pytest.mark.unit +def test_check_satellite_spinrate_triggers_breakup(patch_statusfile): + """Satellite disintegration: spinning faster than its own breakup + rate exits with status 18.""" + cfg = _cfg() + h = _handler(cfg) + h.hf_row['axial_period_sat'] = 4.0 + h.hf_row['breakup_period_sat'] = 5.0 + assert terminate._check_satellite_spinrate(h) is True + assert patch_statusfile[-1][1] == 18 + + +@pytest.mark.unit +def test_check_satellite_spinrate_not_triggered_above_breakup_period(patch_statusfile): + """Edge case for the boundary above: satellite spin period + comfortably longer than its breakup period keeps the simulation + running.""" + cfg = _cfg() + h = _handler(cfg) + h.hf_row['axial_period_sat'] = 20.0 + h.hf_row['breakup_period_sat'] = 5.0 + assert terminate._check_satellite_spinrate(h) is False + assert patch_statusfile == [] + + +@pytest.mark.unit +def test_check_termination_dispatches_satellite_disintegration_checks( + monkeypatch, patch_statusfile +): + """``check_termination`` must actually reach the satellite + disintegration checks when ``stop.disint_sat.enabled`` is True. + """ + cfg = _cfg() + cfg.params.stop.disint_sat.enabled = True + h = _handler(cfg) + # Roche check runs first (roche_enabled defaults True); keep it safely + # unmet so the spin-rate trigger below is what's actually observed. + h.hf_row['separation_sat'] = 5.0 + h.hf_row['roche_limit_sat'] = 1.0 + h.hf_row['axial_period_sat'] = 4.0 + h.hf_row['breakup_period_sat'] = 5.0 + h.loops['total'] = 5 # satisfy min_iter so exit is allowed + monkeypatch.setattr(terminate.os.path, 'exists', lambda _: True) + + assert terminate.check_termination(h) is True + assert patch_statusfile[-1][1] == 18 + + +@pytest.mark.unit +def test_check_termination_skips_satellite_roche_check_when_disabled( + monkeypatch, patch_statusfile +): + """``disint_sat.roche_enabled=False`` must skip the satellite Roche + check entirely -- a satellite well within its Roche limit (which + would otherwise terminate the run) must NOT trigger termination while + the gate is off. + """ + cfg = _cfg() + cfg.params.stop.disint_sat.enabled = True + cfg.params.stop.disint_sat.roche_enabled = False + h = _handler(cfg) + # Deep within the Roche limit -- would trigger if the check ran. + h.hf_row['separation_sat'] = 0.5 + h.hf_row['roche_limit_sat'] = 1.0 + # Spin safely unmet, so it is not what keeps this from terminating. + h.hf_row['axial_period_sat'] = 10.0 + h.hf_row['breakup_period_sat'] = 5.0 + h.loops['total'] = 5 + monkeypatch.setattr(terminate.os.path, 'exists', lambda _: True) + + assert terminate.check_termination(h) is False + assert patch_statusfile == [] + + +@pytest.mark.unit +def test_check_termination_skips_satellite_spinrate_check_when_disabled( + monkeypatch, patch_statusfile +): + """``disint_sat.spin_enabled=False`` must skip the satellite spin-rate + check entirely -- a satellite spinning faster than its breakup rate + (which would otherwise terminate the run) must NOT trigger termination + while the gate is off. + """ + cfg = _cfg() + cfg.params.stop.disint_sat.enabled = True + cfg.params.stop.disint_sat.spin_enabled = False + h = _handler(cfg) + # Roche safely unmet, so it is not what keeps this from terminating. + h.hf_row['separation_sat'] = 5.0 + h.hf_row['roche_limit_sat'] = 1.0 + # Spinning faster than breakup -- would trigger if the check ran. + h.hf_row['axial_period_sat'] = 4.0 + h.hf_row['breakup_period_sat'] = 5.0 + h.loops['total'] = 5 + monkeypatch.setattr(terminate.os.path, 'exists', lambda _: True) + + assert terminate.check_termination(h) is False + assert patch_statusfile == [] + + +@pytest.mark.unit +def test_check_termination_wires_up_satellite_escape_check(monkeypatch, patch_statusfile): + """The satellite-escape criterion must actually be reachable through + the top-level ``check_termination`` orchestrator when enabled, not + just callable in isolation (see ``test_check_satellite_triggers_escape_sma`` + above).""" + cfg = _cfg() + cfg.params.stop.satellite.enabled = True + cfg.params.stop.satellite.sma_max = 10.0 + h = _handler(cfg) + h.hf_row['semimajorax_sat'] = 10.0 * R_earth + h.loops['total'] = 5 # satisfy min_iter so exit is allowed + monkeypatch.setattr(terminate.os.path, 'exists', lambda _: True) + + assert terminate.check_termination(h) is True + assert patch_statusfile[-1][1] == 17 + + @pytest.mark.unit def test_check_maxtime_triggers(patch_statusfile): """Time limit: exceeding maximum time exits with status 13.""" diff --git a/tools/_config_schema.py b/tools/_config_schema.py index 29155d680..d7e9d4bbd 100644 --- a/tools/_config_schema.py +++ b/tools/_config_schema.py @@ -36,6 +36,7 @@ ANNOTATION_OVERRIDES = { 'planet.R_int_override': 'float', 'orbit.axial_period': 'float', + 'orbit.satellite.axial_period_sat': 'float', 'interior_struct.zalmoxis.ice_layer_eos': 'str', 'interior_struct.core_density': 'float | str', 'interior_struct.core_heatcap': 'float | str', diff --git a/tools/_helpfile_scan.py b/tools/_helpfile_scan.py index 99dc1f6bd..a905f6cf3 100644 --- a/tools/_helpfile_scan.py +++ b/tools/_helpfile_scan.py @@ -97,6 +97,10 @@ # hf_row.update(saved) restores of pre-call snapshots. ('interior_energetics/wrapper.py', '_solve_structure_with_adiabat_or_rollback'), ('interior_energetics/wrapper.py', 'update_structure_from_interior'), + # hf_row.clear(); hf_row.update(snapshot) restores the pre-substep state + # on a rejected adaptive step; shared by evolve_orbit_star (orbit.py) and + # evolve_orbit_satellite (satellite.py). + ('orbit/common.py', 'run_adaptive_orbit_substeps'), } # Producers that assemble their key through a local variable the visitor diff --git a/tools/generate_module_map.py b/tools/generate_module_map.py index 4b356d0dd..f8cfe77da 100644 --- a/tools/generate_module_map.py +++ b/tools/generate_module_map.py @@ -131,8 +131,13 @@ 'config_path': 'orbit.module', 'wrapper': 'orbit/wrapper.py', 'entries': { - 'lovepy': ('orbit/lovepy.py', 'run_lovepy', 'Multi-phase tidal heating (Julia)'), - 'dummy': ('orbit/dummy.py', 'run_dummy_orbit', 'Fixed Im(k2) tides'), + 'lovepy': ('orbit/lovepy.py', 'run_lovepy', 'Solid-phase tidal heating (Julia)'), + 'obliqua': ( + 'orbit/obliqua.py', + 'run_obliqua', + 'Multi-phase tidal response (Julia)', + ), + 'dummy': ('orbit/dummy.py', 'run_dummy_tides', 'Fixed Im(k2) tides'), None: (None, None, 'Tides disabled; Im(k2) set to zero'), }, }, @@ -211,17 +216,17 @@ ), ( 'Orbit and tides', - '`orbit.evolve = true`', + '`orbit.star_planet_model != none`', 'orbit/orbit.py', - 'evolve_orbital', - 'evolves semi-major axis and eccentricity each iteration', + 'evolve_orbit_star', + 'evolves the star-planet orbit (sp0d/sp1d) each iteration', ), ( 'Orbit and tides', - '`orbit.satellite = true`', + '`orbit.planet_satellite_model != none`', 'orbit/satellite.py', - 'update_satellite', - 'evolves the satellite orbit instead of the planetary one', + 'evolve_orbit_satellite', + 'evolves the planet-satellite orbit (ps0d/ps1d/ps1d_evec) each iteration', ), ( 'Star', diff --git a/tools/generate_version_badges.py b/tools/generate_version_badges.py index cd2793833..bef0b2233 100644 --- a/tools/generate_version_badges.py +++ b/tools/generate_version_badges.py @@ -79,6 +79,13 @@ 'https://proteus-framework.org/SPIDER/', 'Docs', ), + 'obliqua': ( + 'Obliqua', + 'Multi-phase tidal response (Julia)', + 'https://github.com/FormingWorlds/Obliqua', + 'https://proteus-framework.org/Obliqua/', + 'Docs', + ), } # Each entry: (label, role, pin_val, color, pin_link, doc_url, doc_label, extra). @@ -89,7 +96,7 @@ OPTIONAL = [ ( 'LovePy', - 'Multi-phase tidal heating (Julia)', + 'Solid-phase tidal heating (Julia)', 'main', 'lightgrey', 'https://github.com/nichollsh/LovePy', @@ -117,16 +124,6 @@ 'GitHub', ('vulcan', 'fwl-vulcan'), ), - ( - 'Obliqua', - 'Orbital evolution and tides (Julia)', - None, - None, - None, - 'https://github.com/FormingWorlds/Obliqua', - 'GitHub', - None, - ), ] diff --git a/tools/get_obliqua.sh b/tools/get_obliqua.sh new file mode 100755 index 000000000..ff836b2ef --- /dev/null +++ b/tools/get_obliqua.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Clone Obliqua, instantiate its own environment, and register it into the +# default Julia environment. +# +# Mirrors Obliqua's own documented install steps (README.md "Installation": +# clone, then `pkg> add .`) so the clone target and ref come from +# pyproject.toml's [tool.proteus.modules.obliqua] table instead of being +# typed by hand. Use OBLIQUA_GIT_URL / OBLIQUA_GIT_REF env vars to override +# for local dev. +# +# Usage: +# tools/get_obliqua.sh # clone into ./Obliqua/ at the pinned ref +# tools/get_obliqua.sh 0 # also skip Obliqua's own test suite +# tools/get_obliqua.sh some/path # custom destination + +set -euo pipefail + +if ! command -v julia >/dev/null 2>&1; then + echo "ERROR: julia is not on PATH. Install Julia first (see https://github.com/FormingWorlds/Obliqua)." >&2 + exit 1 +fi + +script_root="$(cd "$(dirname "$0")/.." && pwd)" + +ob_url="${OBLIQUA_GIT_URL:-$(python "$script_root/tools/_module_pins.py" obliqua url)}" +ob_ref="${OBLIQUA_GIT_REF:-$(python "$script_root/tools/_module_pins.py" obliqua ref)}" + +# First positional arg can be either "0" (skip Obliqua test step) or a path. +# Passing "0" skips Pkg.test (Obliqua has no upstream install script of its +# own to preserve an interface for). Anything else is treated as a +# destination path. +skip_tests="" +dest="$script_root/Obliqua" +if [ "${1:-}" = "0" ]; then + skip_tests="0" +elif [ -n "${1:-}" ]; then + dest="$1" +fi + +# A stale Manifest.toml left over from a previous (possibly broken or +# differently-pinned) attempt is a common source of Pkg.instantiate +# failures; drop it before touching git so instantiate always resolves +# fresh against the checked-out Project.toml. +if [ -d "$dest" ]; then + rm -f "$dest/Manifest.toml" +fi + +if [ ! -d "$dest/.git" ]; then + echo "Cloning Obliqua ($ob_url @ $ob_ref) into $dest..." + git clone "$ob_url" "$dest" +fi + +git -C "$dest" fetch --quiet origin +git -C "$dest" checkout --quiet "$ob_ref" + +echo "Obliqua at $(git -C "$dest" rev-parse --short HEAD)" + +cd "$dest" +LD_LIBRARY_PATH="" julia --project=. -e 'using Pkg; Pkg.resolve(); Pkg.instantiate()' + +# Register Obliqua into the DEFAULT Julia environment. +echo "Registering Obliqua into the default Julia environment..." +LD_LIBRARY_PATH="" julia -e 'using Pkg; Pkg.add(path=".")' +LD_LIBRARY_PATH="" julia -e 'using Obliqua; println("Installed to: "*pathof(Obliqua))' + +if [ "$skip_tests" != "0" ]; then + echo "Running Obliqua's own test suite..." + LD_LIBRARY_PATH="" julia --project=. -e 'using Pkg; Pkg.test()' +fi diff --git a/tools/migrate_config_v2_to_v3.py b/tools/migrate_config_v2_to_v3.py index e7031d07e..be59be9db 100644 --- a/tools/migrate_config_v2_to_v3.py +++ b/tools/migrate_config_v2_to_v3.py @@ -260,6 +260,10 @@ def _radius_int_to_metres(v): # 2.0 sized escape from the unmodified XUV radius; the 3.0 default clips # it to the Hill radius. Pin off so migrated runs reproduce 2.0 rates. 'escape.hill_clamp': False, + # 2.0 hardcoded a 4.0 K poststep-change cap when tidal heating was active + # (not configurable); the 3.0 default of 10.0 K would relax that cap for + # migrated tidal-heating runs. Pin 4.0 to reproduce 2.0 behaviour. + 'interior_energetics.tmagma_tides_step': 4.0, } # Element-budget fields consumed by the element handler (not mapped directly). @@ -286,6 +290,24 @@ def _radius_int_to_metres(v): 'interior.dummy.ini_tmagma', } +# Orbit-evolution fields consumed by the orbit-dispatch handler: 2.0's bool +# flags do not reduce to a single rename, since 3.0 replaced each with a +# string-valued model choice (and split the satellite block out under +# [orbit.satellite]). +_ORBIT_FIELDS = { + 'orbit.evolve', + 'orbit.satellite', + 'orbit.mass_sat', + 'orbit.semimajoraxis_sat', +} + +# Earth mass [kg] / astronomical unit [m]. 2.0 orbit.mass_sat and +# orbit.semimajoraxis_sat are in kg/m; 3.0's orbit.satellite.mass_sat and +# .semimajoraxis_sat are in M_earth/AU. Hard-coded to keep the value +# transform independent of an importable proteus at call time; verified +# against proteus.utils.constants.M_earth / AU. +_M_EARTH_KG = 5.972e24 + # Volatiles partial-pressure block: delivery.volatiles.X -> planet.gas_prs.X. _VOLATILE_SPECIES = ('H2O', 'CO2', 'N2', 'S2', 'SO2', 'H2S', 'NH3', 'H2', 'CH4', 'CO') @@ -618,6 +640,41 @@ def _handle_temperature_mode(eff_v2, explicit, v3, active, report): ) +def _handle_orbit_dispatch(eff_v2, explicit, v3, report): + """Map 2.0's boolean orbit.evolve/orbit.satellite flags to 3.0's + string-valued star_planet_model/planet_satellite_model dispatch. + + 2.0 had exactly one orbital-evolution model and one satellite model; + 3.0 offers several per family (sp0d/sp1d; ps0d/ps1d/ps1d_evec). The + simplest 3.0 model in each family (sp0d, ps0d) is the closest analogue + to what 2.0 actually ran, so a set 2.0 flag maps to that rather than a + guess at which richer 3.0 model the user would have wanted. + """ + if eff_v2.get('orbit.evolve'): + v3['orbit.star_planet_model'] = 'sp0d' + if 'orbit.evolve' in explicit: + report.warnings.append( + 'orbit.evolve mapped to orbit.star_planet_model="sp0d" (the ' + 'simplest 3.0 orbital-evolution model); review whether sp1d ' + 'better matches the intended run.' + ) + if eff_v2.get('orbit.satellite'): + v3['orbit.planet_satellite_model'] = 'ps0d' + v3['orbit.satellite.include_satellite'] = True + mass_sat = eff_v2.get('orbit.mass_sat') + if mass_sat not in (None, 'none'): + v3['orbit.satellite.mass_sat'] = float(mass_sat) / _M_EARTH_KG + sma_sat = eff_v2.get('orbit.semimajoraxis_sat') + if sma_sat not in (None, 'none'): + v3['orbit.satellite.semimajoraxis_sat'] = float(sma_sat) / _R_EARTH_M + if 'orbit.satellite' in explicit: + report.warnings.append( + 'orbit.satellite mapped to orbit.planet_satellite_model="ps0d" ' + '(the simplest 3.0 satellite model); review whether ps1d or ' + 'ps1d_evec better matches the intended run.' + ) + + def translate(v2_toml: dict): """Translate a parsed 2.0 config dict into a validated 3.0 config dict. @@ -672,7 +729,12 @@ def emit(v3_path, value, src_path): atmos_hoist_src = {f'atmos_clim.{active["atmos_clim"]}.{field}' for field in _ATMOS_SHARED} for v2_path, val in eff_v2.items(): # consumed by special handlers - if v2_path in _ELEMENT_FIELDS or v2_path in _IC_FIELDS or v2_path in atmos_hoist_src: + if ( + v2_path in _ELEMENT_FIELDS + or v2_path in _IC_FIELDS + or v2_path in atmos_hoist_src + or v2_path in _ORBIT_FIELDS + ): continue if v2_path.startswith('delivery.volatiles.'): sp = v2_path.split('.')[-1] @@ -727,6 +789,7 @@ def emit(v3_path, value, src_path): _handle_elements(eff_v2, explicit, v3, report) _handle_atmos_hoist(eff_v2, explicit, v3, active, report) _handle_temperature_mode(eff_v2, explicit, v3, active, report) + _handle_orbit_dispatch(eff_v2, explicit, v3, report) # derived fields with no 2.0 source if v3.get('interior_struct.module') == 'spider':