Units

The short answer

mg/L, per species, and stated rather than assumed.

Every loader returns mg/L, and a table you build yourself defaults to mg/L for every species. The mixing algebra does not care — it is linear within each species, so mixed units across columns are mathematically harmless. The conversions care, because they read the label to decide what to do.

Units are per species

A real analysis sheet mixes units routinely: concentrations in mg/L beside conductivity in µS/cm and a delta value in permil. One table-level label can only describe such a table by lying about part of it, so Mescla tracks a unit for each species.

import mescla as ms

# One string applies to everything.
samples = ms.WaterChemistry(data, species=["Ca", "Mg", "Cl"], units="mg/L")

# A mapping names only the exceptions; anything unlisted is mg/L.
samples = ms.WaterChemistry(
    data, species=["Cl", "EC", "d18O"], units={"EC": "uS/cm", "d18O": "permil"}
)

samples.units_dict()      # {'Cl': 'mg/L', 'EC': 'uS/cm', 'd18O': 'permil'}
samples.unit_of("EC")     # 'uS/cm'
samples.common_unit       # None -- the table mixes units
samples.is_homogeneous    # False

with_units relabels without converting. Use it when a loader guessed wrong and the numbers were always in the unit you are now stating:

samples = samples.with_units({"EC": "uS/cm"})    # says what the numbers are

The bundled Besòs end-members are the real case: twenty species in mg/L and one, EC, in µS/cm. load_tubau_besos() declares that, so nothing downstream has to guess.

Converting

from mescla.prep.units import to_meq_per_l, to_mmol_per_l

Both convert only the species that are not already in the target unit, and relabel those species individually. A species that cannot be converted stops the call:

>>> to_meq_per_l(ms.WaterChemistry([[40.078, 500.0]], species=["Ca", "EC"]))
ValueError: cannot convert to meq/L:
  'EC': not a recognised ion

These species carry no charge or are not in the ion table, so there is no factor to
apply. Choose deliberately:
  on_unknown='skip'  keep them, unconverted, with their own unit
  on_unknown='drop'  remove them from the returned table
  .select_species(...)  convert only the ions
Inspect the factors with mescla.prep.units.conversion_factors(table).

Refusing is deliberate. The alternative — passing the column through untouched while relabelling the table — returns something that looks converted and is not, which is the failure mode worth preventing. Choose the policy explicitly:

on_unknown

what happens

when

"raise" (default)

refuses, naming the species

you expected everything to convert

"skip"

keeps them with their own unit

the table legitimately holds EC, silica or isotopes

"drop"

removes those columns

you want a purely ionic table

"skip" is honest precisely because units are per species: the result says Ca is meq/L and EC is still µS/cm.

>>> to_meq_per_l(samples, on_unknown="skip").units_dict()
{'Ca': 'meq/L', 'EC': 'uS/cm'}

charge_balance_report uses "skip" internally, because it deliberately considers only the major ions.

Which species convert, and by what factor

Nothing is hidden. conversion_factors() returns exactly the table the conversions read:

>>> from mescla.prep.units import conversion_factors
>>> conversion_factors(["Ca", "SO4", "EC"])
        matched_ion  molar_mass  charge  equivalent_weight    factor target_unit  convertible
species
Ca               Ca      40.078     2.0             20.039  0.049903       meq/L         True
SO4             SO4      96.060    -2.0             48.030  0.020820       meq/L         True
EC              NaN         NaN     NaN                NaN       NaN         NaN        False

Pass a table to describe its own species, and to="mmol/L" for the molar factors:

conversion_factors(samples)                  # what would happen to this table
conversion_factors(samples, to="mmol/L")

Read it whenever a converted number surprises you, or to check that a species was recognised under the name you used. Matching ignores case and charge notation, so "Ca", "Ca2+" and "calcium" are the same ion; ION_TABLE is the full list.

  • meq/L divides by the equivalent weight (molar mass ÷ |charge|), so a neutral species has none — silica and dissolved oxygen cannot convert, and the error says so.

  • mmol/L divides by the molar mass, which exists for neutral species too, so more species convert.

Where the unit label actually matters

operation

depends on the label?

mixing_ratios, mix_ml, EMMA

no — linear within each species

to_meq_per_l, to_mmol_per_l

yes — decides what is already converted

charge_balance_report, ec_check

yes — they convert internally

So a mislabelled table will not corrupt a mixing ratio, but it will corrupt a charge balance. If you load data that is already in meq/L, say so:

samples = ms.WaterChemistry(data, species=[...], units="meq/L")

Isotopes

linearize() replaces a solute isotope column with δ × C, and marks that column as linearised (permil x mg/L) while the others keep their own unit — see 08 · Isotope tracers, and the one that breaks linear mixing.