Skip to content

Chemistry

The chemistry module manages nuclear isotope representations and abundance sets. It is used by ChemistryConfig to hold and scale stellar compositions before they are written into material cards.

Overview

from keppy.chemistry import Ion, Composition, CompositionPresets

# Build a composition from scratch
comp = Composition()
comp.add_from_string("h1",  0.70)
comp.add_from_string("he4", 0.28)
comp.add_from_string("c12", 0.01)
comp.add_from_string("o16", 0.01)
comp.normalize()

print(comp.mu)           # mean molecular weight
print(comp.metallicity)  # metal fraction Z

Ion

Ion pydantic-model

Bases: BaseModel

Represents a nuclear species with its abundance

Config:

  • populate_by_name: True

Fields:

Validators:

Source code in src/keppy/chemistry/ion.py
class Ion(BaseModel):
    """Represents a nuclear species with its abundance"""

    # periodic table data as classvar
    # symb : atm n (goes upto elem 118 (Og))
    ELEMENT_DATA: ClassVar[Dict[str, int]] = {
        x.symbol.lower(): x.number for x in pt.elements
    }

    symbol: str = Field(description="Element symbol (lowercase)")
    atomic_number: int = Field(description="Proton number Z", alias="Z")
    mass_number: int = Field(description="Nucleon number A", alias="A")
    abundance: float = Field(
        default=0.0, ge=0.0, le=1.0, description="Mass fraction X_i"
    )

    model_config = ConfigDict(populate_by_name=True)

    @property
    def Z(self) -> int:
        return self.atomic_number

    @property
    def A(self) -> int:
        return self.mass_number

    @field_validator("symbol")
    @classmethod
    def validate_symbol(cls, v: str) -> str:
        """Ensure symbol is lowercase and known"""
        v = v.lower()
        if v not in cls.ELEMENT_DATA:
            raise ValueError(f"Unknown element symbol: {v}")
        return v

    @field_validator("atomic_number")
    @classmethod
    def validate_Z(cls, v: int, info) -> int:
        """Validate Z matches symbol"""
        symbol = info.data.get("symbol")
        if symbol and v != cls.ELEMENT_DATA[symbol]:
            raise ValueError(
                f"Atomic number {v} doesn't match symbol '{symbol}' (expected {cls.ELEMENT_DATA[symbol]})"
            )
        return v

    @classmethod
    def from_string(cls, ion_string: str, abundance: float = 0.0) -> "Ion":
        """
        Parse ion from string format: 'h1', 'he4', 'c12', 'fe56', etc.

        Examples:
            Ion.from_string('h1', 0.70)
            Ion.from_string('he4', 0.28)
            Ion.from_string('c12', 0.001)
        """
        # Match pattern: letters followed by digits
        match = re.match(r"^([a-zA-Z]+)(\d+)$", ion_string.strip())
        if not match:
            raise ValueError(
                f"Invalid ion string format: '{ion_string}'. Expected format: 'h1', 'he4', 'c12', etc."
            )

        symbol = match.group(1).lower()
        mass_number = int(match.group(2))

        if symbol not in cls.ELEMENT_DATA:
            raise ValueError(f"Unknown element: {symbol}")

        atomic_number = cls.ELEMENT_DATA[symbol]

        return cls(
            symbol=symbol,
            atomic_number=atomic_number,
            mass_number=mass_number,
            abundance=abundance,
        )

    def to_string(self) -> str:
        """Convert to string format: 'h1', 'he4', etc."""
        return f"{self.symbol}{self.mass_number}"

    def to_latex(self) -> str:
        """Convert to pretty LaTeX format: '^1', 'he4', etc."""
        return rf"$^{{{self.mass_number}}}\mathrm{{{self.symbol.capitalize()}}}$"

    @property
    def neutron_number(self) -> int:
        """Number of neutrons N = A - Z"""
        return self.mass_number - self.atomic_number

    def __str__(self) -> str:
        return f"{self.to_string()} (X={self.abundance:.6f})"

    def __repr__(self) -> str:
        return f"Ion('{self.to_string()}', Z={self.atomic_number}, A={self.mass_number}, X={self.abundance})"

symbol pydantic-field

symbol: str

Element symbol (lowercase)

atomic_number pydantic-field

atomic_number: int

Proton number Z

mass_number pydantic-field

mass_number: int

Nucleon number A

abundance pydantic-field

abundance: float = 0.0

Mass fraction X_i

neutron_number property

neutron_number: int

Number of neutrons N = A - Z

validate_symbol pydantic-validator

validate_symbol(v: str) -> str

Ensure symbol is lowercase and known

Source code in src/keppy/chemistry/ion.py
@field_validator("symbol")
@classmethod
def validate_symbol(cls, v: str) -> str:
    """Ensure symbol is lowercase and known"""
    v = v.lower()
    if v not in cls.ELEMENT_DATA:
        raise ValueError(f"Unknown element symbol: {v}")
    return v

validate_Z pydantic-validator

validate_Z(v: int, info) -> int

Validate Z matches symbol

Source code in src/keppy/chemistry/ion.py
@field_validator("atomic_number")
@classmethod
def validate_Z(cls, v: int, info) -> int:
    """Validate Z matches symbol"""
    symbol = info.data.get("symbol")
    if symbol and v != cls.ELEMENT_DATA[symbol]:
        raise ValueError(
            f"Atomic number {v} doesn't match symbol '{symbol}' (expected {cls.ELEMENT_DATA[symbol]})"
        )
    return v

from_string classmethod

from_string(ion_string: str, abundance: float = 0.0) -> Ion

Parse ion from string format: 'h1', 'he4', 'c12', 'fe56', etc.

Examples:

Ion.from_string('h1', 0.70) Ion.from_string('he4', 0.28) Ion.from_string('c12', 0.001)

Source code in src/keppy/chemistry/ion.py
@classmethod
def from_string(cls, ion_string: str, abundance: float = 0.0) -> "Ion":
    """
    Parse ion from string format: 'h1', 'he4', 'c12', 'fe56', etc.

    Examples:
        Ion.from_string('h1', 0.70)
        Ion.from_string('he4', 0.28)
        Ion.from_string('c12', 0.001)
    """
    # Match pattern: letters followed by digits
    match = re.match(r"^([a-zA-Z]+)(\d+)$", ion_string.strip())
    if not match:
        raise ValueError(
            f"Invalid ion string format: '{ion_string}'. Expected format: 'h1', 'he4', 'c12', etc."
        )

    symbol = match.group(1).lower()
    mass_number = int(match.group(2))

    if symbol not in cls.ELEMENT_DATA:
        raise ValueError(f"Unknown element: {symbol}")

    atomic_number = cls.ELEMENT_DATA[symbol]

    return cls(
        symbol=symbol,
        atomic_number=atomic_number,
        mass_number=mass_number,
        abundance=abundance,
    )

to_string

to_string() -> str

Convert to string format: 'h1', 'he4', etc.

Source code in src/keppy/chemistry/ion.py
def to_string(self) -> str:
    """Convert to string format: 'h1', 'he4', etc."""
    return f"{self.symbol}{self.mass_number}"

to_latex

to_latex() -> str

Convert to pretty LaTeX format: '^1', 'he4', etc.

Source code in src/keppy/chemistry/ion.py
def to_latex(self) -> str:
    """Convert to pretty LaTeX format: '^1', 'he4', etc."""
    return rf"$^{{{self.mass_number}}}\mathrm{{{self.symbol.capitalize()}}}$"

Composition

Composition pydantic-model

Bases: BaseModel

Chemical composition, for use with materials

Fields:

Source code in src/keppy/chemistry/composition.py
class Composition(BaseModel):
    """Chemical composition, for use with materials"""

    ions: dict[str, Ion] = Field(
        default_factory=dict,
        description="dictionary of ions keyed by string representation",
    )

    def add_ion(self, ion: Ion) -> None:
        """Add an ion to the composition"""
        key = ion.to_string()
        self.ions[key] = ion

    def add_from_string(self, ion_string: str, abundance: float) -> None:
        """Add ion from string format"""
        ion = Ion.from_string(ion_string, abundance)
        self.add_ion(ion)

    def get_ion(self, ion_string: str) -> Optional[Ion]:
        """Retrieve ion by string representation"""
        return self.ions.get(ion_string)

    @property
    def total_abundance(self) -> float:
        """Sum of all mass fractions (should be ≤ 1.0)"""
        return sum(ion.abundance for ion in self.ions.values())

    def normalize(self) -> None:
        """Normalize all abundances to sum to 1.0"""
        total = self.total_abundance
        if total > 0:
            for ion in self.ions.values():
                ion.abundance /= total

    def get_by_element(self, symbol: str) -> list[Ion]:
        """Get all isotopes of a given element"""
        symbol = symbol.lower()
        return [ion for ion in self.ions.values() if ion.symbol == symbol]

    def to_dict(self) -> dict[str, float]:
        """Convert to simple dict: {'h1': 0.70, 'he4': 0.28, ...}"""
        return {ion.to_string(): ion.abundance for ion in self.ions.values()}

    @classmethod
    def from_dict(cls, abundances: dict[str, float]) -> "Composition":
        """Create composition from dict: {'h1': 0.70, 'he4': 0.28, ...}"""
        comp = cls()
        for ion_string, abundance in abundances.items():
            comp.add_from_string(ion_string, abundance)
        return comp

    @property
    def mu(self) -> float:
        xmu = sum([ion.abundance / ion.A * (ion.Z + 1) for ion in self.ions.values()])
        return 1 / xmu

    @property
    def metallicity(self) -> float:
        Z = sum(
            [
                ion.abundance
                for symb, ion in self.ions.items()
                if symb not in {"h1", "h2", "he3", "he4"}
            ]
        )
        return Z

    def __str__(self) -> str:
        lines = ["Composition:"]
        for ion in sorted(
            self.ions.values(), key=lambda x: (x.atomic_number, x.mass_number)
        ):
            lines.append(f"  {ion}")
        lines.append(f"Total: {self.total_abundance:.6f}")
        return "\n".join(lines)

    # ------------------------------------------------------------------
    # Preset loading
    # ------------------------------------------------------------------

    @classmethod
    def load_kepler_file(cls, preset: str) -> "Composition":
        """Load a composition from a KEPLER preset data file"""
        try:
            path = Path(os.getenv("KEPLER_DATA"))
        except Exception as e:
            raise ValueError("KEPLER_DATA envvar is not set. {} ".format(str(e)))
        data = {}
        with open((path / preset).with_suffix(".dat"), "r") as f:
            for line in f.readlines():
                stripped = line.strip()
                if not stripped or stripped.startswith(";"):
                    continue
                elem, val = stripped.split()
                data[elem] = val
        return cls.from_dict(data)

    @classmethod
    def list_presets(cls) -> list[str]:
        """List all available preset composition names"""
        return [
            "solas09",
            "solas12",
            "solag89",
            "solgn93",
            "sollo03",
            "sollo09",
            "sollo20",
            "sollo25",
            "bbnc19",
            "bbnc16",
            "bbnf02",
            "bbni09",
        ]

    # ------------------------------------------------------------------
    # Metallicity scaling
    # ------------------------------------------------------------------

    def scale_metallicity(
        self, Z_target: float, bbn_preset: str = BBN_LATEST
    ) -> "Composition":
        """
        Scale this composition to a target metallicity.

        Blends between the BBN reference composition (bbn_preset, scale=0) and
        this solar composition (scale=1), with an exponential correction for
        super-solar scaling (scale>1) that keeps He4 as the compensating
        reservoir — matching the algorithm in example_scaling.py.

        Parameters
        ----------
        Z_target:
            Target metal mass fraction.
        bbn_preset:
            KEPLER preset name to use as the BBN (zero-metal) reference.
            Defaults to the most recent BBN abundance set (bbnc19).
        """
        Z_solar = self.metallicity
        if Z_solar == 0:
            raise ValueError("Base composition has no metals to scale")

        scale = Z_target / Z_solar
        bbn = Composition.load_kepler_file(bbn_preset)

        # Linear blend: scaled = solar * scale + bbn * (1 - scale)
        all_keys = set(self.ions.keys()) | set(bbn.ions.keys())
        new_abu: dict[str, float] = {}
        for key in all_keys:
            solar_val = self.ions[key].abundance if key in self.ions else 0.0
            bbn_val = bbn.ions[key].abundance if key in bbn.ions else 0.0
            new_abu[key] = solar_val * scale + bbn_val * (1.0 - scale)

        # Beyond-solar correction: for isotopes that fall below their solar
        # value after linear extrapolation, apply exponential suppression and
        # channel the surplus/deficit through He4.
        if scale > 1.0:
            if "he4" not in new_abu:
                raise ValueError("Composition has no He4 for beyond-solar scaling")

            for key in list(new_abu.keys()):
                if key == "he4":
                    continue
                solar_val = self.ions[key].abundance if key in self.ions else 0.0
                bbn_val = bbn.ions[key].abundance if key in bbn.ions else 0.0

                if new_abu[key] < solar_val and solar_val > 0.0:
                    old_val = new_abu[key]
                    new_val = solar_val * np.exp(
                        (scale - 1.0) * (1.0 - bbn_val / solar_val)
                    )
                    new_abu["he4"] += old_val - new_val
                    new_abu[key] = new_val

        new_comp = Composition()
        for key, abu in new_abu.items():
            if abu > 0.0:
                new_comp.add_from_string(key, abu)

        new_comp.normalize()
        logger.info(
            f"Rescaled composition to Z = {Z_target:.4f} from Z = {Z_solar:.4f}"
        )
        return new_comp

    # ------------------------------------------------------------------
    # Helium scaling
    # ------------------------------------------------------------------

    def scale_helium(self, Y_target: float) -> "Composition":
        """
        Scale the total helium mass fraction to ``Y_target``, compensating
        from hydrogen.

        The mass shift ``Y_target - Y_current`` is taken from (or added to)
        the combined hydrogen mass fraction ``X = H1 + H2``. The H2/H1 and
        He3/He4 isotopic ratios are preserved, and metals are left
        untouched.

        Parameters
        ----------
        Y_target:
            Target total helium mass fraction (He3 + He4).
        """
        if Y_target < 0.0:
            raise ValueError(f"Y_target must be non-negative, got {Y_target}")

        he_keys = ("he3", "he4")
        h_keys = ("h1", "h2")

        Y_current = sum(
            self.ions[k].abundance for k in he_keys if k in self.ions
        )
        X_current = sum(
            self.ions[k].abundance for k in h_keys if k in self.ions
        )

        if Y_current == 0.0:
            raise ValueError(
                "Composition has no He3 or He4; cannot preserve isotopic ratio"
            )
        if X_current == 0.0:
            raise ValueError("Composition has no hydrogen reservoir to draw from")

        delta = Y_target - Y_current
        X_new = X_current - delta
        if X_new < 0.0:
            raise ValueError(
                f"Y_target={Y_target:.4f} requires X={X_new:.4f}; insufficient "
                f"hydrogen (X_current={X_current:.4f}) to compensate"
            )

        he_factor = Y_target / Y_current
        h_factor = X_new / X_current

        new_abu = {key: ion.abundance for key, ion in self.ions.items()}
        for k in he_keys:
            if k in new_abu:
                new_abu[k] *= he_factor
        for k in h_keys:
            if k in new_abu:
                new_abu[k] *= h_factor

        new_comp = Composition()
        for key, abu in new_abu.items():
            if abu > 0.0:
                new_comp.add_from_string(key, abu)

        logger.info(
            f"Rescaled composition to Y = {Y_target:.4f} from Y = {Y_current:.4f} "
            f"(X: {X_current:.4f} -> {X_new:.4f})"
        )
        return new_comp

ions pydantic-field

ions: dict[str, Ion]

dictionary of ions keyed by string representation

total_abundance property

total_abundance: float

Sum of all mass fractions (should be ≤ 1.0)

add_ion

add_ion(ion: Ion) -> None

Add an ion to the composition

Source code in src/keppy/chemistry/composition.py
def add_ion(self, ion: Ion) -> None:
    """Add an ion to the composition"""
    key = ion.to_string()
    self.ions[key] = ion

add_from_string

add_from_string(ion_string: str, abundance: float) -> None

Add ion from string format

Source code in src/keppy/chemistry/composition.py
def add_from_string(self, ion_string: str, abundance: float) -> None:
    """Add ion from string format"""
    ion = Ion.from_string(ion_string, abundance)
    self.add_ion(ion)

get_ion

get_ion(ion_string: str) -> Optional[Ion]

Retrieve ion by string representation

Source code in src/keppy/chemistry/composition.py
def get_ion(self, ion_string: str) -> Optional[Ion]:
    """Retrieve ion by string representation"""
    return self.ions.get(ion_string)

normalize

normalize() -> None

Normalize all abundances to sum to 1.0

Source code in src/keppy/chemistry/composition.py
def normalize(self) -> None:
    """Normalize all abundances to sum to 1.0"""
    total = self.total_abundance
    if total > 0:
        for ion in self.ions.values():
            ion.abundance /= total

get_by_element

get_by_element(symbol: str) -> list[Ion]

Get all isotopes of a given element

Source code in src/keppy/chemistry/composition.py
def get_by_element(self, symbol: str) -> list[Ion]:
    """Get all isotopes of a given element"""
    symbol = symbol.lower()
    return [ion for ion in self.ions.values() if ion.symbol == symbol]

to_dict

to_dict() -> dict[str, float]

Convert to simple dict: {'h1': 0.70, 'he4': 0.28, ...}

Source code in src/keppy/chemistry/composition.py
def to_dict(self) -> dict[str, float]:
    """Convert to simple dict: {'h1': 0.70, 'he4': 0.28, ...}"""
    return {ion.to_string(): ion.abundance for ion in self.ions.values()}

from_dict classmethod

from_dict(abundances: dict[str, float]) -> Composition

Create composition from dict: {'h1': 0.70, 'he4': 0.28, ...}

Source code in src/keppy/chemistry/composition.py
@classmethod
def from_dict(cls, abundances: dict[str, float]) -> "Composition":
    """Create composition from dict: {'h1': 0.70, 'he4': 0.28, ...}"""
    comp = cls()
    for ion_string, abundance in abundances.items():
        comp.add_from_string(ion_string, abundance)
    return comp

load_kepler_file classmethod

load_kepler_file(preset: str) -> Composition

Load a composition from a KEPLER preset data file

Source code in src/keppy/chemistry/composition.py
@classmethod
def load_kepler_file(cls, preset: str) -> "Composition":
    """Load a composition from a KEPLER preset data file"""
    try:
        path = Path(os.getenv("KEPLER_DATA"))
    except Exception as e:
        raise ValueError("KEPLER_DATA envvar is not set. {} ".format(str(e)))
    data = {}
    with open((path / preset).with_suffix(".dat"), "r") as f:
        for line in f.readlines():
            stripped = line.strip()
            if not stripped or stripped.startswith(";"):
                continue
            elem, val = stripped.split()
            data[elem] = val
    return cls.from_dict(data)

list_presets classmethod

list_presets() -> list[str]

List all available preset composition names

Source code in src/keppy/chemistry/composition.py
@classmethod
def list_presets(cls) -> list[str]:
    """List all available preset composition names"""
    return [
        "solas09",
        "solas12",
        "solag89",
        "solgn93",
        "sollo03",
        "sollo09",
        "sollo20",
        "sollo25",
        "bbnc19",
        "bbnc16",
        "bbnf02",
        "bbni09",
    ]

scale_metallicity

scale_metallicity(Z_target: float, bbn_preset: str = BBN_LATEST) -> Composition

Scale this composition to a target metallicity.

Blends between the BBN reference composition (bbn_preset, scale=0) and this solar composition (scale=1), with an exponential correction for super-solar scaling (scale>1) that keeps He4 as the compensating reservoir — matching the algorithm in example_scaling.py.

Parameters

Z_target: Target metal mass fraction. bbn_preset: KEPLER preset name to use as the BBN (zero-metal) reference. Defaults to the most recent BBN abundance set (bbnc19).

Source code in src/keppy/chemistry/composition.py
def scale_metallicity(
    self, Z_target: float, bbn_preset: str = BBN_LATEST
) -> "Composition":
    """
    Scale this composition to a target metallicity.

    Blends between the BBN reference composition (bbn_preset, scale=0) and
    this solar composition (scale=1), with an exponential correction for
    super-solar scaling (scale>1) that keeps He4 as the compensating
    reservoir — matching the algorithm in example_scaling.py.

    Parameters
    ----------
    Z_target:
        Target metal mass fraction.
    bbn_preset:
        KEPLER preset name to use as the BBN (zero-metal) reference.
        Defaults to the most recent BBN abundance set (bbnc19).
    """
    Z_solar = self.metallicity
    if Z_solar == 0:
        raise ValueError("Base composition has no metals to scale")

    scale = Z_target / Z_solar
    bbn = Composition.load_kepler_file(bbn_preset)

    # Linear blend: scaled = solar * scale + bbn * (1 - scale)
    all_keys = set(self.ions.keys()) | set(bbn.ions.keys())
    new_abu: dict[str, float] = {}
    for key in all_keys:
        solar_val = self.ions[key].abundance if key in self.ions else 0.0
        bbn_val = bbn.ions[key].abundance if key in bbn.ions else 0.0
        new_abu[key] = solar_val * scale + bbn_val * (1.0 - scale)

    # Beyond-solar correction: for isotopes that fall below their solar
    # value after linear extrapolation, apply exponential suppression and
    # channel the surplus/deficit through He4.
    if scale > 1.0:
        if "he4" not in new_abu:
            raise ValueError("Composition has no He4 for beyond-solar scaling")

        for key in list(new_abu.keys()):
            if key == "he4":
                continue
            solar_val = self.ions[key].abundance if key in self.ions else 0.0
            bbn_val = bbn.ions[key].abundance if key in bbn.ions else 0.0

            if new_abu[key] < solar_val and solar_val > 0.0:
                old_val = new_abu[key]
                new_val = solar_val * np.exp(
                    (scale - 1.0) * (1.0 - bbn_val / solar_val)
                )
                new_abu["he4"] += old_val - new_val
                new_abu[key] = new_val

    new_comp = Composition()
    for key, abu in new_abu.items():
        if abu > 0.0:
            new_comp.add_from_string(key, abu)

    new_comp.normalize()
    logger.info(
        f"Rescaled composition to Z = {Z_target:.4f} from Z = {Z_solar:.4f}"
    )
    return new_comp

scale_helium

scale_helium(Y_target: float) -> Composition

Scale the total helium mass fraction to Y_target, compensating from hydrogen.

The mass shift Y_target - Y_current is taken from (or added to) the combined hydrogen mass fraction X = H1 + H2. The H2/H1 and He3/He4 isotopic ratios are preserved, and metals are left untouched.

Parameters

Y_target: Target total helium mass fraction (He3 + He4).

Source code in src/keppy/chemistry/composition.py
def scale_helium(self, Y_target: float) -> "Composition":
    """
    Scale the total helium mass fraction to ``Y_target``, compensating
    from hydrogen.

    The mass shift ``Y_target - Y_current`` is taken from (or added to)
    the combined hydrogen mass fraction ``X = H1 + H2``. The H2/H1 and
    He3/He4 isotopic ratios are preserved, and metals are left
    untouched.

    Parameters
    ----------
    Y_target:
        Target total helium mass fraction (He3 + He4).
    """
    if Y_target < 0.0:
        raise ValueError(f"Y_target must be non-negative, got {Y_target}")

    he_keys = ("he3", "he4")
    h_keys = ("h1", "h2")

    Y_current = sum(
        self.ions[k].abundance for k in he_keys if k in self.ions
    )
    X_current = sum(
        self.ions[k].abundance for k in h_keys if k in self.ions
    )

    if Y_current == 0.0:
        raise ValueError(
            "Composition has no He3 or He4; cannot preserve isotopic ratio"
        )
    if X_current == 0.0:
        raise ValueError("Composition has no hydrogen reservoir to draw from")

    delta = Y_target - Y_current
    X_new = X_current - delta
    if X_new < 0.0:
        raise ValueError(
            f"Y_target={Y_target:.4f} requires X={X_new:.4f}; insufficient "
            f"hydrogen (X_current={X_current:.4f}) to compensate"
        )

    he_factor = Y_target / Y_current
    h_factor = X_new / X_current

    new_abu = {key: ion.abundance for key, ion in self.ions.items()}
    for k in he_keys:
        if k in new_abu:
            new_abu[k] *= he_factor
    for k in h_keys:
        if k in new_abu:
            new_abu[k] *= h_factor

    new_comp = Composition()
    for key, abu in new_abu.items():
        if abu > 0.0:
            new_comp.add_from_string(key, abu)

    logger.info(
        f"Rescaled composition to Y = {Y_target:.4f} from Y = {Y_current:.4f} "
        f"(X: {X_current:.4f} -> {X_new:.4f})"
    )
    return new_comp