"""
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()}