Source code for stellar_geology.conversions

"""
Chemistry conversion functions imported by planet and star
"""

from . import constants as const

__all__ = [
    'VALID_UNITS',
    'calculate_bulk_planet_from_dex',
    'calculate_dex_from_bulk_planet',
    'convert_composition',
    'convert_to_wtpt_oxides',
    'normalize_composition',
    # Legacy public functions (used in reverse conversions)
    'wtpt_oxides_to_mol_oxides',
    'wtpt_oxides_to_mol_cations',
    'wtpt_oxides_to_mol_singleO',
    'mol_oxides_to_wtpt_oxides',
    'mol_cations_to_wtpt_oxides',
    'mol_oxides_to_mol_cations',
    'mol_cations_to_mol_oxides',
]

[docs] def calculate_bulk_planet_from_dex(stellar_dex: dict[str, float]) -> dict[str, float]: """ Runs all intermediate calculations to go directly from star composition in dex notation to bulk planet in wt% oxides. This is the most common use case, but allows the other intermediate methods to be exposed for benchmarking and debugging. Parameters ---------- stellar_dex: dict[str, float] Star composition in dex notation. Returns ------- dict[str, float] Bulk planet composition in wt% oxides. """ ax = calculate_ax_from_dex(stellar_dex) atoms_ref_solar = calculate_atoms_ref_solar_from_ax(ax) total_wt_atoms = calculate_total_wt_atoms_from_atoms_ref_solar(atoms_ref_solar) wtpt_elements = calculate_wtpt_elements_from_total_wt_atoms(total_wt_atoms) wtpt_oxides = calculate_wtpt_oxides_from_wtpt_elements(wtpt_elements) return wtpt_oxides
#--- INTERMEDIATE CALCULATIONS BETWEEN DEX NOTATION AND BULK PLANET OXIDES ---# def calculate_ax_from_dex(stellar_dex: dict[str, float]) -> dict[str, float]: """ Convert from dex system notation to elemental ratio relative to solar Parameters ---------- stellar_dex : dict[str, float] Stellar composition in dex notation. Returns ------- dict[str, float] Elemental ratios relative to solar (10^dex). """ ax = {} dex_elems = list(stellar_dex.keys()) for el in dex_elems: if stellar_dex[el] != 0: ax[el] = 10**stellar_dex[el] else: ax[el] = 0 return ax def calculate_atoms_ref_solar_from_ax(ax: dict[str, float]) -> dict[str, float]: """Convert elemental ratios to atom counts referenced to solar abundances. Parameters ---------- ax : dict[str, float] Elemental ratios relative to solar. Returns ------- dict[str, float] Atom counts referenced to solar abundances. """ atoms_ref_solar = {} ax_elems = list(ax.keys()) for el in ax_elems: atoms_ref_solar[el] = ax[el] * 10**const.A_El[el] return atoms_ref_solar def calculate_total_wt_atoms_from_atoms_ref_solar(atoms_ref_solar: dict[str, float]) -> dict[str, float]: """Convert solar-referenced atom counts to total weight of atoms. Parameters ---------- atoms_ref_solar : dict[str, float] Atom counts referenced to solar abundances. Returns ------- dict[str, float] Total weight of atoms (element count * atomic mass). """ total_wt_atoms = {} atoms_ref_solar_elems = list(atoms_ref_solar.keys()) for el in atoms_ref_solar_elems: total_wt_atoms[el] = atoms_ref_solar[el] * const.cationMass[el] return total_wt_atoms def calculate_wtpt_elements_from_total_wt_atoms(total_wt_atoms: dict[str, float]) -> dict[str, float]: """Convert total weight of atoms to wt% elements. Parameters ---------- total_wt_atoms : dict[str, float] Total weight of atoms. Returns ------- dict[str, float] Composition in wt% elements, normalized to sum to 100. """ total_wt_atoms_sum = sum(total_wt_atoms.values()) wtpt_elements = {} total_wt_atoms_elems = list(total_wt_atoms.keys()) for el in total_wt_atoms_elems: wtpt_elements[el] = 100 * total_wt_atoms[el]/total_wt_atoms_sum return wtpt_elements def calculate_wtpt_oxides_from_wtpt_elements(wtpt_elements: dict[str, float]) -> dict[str, float]: """Convert wt% elements to wt% oxides (volatile-free, normalized to 100). Parameters ---------- wtpt_elements : dict[str, float] Composition in wt% elements. Returns ------- dict[str, float] Composition in wt% oxides, normalized to sum to 100. """ wtpt_oxides = {} volatile_free_elems = list(const.elements_to_oxides.keys()) for el in volatile_free_elems: if el not in wtpt_elements: continue ox = const.elements_to_oxides[el] conversion_factor = const.oxideMass[ox]/(const.cationMass[el]*const.CationNum[ox]) wtpt_oxides[ox] = wtpt_elements[el]*conversion_factor wtpt_oxides_sum = sum(wtpt_oxides.values()) wtpt_oxides = {k: 100*v/wtpt_oxides_sum for k, v in wtpt_oxides.items()} return wtpt_oxides #--- REVERSE PIPELINE: BULK PLANET OXIDES TO DEX ---# # These functions invert the forward pipeline above. Each one reverses a single # step, mirroring the forward function naming convention. # # IMPORTANT CAVEAT: the forward step calculate_wtpt_elements_from_total_wt_atoms # normalizes to 100%, discarding the absolute total mass. This means the full # round-trip dex → oxides → dex recovers dex values that are correct *relative # to each other* but shifted by a constant offset. The interelemental ratios # (which determine mineralogy, BSP, etc.) are perfectly preserved. The absolute # dex offset is irrecoverable without external calibration. This is documented # in detail in CAVEATS.md. import math def calculate_wtpt_elements_from_wtpt_oxides(wtpt_oxides: dict[str, float]) -> dict[str, float]: """Convert wt% oxides to wt% elements (volatile-free, normalized to 100). Reverse of :func:`calculate_wtpt_oxides_from_wtpt_elements`. Uses the oxide-to-element mass conversion and renormalizes. Note that volatile elements (C, O, S) cannot be recovered from oxide data — only rock-forming elements are returned. This is a one-way simplucation inherent to the oxide representation. Parameters ---------- wtpt_oxides : dict[str, float] Composition in wt% oxides. Returns ------- dict[str, float] Composition in wt% elements (volatile-free), normalized to sum to 100. """ raw = _wt_oxides_to_wt_elements(wtpt_oxides) raw_sum = sum(raw.values()) if raw_sum == 0: return dict(raw) return {k: 100.0 * v / raw_sum for k, v in raw.items()} def calculate_total_wt_atoms_from_wtpt_elements(wtpt_elements: dict[str, float]) -> dict[str, float]: """Convert wt% elements to total weight of atoms (proportional). Reverse of :func:`calculate_wtpt_elements_from_total_wt_atoms`. The forward direction normalizes total_wt_atoms to sum to 100 (wt%), which discards the absolute mass scale. Going backward, the absolute scale is irrecoverable, but the *ratios* between elements are fully preserved — and ratios are all that downstream steps (atoms_ref_solar, ax, dex) need. In practice, this function returns the input values unchanged (identity mapping). It exists for API symmetry with the forward pipeline and to document the normalization caveat explicitly. Parameters ---------- wtpt_elements : dict[str, float] Composition in wt% elements. Returns ------- dict[str, float] Total weight of atoms (proportional — absolute scale unknown). """ return dict(wtpt_elements) def calculate_atoms_ref_solar_from_total_wt_atoms(total_wt_atoms: dict[str, float]) -> dict[str, float]: """Convert total weight of atoms to atom counts referenced to solar abundances. Reverse of :func:`calculate_total_wt_atoms_from_atoms_ref_solar`. Divides each element's weight by its atomic mass (cationMass). Parameters ---------- total_wt_atoms : dict[str, float] Total weight of atoms. Returns ------- dict[str, float] Atom counts referenced to solar abundances. """ return {el: wt / const.cationMass[el] for el, wt in total_wt_atoms.items()} def calculate_ax_from_atoms_ref_solar(atoms_ref_solar: dict[str, float]) -> dict[str, float]: """Convert atom counts referenced to solar abundances to elemental ratios (ax). Reverse of :func:`calculate_atoms_ref_solar_from_ax`. Divides each element's atom count by 10^A_El to remove the solar reference scaling. Parameters ---------- atoms_ref_solar : dict[str, float] Atom counts referenced to solar abundances. Returns ------- dict[str, float] Elemental ratios relative to solar. """ return {el: ars / (10 ** const.A_El[el]) for el, ars in atoms_ref_solar.items()} def calculate_dex_from_ax(ax: dict[str, float]) -> dict[str, float]: """Convert elemental ratios (ax) to dex notation. Reverse of :func:`calculate_ax_from_dex`. Takes log10 of each ratio. Elements with ax <= 0 are skipped with a warning, since log10 is undefined for non-positive values. Parameters ---------- ax : dict[str, float] Elemental ratios relative to solar (must be > 0). Returns ------- dict[str, float] Stellar composition in dex notation. """ import warnings as w dex = {} for el, val in ax.items(): if val <= 0: w.warn(f"ax['{el}'] = {val} is non-positive; cannot compute log10. " "Skipping this element.", category=UserWarning) continue dex[el] = math.log10(val) return dex
[docs] def calculate_dex_from_bulk_planet(wtpt_oxides: dict[str, float]) -> dict[str, float]: """Run the full reverse pipeline from bulk planet wt% oxides to dex notation. Counterpart to :func:`calculate_bulk_planet_from_dex`. Chains all intermediate reverse steps: wtpt_oxides → wtpt_elements → total_wt_atoms → atoms_ref_solar → ax → dex. The recovered dex values preserve interelemental ratios perfectly but are shifted by a constant offset relative to the original dex values (see CAVEATS.md for details). Volatile elements (C, O, S) present in the original stellar dex are not recoverable from oxide data. Parameters ---------- wtpt_oxides : dict[str, float] Bulk planet composition in wt% oxides. Returns ------- dict[str, float] Stellar composition in dex notation (relative values). """ wtpt_elements = calculate_wtpt_elements_from_wtpt_oxides(wtpt_oxides) total_wt_atoms = calculate_total_wt_atoms_from_wtpt_elements(wtpt_elements) atoms_ref_solar = calculate_atoms_ref_solar_from_total_wt_atoms(total_wt_atoms) ax = calculate_ax_from_atoms_ref_solar(atoms_ref_solar) dex = calculate_dex_from_ax(ax) return dex
#--- COMPOSABLE UNIT CONVERSION SYSTEM ---# # Valid unit strings for convert_composition VALID_UNITS: list[str] = [ 'wtpt_oxides', 'wtpt_elements', 'wtfrac_oxides', 'wtfrac_elements', 'molfrac_oxides', 'molfrac_elements', 'molfrac_singleO', 'molpt_oxides', 'molpt_elements', ] def _wt_to_mol_oxides(wtpt_oxides: dict[str, float]) -> dict[str, float]: """Compute raw numerators: wtpt / oxideMass for each oxide. Dividing by the sum of the result yields mole fractions. """ return {oxide: wtpt / const.oxideMass[oxide] for oxide, wtpt in wtpt_oxides.items()} def _wt_to_mol_elements(wtpt_oxides: dict[str, float]) -> dict[str, float]: """Compute raw numerators: CationNum * wtpt / oxideMass for each element. Dividing by the sum of the result yields mole fractions of cations. """ raw = {} for oxide, wtpt in wtpt_oxides.items(): element = const.oxides_to_elements[oxide] raw[element] = const.CationNum[oxide] * wtpt / const.oxideMass[oxide] return raw def _wt_oxides_to_wt_elements(wtpt_oxides: dict[str, float]) -> dict[str, float]: """Compute raw numerators: wtpt * cationMass * CationNum / oxideMass. Dividing by the sum and multiplying by 100 yields wt% elements. """ raw = {} for oxide, wtpt in wtpt_oxides.items(): element = const.oxides_to_elements[oxide] raw[element] = wtpt * const.cationMass[element] * const.CationNum[oxide] / const.oxideMass[oxide] return raw def _wt_to_mol_singleO(wtpt_oxides: dict[str, float]) -> dict[str, float]: """Convert wt% oxides to moles of cations per single oxygen atom. Normalized per oxygen atom, not by sum of cations. """ cation_moles = {} total_O = 0.0 for oxide, wtpt in wtpt_oxides.items(): element = const.oxides_to_elements[oxide] cation_moles[element] = const.CationNum[oxide] * wtpt / const.oxideMass[oxide] total_O += const.OxygenNum[oxide] * wtpt / const.oxideMass[oxide] if total_O == 0: return dict(cation_moles) return {k: v / total_O for k, v in cation_moles.items()}
[docs] def convert_composition(wtpt_oxides: dict[str, float], units: str) -> dict[str, float]: """Convert a composition in wt% oxides to any supported unit system. This is the main dispatcher for all unit conversions. It takes a canonical wt% oxides dict and converts it to the requested units by: 1. Computing raw numerators via the appropriate helper 2. Dividing by the sum to complete the conversion 3. Scaling to the target (100 for percent, 1.0 for fraction) Parameters ---------- wtpt_oxides : dict[str, float] Composition in wt% oxides (oxide keys, values summing to ~100). units : str Target unit string. One of: 'wtpt_oxides', 'wtpt_elements', 'wtfrac_oxides', 'wtfrac_elements', 'molfrac_oxides', 'molfrac_elements', 'molfrac_singleO', 'molpt_oxides', 'molpt_elements'. Returns ------- dict[str, float] Composition in the requested units. Raises ------ ValueError If units is not a recognized unit string. """ if units not in VALID_UNITS: raise ValueError(f"units must be one of {VALID_UNITS}, got '{units}'.") # Special case: molfrac_singleO is normalized per oxygen atom, not by sum if units == 'molfrac_singleO': return _wt_to_mol_singleO(wtpt_oxides) # Determine the raw numerators and target scale raw: dict[str, float] if units in ('wtpt_oxides', 'wtfrac_oxides'): raw = dict(wtpt_oxides) elif units in ('wtpt_elements', 'wtfrac_elements'): raw = _wt_oxides_to_wt_elements(wtpt_oxides) elif units in ('molfrac_oxides', 'molpt_oxides'): raw = _wt_to_mol_oxides(wtpt_oxides) elif units in ('molfrac_elements', 'molpt_elements'): raw = _wt_to_mol_elements(wtpt_oxides) else: raise ValueError(f"Unhandled units: '{units}'") # Divide by sum and scale to target raw_sum = sum(raw.values()) if raw_sum == 0: return dict(raw) if 'pt' in units: target = 100.0 else: target = 1.0 return {k: target * v / raw_sum for k, v in raw.items()}
def _wt_elements_to_wt_oxides(wt_elements: dict[str, float]) -> dict[str, float]: """Convert element weight values to oxide weight values. Reverse of _wt_oxides_to_wt_elements. Multiply each element's weight by oxideMass / (cationMass * CationNum), then normalize to sum to 100. """ raw = {} for element, wt in wt_elements.items(): oxide = const.elements_to_oxides[element] raw[oxide] = wt * const.oxideMass[oxide] / (const.cationMass[element] * const.CationNum[oxide]) raw_sum = sum(raw.values()) if raw_sum == 0: return dict(raw) return {k: 100.0 * v / raw_sum for k, v in raw.items()}
[docs] def convert_to_wtpt_oxides(composition: dict[str, float], from_units: str) -> dict[str, float]: """Convert a composition from any supported unit system back to wt% oxides. This is the inverse of convert_composition(). Takes a dict in any supported unit and returns wt% oxides (oxide keys, values summing to 100). Parameters ---------- composition : dict[str, float] Composition in the units specified by from_units. from_units : str Unit string describing the input. One of the strings in VALID_UNITS. Returns ------- dict[str, float] Composition in wt% oxides, normalized to sum to 100. Raises ------ ValueError If from_units is not a recognized unit string. """ if from_units not in VALID_UNITS: raise ValueError(f"from_units must be one of {VALID_UNITS}, got '{from_units}'.") if from_units == 'wtpt_oxides': return dict(composition) if from_units == 'wtfrac_oxides': return {k: v * 100.0 for k, v in composition.items()} if from_units == 'wtpt_elements': return _wt_elements_to_wt_oxides(composition) if from_units == 'wtfrac_elements': scaled = {k: v * 100.0 for k, v in composition.items()} return _wt_elements_to_wt_oxides(scaled) if from_units == 'molfrac_oxides': return mol_oxides_to_wtpt_oxides(composition) if from_units == 'molfrac_elements': return mol_cations_to_wtpt_oxides(composition) if from_units == 'molpt_oxides': frac = {k: v / 100.0 for k, v in composition.items()} return mol_oxides_to_wtpt_oxides(frac) if from_units == 'molpt_elements': frac = {k: v / 100.0 for k, v in composition.items()} return mol_cations_to_wtpt_oxides(frac) if from_units == 'molfrac_singleO': # Normalize cation ratios to sum to 1.0, then treat as mol_cations total = sum(composition.values()) if total == 0: return {const.elements_to_oxides[k]: 0.0 for k in composition} frac = {k: v / total for k, v in composition.items()} return mol_cations_to_wtpt_oxides(frac) # Should be unreachable — all VALID_UNITS are handled above raise ValueError(f"Unhandled from_units: '{from_units}'")
#--- LEGACY UNIT CONVERSION FUNCTIONS (kept for reverse conversions) ---#
[docs] def wtpt_oxides_to_mol_oxides(wtpt_oxides: dict[str, float]) -> dict[str, float]: """ Convert from wt% oxides to mol fraction oxides. Parameters ---------- wtpt_oxides: dict[str, float] Composition in wt% oxides. Returns ------- dict[str, float] Composition in mol fraction oxides, normalized to sum to 1.0. """ mol_oxides = {} for oxide, wtpt in wtpt_oxides.items(): mol_oxides[oxide] = wtpt / const.oxideMass[oxide] mol_sum = sum(mol_oxides.values()) if mol_sum == 0: return dict(mol_oxides) mol_oxides = {k: v / mol_sum for k, v in mol_oxides.items()} return mol_oxides
[docs] def wtpt_oxides_to_mol_cations(wtpt_oxides: dict[str, float]) -> dict[str, float]: """ Convert from wt% oxides to mol fraction cations. Parameters ---------- wtpt_oxides: dict[str, float] Composition in wt% oxides. Returns ------- dict[str, float] Composition in mol fraction cations (element keys), normalized to sum to 1.0. """ mol_cations = {} for oxide, wtpt in wtpt_oxides.items(): element = const.oxides_to_elements[oxide] mol_cations[element] = const.CationNum[oxide] * wtpt / const.oxideMass[oxide] mol_sum = sum(mol_cations.values()) if mol_sum == 0: return dict(mol_cations) mol_cations = {k: v / mol_sum for k, v in mol_cations.items()} return mol_cations
[docs] def wtpt_oxides_to_mol_singleO(wtpt_oxides: dict[str, float]) -> dict[str, float]: """ Convert from wt% oxides to moles of cations per single oxygen atom. Parameters ---------- wtpt_oxides: dict[str, float] Composition in wt% oxides. Returns ------- dict[str, float] Composition as cation moles normalized to one oxygen atom (element keys). Not normalized to sum to 1.0. """ cation_moles = {} total_O = 0.0 for oxide, wtpt in wtpt_oxides.items(): element = const.oxides_to_elements[oxide] cation_moles[element] = const.CationNum[oxide] * wtpt / const.oxideMass[oxide] total_O += const.OxygenNum[oxide] * wtpt / const.oxideMass[oxide] if total_O == 0: return dict(cation_moles) mol_singleO = {k: v / total_O for k, v in cation_moles.items()} return mol_singleO
[docs] def mol_oxides_to_wtpt_oxides(mol_oxides: dict[str, float]) -> dict[str, float]: """ Convert from mol fraction oxides to wt% oxides. Parameters ---------- mol_oxides: dict[str, float] Composition in mol fraction oxides. Returns ------- dict[str, float] Composition in wt% oxides, normalized to sum to 100. """ wtpt_oxides = {} for oxide, mol_frac in mol_oxides.items(): wtpt_oxides[oxide] = mol_frac * const.oxideMass[oxide] wtpt_sum = sum(wtpt_oxides.values()) if wtpt_sum == 0: return dict(wtpt_oxides) wtpt_oxides = {k: 100 * v / wtpt_sum for k, v in wtpt_oxides.items()} return wtpt_oxides
[docs] def mol_cations_to_wtpt_oxides(mol_cations: dict[str, float]) -> dict[str, float]: """ Convert from mol fraction cations to wt% oxides. Parameters ---------- mol_cations: dict[str, float] Composition in mol fraction cations (element keys). Returns ------- dict[str, float] Composition in wt% oxides, normalized to sum to 100. """ wtpt_oxides = {} for element, mol_frac in mol_cations.items(): oxide = const.elements_to_oxides[element] wtpt_oxides[oxide] = mol_frac / const.CationNum[oxide] * const.oxideMass[oxide] wtpt_sum = sum(wtpt_oxides.values()) if wtpt_sum == 0: return dict(wtpt_oxides) wtpt_oxides = {k: 100 * v / wtpt_sum for k, v in wtpt_oxides.items()} return wtpt_oxides
[docs] def mol_oxides_to_mol_cations(mol_oxides: dict[str, float]) -> dict[str, float]: """ Convert from mol fraction oxides to mol fraction cations. Parameters ---------- mol_oxides: dict[str, float] Composition in mol fraction oxides. Returns ------- dict[str, float] Composition in mol fraction cations (element keys), normalized to sum to 1.0. """ mol_cations = {} for oxide, mol_frac in mol_oxides.items(): element = const.oxides_to_elements[oxide] mol_cations[element] = mol_frac * const.CationNum[oxide] mol_sum = sum(mol_cations.values()) if mol_sum == 0: return dict(mol_cations) mol_cations = {k: v / mol_sum for k, v in mol_cations.items()} return mol_cations
[docs] def mol_cations_to_mol_oxides(mol_cations: dict[str, float]) -> dict[str, float]: """ Convert from mol fraction cations to mol fraction oxides. Parameters ---------- mol_cations: dict[str, float] Composition in mol fraction cations (element keys). Returns ------- dict[str, float] Composition in mol fraction oxides, normalized to sum to 1.0. """ mol_oxides = {} for element, mol_frac in mol_cations.items(): oxide = const.elements_to_oxides[element] mol_oxides[oxide] = mol_frac / const.CationNum[oxide] mol_sum = sum(mol_oxides.values()) if mol_sum == 0: return dict(mol_oxides) mol_oxides = {k: v / mol_sum for k, v in mol_oxides.items()} return mol_oxides
[docs] def normalize_composition(composition: dict[str, float], units: str) -> dict[str, float]: """ Rescale a composition dict so its values sum to the target for the given units (100 for percent units, 1.0 for fraction units). Note that :func:`convert_composition` already normalizes outputs to its target (except for molfrac_singleO, see docs). This is a helper function to allow the user to easily perform normalization within their own scripts and essentially exists for convenience. Parameters ---------- composition: dict[str, float] Composition to normalize. units: str Any valid unit string from VALID_UNITS except ``'molfrac_singleO'`` — singleO values are cations per oxygen atom, not parts of a whole, so sum-rescaling would distort their meaning. Returns ------- dict[str, float] Normalized composition. Raises ------ ValueError If ``units`` is invalid or is ``'molfrac_singleO'``. """ if units not in VALID_UNITS: raise ValueError(f"units must be one of {VALID_UNITS}, got '{units}'.") if units == 'molfrac_singleO': raise ValueError( "Normalization is not defined for units='molfrac_singleO'. " "molfrac_singleO values are cations per oxygen atom (not parts " "of a whole), so sum-rescaling would distort their meaning. " "Use a different unit if you need normalized output." ) target = 100.0 if 'pt' in units else 1.0 total = sum(composition.values()) if total == 0: return dict(composition) return {k: target * v / total for k, v in composition.items()}