Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/central_forces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Central-force potentials

BlueberryCircus began with two hardcoded binding laws — `Harmonic` and
`Coulomb`. This module generalizes the potential layer into an extensible
**central-force family** while keeping the engine, the radiation-reaction
reduction, and the certificate discipline untouched.

## The abstraction

A potential is any object exposing `potential(x)`, `force(x)`, and
`force_jacobian(x)`. The Jacobian is not optional: the Landau–Lifshitz
radiation-reaction reduction needs `dF/dt = J · v`. To make new laws cheap and
correct, `CentralPotential` derives all three Cartesian quantities from three
*radial* functions of the (optionally softened) radius `s = √(r² + softening²)`:

| supply | meaning |
|---|---|
| `_U(s)` | potential energy `U(r)` |
| `_dU(s)` | `U'(r)` |
| `_d2U(s)` | `U''(r)` |

With `F(x) = g(s) x`, `g(s) = −U'(s)/s`, the analytic Jacobian is

```
J = g(s) I + (g'(s)/s) x xᵀ , g'(s) = (U'(s) − s U''(s)) / s² .
```

Every subclass is a handful of lines and inherits an exact `force_jacobian`.

## The families

| class | `U(r)` | notes |
|---|---|---|
| `PowerLaw(coeff, p)` | `coeff · rᵖ` | continuous exponent; `p=2` → harmonic, `p=−1` → Coulomb |
| `Yukawa(g, lam)` | `−(g/r) e^{−r/λ}` | screened Coulomb (`λ→∞` → Coulomb) |
| `Morse(De, a, re)` | `De(1−e^{−a(r−re)})² − De` | bounded well, finite dissociation energy |
| `AnharmonicOscillator(omega0, beta)` | `½ m ω₀² r² + ¼ β r⁴` | isotropic Duffing (`β=0` → harmonic) |
| `LennardJones(eps, sigma)` | `4ε[(σ/r)¹² − (σ/r)⁶]` | stiff repulsive core; use `softening` or keep the orbit off the core |

Singular laws take a Plummer `softening` length that regularizes the `r→0` core,
identical in spirit to `Coulomb.softening`.

## Declarative use

The new operations plug into the existing `op | q[i]` program model. Potential
collection in `Program.compile()` is now keyed on the `_PotentialOp` marker, so
future potentials are picked up automatically (no more hardcoded tuple).

```python
import blueberry_circus as bc

prog = bc.Program(n_particles=1)
with prog.context as q:
bc.Yukawa(g=0.8, lam=2.0, softening=1e-3) | q[0]
bc.ZPF(band=(0.3, 3.0), n_modes=200, seed=0) | q[0]
bc.RadiationReaction("landau_lifshitz") | q[0]
result = bc.Engine(dt=0.02, t_max=600).run(prog, x0=[0, 0, 0], v0=[0, 0, 0])
```

## Conservation certificates

`blueberry_circus.conservation` adds two re-checkable certificates for closed
conservative systems (`rr="none"`, no field):

- `energy_conservation_certificate(traj, potential, particle, tol)`
- `angular_momentum_conservation_certificate(traj, particle, tol)`

Both encode the fractional peak-to-peak drift of the invariant against the
canonical `residual_le_tol` rule, so an under-resolved integration or a tampered
trajectory re-derives `FAIL`. For a central force `|L|` conservation is the
signature of centrality, and energy conservation cross-checks that a new
potential's analytic `force` is consistent with its `potential` energy — purely
from the integrated dynamics. See `examples/central_forces.py`; all six families
conserve both invariants to ≲10⁻¹¹ at `dt = 5·10⁻³`.

## Correctness

`tests/test_central_potentials.py` pins the family three independent ways:

1. **Finite differences** — analytic `force` and `force_jacobian` match central
differences of `potential` and `force`; the Jacobian is symmetric (curl-free).
2. **Reduction oracles** — `PowerLaw` reproduces `Harmonic` (`p=2`) and `Coulomb`
(`p=−1`) to floating-point closeness.
3. **Conservation** — closed orbits pass the energy and angular-momentum
certificates, and a real drift `FAIL`s an impossibly tight tolerance.
63 changes: 63 additions & 0 deletions examples/central_forces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Central-force potential family: declarative programs + conservation certificates.

Runs a near-circular orbit under each new potential with radiation reaction off
(a closed conservative system) and prints the re-checkable energy and
angular-momentum conservation certificates. Because the ZPF background is a pure
function of its seed, swapping the potential while holding the seed fixed gives a
*matched-seed counterfactual*: identical driving noise, one edited force law.

python examples/central_forces.py
"""
import numpy as np

import blueberry_circus as bc
from blueberry_circus.dynamics import Particle, integrate
from blueberry_circus.conservation import (
energy_conservation_certificate, angular_momentum_conservation_certificate,
)

U = bc.Units.scaled(gamma_over_omega0=0.05, omega0=1.0)
P = Particle(U.charge, U.mass)

POTENTIALS = {
"PowerLaw(p=-1, Kepler)": bc.potentials.PowerLaw(coeff=-0.7, p=-1.0, softening=1e-3),
"PowerLaw(p=2.5)": bc.potentials.PowerLaw(coeff=0.4, p=2.5),
"Yukawa": bc.potentials.Yukawa(g=0.8, lam=3.0, softening=1e-3),
"Morse": bc.potentials.Morse(De=3.0, a=0.8, re=1.0),
"Anharmonic(Duffing)": bc.potentials.AnharmonicOscillator(omega0=1.0, beta=0.3),
"LennardJones": bc.potentials.LennardJones(eps=1.0, sigma=0.6),
}


def orbit(pot, r0=1.0, tmax=120.0, dt=0.005):
Fmag = np.linalg.norm(pot.force([r0, 0, 0]))
v = np.sqrt(Fmag * r0 / P.mass)
t = np.arange(0.0, tmax, dt)
return integrate(field=None, potential=pot, particle=P, t_grid=t,
x0=[r0, 0, 0], v0=[0, v, 0], rr="none", units=U, dipole=False)


def main():
print(f"{'potential':24s} {'E drift':>12s} {'|L| drift':>12s} verdicts")
for name, pot in POTENTIALS.items():
tr = orbit(pot)
e = energy_conservation_certificate(tr, pot, P, tol=1e-6)
l = angular_momentum_conservation_certificate(tr, P, tol=1e-8)
print(f"{name:24s} {e.residual:12.2e} {l.residual:12.2e} "
f"[E:{e.recheck()}] [L:{l.recheck()}]")

# matched-seed counterfactual teaser: same ZPF seed, two force laws.
print("\nmatched-seed counterfactual (same noise, edited law):")
for law in (bc.Harmonic(omega0=1.0), bc.Anharmonic(omega0=1.0, beta=0.4)):
prog = bc.Program(n_particles=1, units=U)
with prog.context as q:
law | q[0]
bc.ZPF(band=(0.3, 3.0), n_modes=64, seed=7,
mode="one_dimensional", axis=0) | q[0]
bc.RadiationReaction("landau_lifshitz") | q[0]
res = bc.Engine(dt=0.02, t_max=60.0).run(prog, x0=[0.1, 0, 0], v0=[0, 0, 0])
print(f" {type(law).__name__:12s} <x^2>={res.observables['position_variance'][0]:.4e}")


if __name__ == "__main__":
main()
18 changes: 12 additions & 6 deletions src/blueberry_circus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
ALPHA, A0, radiation_reaction_time,
setterfield_rescale)
from . import (spectrum, oracles, observables, potentials, symplectic,
rectification, tournament)
rectification, tournament, conservation)
from .spectrum import rho, spectral_density_Ex, mode_density, mode_energy
from .zpf import ZPFBackground
from .dynamics import Particle, Trajectory, integrate
from .certify import Certificate, RULES, PASS, FAIL, NULL, audit_overclaim, \
save_bundle, load_bundle
from .program import (Program, Harmonic, Coulomb, ZPF, RadiationReaction,
from .program import (Program, Harmonic, Coulomb, PowerLaw, Yukawa, Morse,
Anharmonic, LennardJones, ZPF, RadiationReaction,
Operation)
from .conservation import (energy_conservation_certificate,
angular_momentum_conservation_certificate)
from .engine import Engine, Result
from .tournament import (OrbitState, EnergyLedger, TournamentConfig,
HypothesisResult)
Expand All @@ -31,11 +34,14 @@
"Units", "SI", "BOHR", "EPS0", "HBAR", "C", "E_CHARGE", "M_E", "K_E",
"ALPHA", "A0", "radiation_reaction_time", "setterfield_rescale",
"spectrum", "oracles", "observables", "potentials", "symplectic",
"rectification", "tournament", "rho", "spectral_density_Ex", "mode_density",
"mode_energy",
"rectification", "tournament", "conservation", "rho", "spectral_density_Ex",
"mode_density", "mode_energy",
"ZPFBackground", "Particle", "Trajectory", "integrate", "Certificate",
"RULES", "PASS", "FAIL", "NULL", "audit_overclaim", "save_bundle",
"load_bundle", "Program", "Harmonic", "Coulomb", "ZPF", "RadiationReaction",
"Operation", "Engine", "Result", "OrbitState", "EnergyLedger",
"load_bundle", "Program", "Harmonic", "Coulomb", "PowerLaw", "Yukawa",
"Morse", "Anharmonic", "LennardJones", "ZPF", "RadiationReaction",
"Operation", "energy_conservation_certificate",
"angular_momentum_conservation_certificate",
"Engine", "Result", "OrbitState", "EnergyLedger",
"TournamentConfig", "HypothesisResult", "__version__",
]
73 changes: 73 additions & 0 deletions src/blueberry_circus/conservation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Conservation-law certificates for closed, conservative systems.

A closed central-force system integrated with ``rr="none"`` and no external
field must conserve mechanical energy ``E = 1/2 m v^2 + U(r)`` and, because the
force is central, the angular-momentum magnitude ``|L| = |m x cross v|``. These
are re-checkable :class:`~blueberry_circus.certify.Certificate` claims in exactly
the repo's assurance style: the residual is the fractional drift of the quantity
over the window, encoded against the canonical ``residual_le_tol`` rule, so a
tampered trajectory (or an under-resolved integration) re-derives ``FAIL``.

They are the correctness backbone for the central-force potential family: any new
conservative potential can self-certify that its analytic force is consistent
with its potential energy, purely from the integrated dynamics.
"""
from __future__ import annotations

import numpy as np

from .certify import Certificate, finalize
from .observables import total_energy, angular_momentum

_METHOD = "blueberry_circus closed-system RK4 conservation check"


def _fractional_drift(series) -> float:
"""(max - min) / |mean| -- the fractional peak-to-peak drift of a conserved
quantity. Returns a finite over-threshold sentinel if the series is not all
finite, so the certificate stays canonicalizable and re-derives FAIL."""
series = np.asarray(series, float)
if not np.all(np.isfinite(series)):
return np.inf
mean = series.mean()
denom = abs(mean) if mean != 0.0 else 1.0
return float((series.max() - series.min()) / denom)


def _residual_certificate(kind, claim, value, residual, tolerance, provenance):
# Keep the hash surface clean: a non-finite drift is recorded as a finite
# FAIL sentinel and the value is dropped to None (mirrors rel_error_certificate).
if not np.isfinite(residual):
residual = abs(float(tolerance)) * 2.0 + 1.0
value = None
cert = Certificate(
kind=kind, claim=claim, method=_METHOD, rule="residual_le_tol",
value=(float(value) if value is not None else None),
residual=float(residual), tolerance=float(tolerance),
provenance=provenance)
return finalize(cert)


def energy_conservation_certificate(traj, potential, particle, tol=1e-6):
"""Certify that mechanical energy drifts by less than ``tol`` (fractional)."""
E = total_energy(traj, potential, particle)
return _residual_certificate(
kind="energy_conservation",
claim="mechanical energy is conserved along the trajectory",
value=(np.mean(E) if np.all(np.isfinite(E)) else None),
residual=_fractional_drift(E), tolerance=tol,
provenance={"n_steps": int(len(traj.t)),
"quantity": "E_peak_to_peak_over_mean"})


def angular_momentum_conservation_certificate(traj, particle, tol=1e-6):
"""Certify that ``|L|`` drifts by less than ``tol`` (fractional) -- the
signature of a central force."""
Lmag = np.linalg.norm(angular_momentum(traj, particle), axis=1)
return _residual_certificate(
kind="angular_momentum_conservation",
claim="angular-momentum magnitude is conserved (central force)",
value=(np.mean(Lmag) if np.all(np.isfinite(Lmag)) else None),
residual=_fractional_drift(Lmag), tolerance=tol,
provenance={"n_steps": int(len(traj.t)),
"quantity": "absL_peak_to_peak_over_mean"})
Loading
Loading