Source code for bioniumx.molecules.disequilibrium

"""
Chemical disequilibrium biosignature detection.

A single molecule is rarely a biosignature. The simultaneous presence
of O₂ + CH₄ (or other highly reactive pairs) indicates an atmosphere
maintained out of equilibrium — the strongest abiotic-defying signature.
"""
import numpy as np
from dataclasses import dataclass, field
from typing import Dict, Optional


DISEQUILIBRIUM_PAIRS = {
    # (mol_A, mol_B): (timescale_years, description, reference)
    ("O2", "CH4"): (
        1e-3,
        "Photosynthesis + Methanogenesis disequilibrium (Earth analog)",
        "Krissansen-Totton et al. 2018"
    ),
    ("O2", "CO"): (
        1e2,
        "Incomplete combustion / photochemical disequilibrium",
        "Schwieterman et al. 2019"
    ),
    ("N2O", "CH4"): (
        1e1,
        "Denitrification disequilibrium",
        "Seager et al. 2013"
    ),
    ("PH3", "O2"): (
        1e-6,
        "Phosphine in oxidizing atmosphere (Venus controversy)",
        "Greaves et al. 2020"
    ),
}


@dataclass
class DisequilibriumResult:
    """
    Result of a chemical disequilibrium analysis.

    Attributes
    ----------
    detected_pairs : list of tuple
        Molecule pairs simultaneously detected above threshold.
    disequilibrium_score : float
        Composite score 0–1. Higher → stronger disequilibrium signature.
    confidence_intervals : dict
        Per-molecule 1-sigma detection confidence.
    flags : list of str
        Human-readable description of detected signals.
    references : list of str
        Scientific references for each flagged pair.
    """
    detected_pairs: list = field(default_factory=list)
    disequilibrium_score: float = 0.0
    confidence_intervals: Dict[str, tuple] = field(default_factory=dict)
    flags: list = field(default_factory=list)
    references: list = field(default_factory=list)

    def is_significant(self, threshold: float = 0.5) -> bool:
        """Return True if disequilibrium_score exceeds threshold."""
        return self.disequilibrium_score > threshold


[docs] def compute_disequilibrium( molecule_detections: Dict[str, float], detection_threshold: float = 2.0, ) -> DisequilibriumResult: """ Evaluate chemical disequilibrium from per-molecule significance values. Parameters ---------- molecule_detections : dict Mapping of molecule name → detection significance in σ. Example: {"O2": 3.2, "CH4": 2.8, "H2O": 5.1} detection_threshold : float, optional Minimum σ to consider a molecule "detected". Default 2.0. Returns ------- result : DisequilibriumResult Examples -------- >>> sigs = {"O2": 3.5, "CH4": 2.9, "H2O": 6.0, "CO2": 4.1} >>> result = compute_disequilibrium(sigs) >>> print(result.disequilibrium_score) # e.g. 0.78 >>> result.is_significant() # True """ result = DisequilibriumResult() detected = {m for m, sig in molecule_detections.items() if sig >= detection_threshold} for (mol_a, mol_b), (timescale, desc, ref) in DISEQUILIBRIUM_PAIRS.items(): if mol_a in detected and mol_b in detected: result.detected_pairs.append((mol_a, mol_b)) result.flags.append(desc) result.references.append(ref) # Score: weighted by inverse chemical lifetime (faster destruction = stronger signal) if result.detected_pairs: pair_scores = [] for (mol_a, mol_b) in result.detected_pairs: ts = DISEQUILIBRIUM_PAIRS[(mol_a, mol_b)][0] sig_a = molecule_detections.get(mol_a, 0.0) sig_b = molecule_detections.get(mol_b, 0.0) # Geometric mean of significances, weighted by chemical urgency (shorter ts = higher weight) weight = (10.0 - np.log10(max(ts, 1e-9))) / 5.0 pair_score = (sig_a * sig_b) ** 0.5 * weight pair_scores.append(pair_score) # Normalize to [0, 1] raw_score = np.tanh(np.mean(pair_scores) / 5.0) result.disequilibrium_score = float(raw_score) return result