Skip to content

Species ​

Species is a composite type (introduced by the keyword struct) and is defined by a human-readable name, a chemical symbol/notation, an underlying Formula object holding composition, charge, and string representations, a physical state aggregate_state, a species class, as well as an extensible map of custom properties (molar mass, thermodynamic data, etc.)

julia
struct Species{T<:Number} <: AbstractSpecies
    name::String
    symbol::String
    formula::Formula{T}
    aggregate_state::AggregateState
    class::Class
    properties::OrderedDict{Symbol,PropertyType}
end

Advanced description

  • aggregate_state denotes the state of the species (solid, liquid, gas) for which the possible keywords are AS_AQUEOUS, AS_CRYSTAL, AS_GAS, AS_LIQUID and AS_UNDEF

  • class defines the role played by the species in the solution. The possible keywords are SC_AQSOLVENT, SC_AQSOLUTE, SC_COMPONENT, SC_GASFLUID and SC_UNDEF

  • properties refers to the set of properties intrinsic to the species. These properties are detailed below.

Species construction ​

Species can be created from:

  • a Formula
julia
fH2O = Formula("H2O")
H2O = Species(fH2O, aggregate_state=AS_AQUEOUS, class=SC_AQSOLVENT)
Species{Int64}
           name: H2O
         symbol: H2O
        formula: H2O ◆ H₂O
          atoms: H => 2, O => 1
         charge: 0
aggregate_state: AS_AQUEOUS
          class: SC_AQSOLVENT
     properties: M = 0.0180149999937744 kg mol⁻¹
  • a string
julia
HSO4⁻ = Species("HSO₄⁻", aggregate_state=AS_AQUEOUS, class=SC_COMPONENT)
Species{Int64}
           name: HSO₄⁻
         symbol: HSO₄⁻
        formula: HSO₄⁻ ◆ HSO4-
          atoms: H => 1, S => 1, O => 4
         charge: -1
aggregate_state: AS_AQUEOUS
          class: SC_COMPONENT
     properties: M = 0.09706399996645676 kg mol⁻¹
  • a dictionary
julia
CO2 = Species(Dict(:C => 1, :O => 2), aggregate_state=AS_GAS, class=SC_GASFLUID)
Species{Int64}
           name: CO₂
         symbol: CO₂
        formula: CO2 ◆ CO₂
          atoms: O => 2, C => 1
         charge: 0
aggregate_state: AS_GAS
          class: SC_GASFLUID
     properties: M = 0.04400899998479143 kg mol⁻¹

Adding charge

To add a charge when creating species with a dictionary, you must add, after the dictionary, the value of the charge (charge is considered an argument of the composite type).

julia
SiO₃²⁻ = Species(Dict(:Si => 1, :O => 3), -2, aggregate_state=AS_AQUEOUS, class=SC_COMPONENT)
Species{Int64}
           name: SiO₃-2
         symbol: SiO₃-2
        formula: SiO3-2 ◆ SiO₃-2
          atoms: Si => 1, O => 3
         charge: -2
aggregate_state: AS_AQUEOUS
          class: SC_COMPONENT
     properties: M = 0.0760819999737077 kg mol⁻¹

Keyword arguments such as name, symbol, aggregate_state, class can be added during construction.

julia
fH₂O = Formula("H2O")
H₂O = Species(fH₂O; name="Water", symbol="H₂O@", aggregate_state=AS_AQUEOUS, class=SC_AQSOLVENT)
Species{Int64}
           name: Water
         symbol: H₂O@
        formula: H2O ◆ H₂O
          atoms: H => 2, O => 1
         charge: 0
aggregate_state: AS_AQUEOUS
          class: SC_AQSOLVENT
     properties: M = 0.0180149999937744 kg mol⁻¹

And symbol accept unicode characters.

julia
CO₂ = Species(Dict(:C=>1, :O=>2); name="Carbon dioxide", symbol="CO₂⤴", aggregate_state=AS_GAS, class=SC_GASFLUID)
Species{Int64}
           name: Carbon dioxide
         symbol: CO₂⤴
        formula: CO2 ◆ CO₂
          atoms: O => 2, C => 1
         charge: 0
aggregate_state: AS_GAS
          class: SC_GASFLUID
     properties: M = 0.04400899998479143 kg mol⁻¹

Comparison between species

Comparison between species (or cemspecies) are done by comparing atoms, aggregate_state and class. In the example below, vapor is not equal to H₂O since aggregate_state and class are different despite atoms are identical.

julia
vapour = Species("H2O"; name="Vapour", symbol="H₂O⤴", aggregate_state=AS_GAS, class=SC_GASFLUID)
vapour == H₂O

Remark

You will also have noticed that a calculation of the molar mass of the species is systematically carried out.


When are two species the same species? ​

Two species are equal when their formula, aggregate state and class agree, and when their symbol agrees on top of that — but only insofar as the symbol says something the formula does not.

The symbol is in the contract because the first three do not separate polymorphs, and a polymorph is a different substance. Calcite and aragonite are both CaCO3, both AS_CRYSTAL, both SC_COMPONENT, and in CEMDATA18 their standard Gibbs energies differ by 821 J/mol — at 298 K, 0.33 in ln K, which is the whole difference in solubility between them.

julia
using ChemistryLab

calcite   = Species("CaCO3"; symbol = "Cal", aggregate_state = AS_CRYSTAL)
aragonite = Species("CaCO3"; symbol = "Arg", aggregate_state = AS_CRYSTAL)

(calcite == aragonite, length(unique([calcite, aragonite])))
(false, 2)

At the same time, a symbol that merely spells the species' own formula adds nothing, and is ignored. So the same substance typed two ways is one species, with one hash — which is what a Dict or a Set needs:

julia
a, b = Species("Ca+2"), Species("Ca⁺²")

(a == b, hash(a) == hash(b), Dict(a => 1)[b])
(true, true, 1)

The symbol is also a lookup key, and stays what you typed

ChemicalSystem indexes its species by symbol — cs["H2O"] — so the stored symbol is never rewritten. The canonicalization above happens inside the comparison only. In particular it does not replace a derived symbol by a canonical spelling, because unicode is not constant on formulas that compare equal: Formula("e") and Formula("e-") are equal yet spell themselves "e" and "e⁻", and Formula("Ca+2") and Formula("Ca⁺²") are equal yet spell themselves "Ca²⁺" and "Ca⁺²". A derived symbol therefore contributes nothing at all rather than a canonical form.

julia
cs = ChemicalSystem([Species("H2O"; aggregate_state = AS_AQUEOUS)])

symbol.(cs.species), cs["H2O"] == Species("H2O"; aggregate_state = AS_AQUEOUS)
(["H2O"], true)

Species read from a database always carry an explicit symbol — "Cal", "Arg", "H2O@" — so every identity question about them is decided by that symbol, as it should be.

Species properties ​

The molar mass is automatically calculated and stored in the species properties dict under the key :M. Beyond that, the properties dict is open: any value of type Number, AbstractVector{<:Number}, Function, or AbstractString can be added at any time.

Predefined property:

KeyTypeDescription
:MQuantity (g/mol)Molar mass, computed automatically from the formula

Common user-added thermodynamic properties (loaded from databases or set manually):

KeyTypeDescription
:Cp⁰SymbolicFunc(T)Standard heat capacity (J mol⁻¹ K⁻¹)
:ΔₐH⁰SymbolicFunc(T)Standard enthalpy of formation (J mol⁻¹)
:S⁰SymbolicFunc(T)Standard entropy (J mol⁻¹ K⁻¹)
:ΔₐG⁰SymbolicFunc(T)Standard Gibbs free energy of formation (J mol⁻¹)
:V⁰Quantity or FunctionMolar volume (m³ mol⁻¹)

Properties are accessed and mutated via []:

julia
using ChemistryLab
using DynamicQuantities

H2O = Species("H2O"; aggregate_state = AS_AQUEOUS, class = SC_AQSOLVENT)

# Molar mass is always available
H2O[:M]
0.0180149999937744 kg mol⁻¹
julia
# Add a scalar property
H2O[:V⁰] = 18.07e-6u"m^3/mol"
H2O[:V⁰]
1.807e-5 m³ mol⁻¹
julia
# Add a string annotation
H2O[:source] = "CRC Handbook 2024"
H2O[:source]
"CRC Handbook 2024"

Attaching temperature-dependent thermodynamic functions ​

The standard workflow uses build_thermo_functions to construct callable SymbolicFunc objects from reference data and Cp polynomial coefficients, then assigns them to the species:

julia
using ChemistryLab
using DynamicQuantities

CO₂ = Species("CO2"; name = "Carbon dioxide", aggregate_state = AS_GAS, class = SC_GASFLUID)

# Reference data at 298.15 K (from e.g. thermoddem.brgm.fr)
params_Cp_CO2 = Dict(
    :S⁰   => 213.785u"J/K/mol",
    :ΔₐH⁰ => -393510u"J/mol",
    :ΔₐG⁰ => -394373u"J/mol",
    :a₀   => 33.98u"J/K/mol",       # Cp polynomial: only a₀ and a₁ non-zero here
    :a₁   => 23.88e-3u"J/(mol*K^2)",
    :a₂   => 0.0u"J*K/mol",
    :a₃   => 0.0u"J/(mol*K^0.5)",
    :T    => 298.15u"K",             # reference temperature
)

dtf_CO2 = build_thermo_functions(:cp_ft_equation, params_Cp_CO2)
OrderedCollections.OrderedDict{Symbol, SymbolicFunc{1, @NamedTuple{T::DynamicQuantities.Quantity{Float64, DynamicQuantities.Dimensions{DynamicQuantities.FRInt32}}}, Float64, DynamicQuantities.Dimensions{DynamicQuantities.FRInt32}}} with 4 entries:
  :Cp⁰  => 33.98 + 0.02388T [m² kg s⁻² K⁻¹ mol⁻¹] ◆ vars=(T) ◆ T=298.15 K
  :ΔₐH⁰ => -4.04703e5 + 33.98T + 0.01194(T^2) [m² kg s⁻² mol⁻¹] ◆ vars=(T) ◆ T=298.15 K
  :S⁰   => 13.0608 + 0.02388T + 33.98log(T) [m² kg s⁻² K⁻¹ mol⁻¹] ◆ vars=(T) ◆ T=298.15 K
  :ΔₐG⁰ => -3.41826e5 + 20.9192T - 0.01194(T^2) - 33.98T*log(T) [m² kg s⁻² mol⁻¹] ◆ vars=(T) ◆ T=298.15 K
julia
# Assign each function to the species
for (k, v) in dtf_CO2
    CO₂[k] = v
end

# Evaluate at different temperatures
CO₂[:Cp⁰](T = 298.15)    # J/mol/K at 25 °C
41.099821999999996
julia
CO₂[:Cp⁰](T = 500.0)     # J/mol/K at 227 °C
45.919999999999995
julia
CO₂[:ΔₐG⁰](T = 500.0u"K", unit = true)   # Gibbs energy at 227 °C with units
-439937.13910932385 m² kg s⁻² mol⁻¹

Cp polynomial

The built-in :cp_ft_equation model uses a 10-term polynomial:         Unused coefficients should be set to 0.0 with appropriate units. Only non-zero terms affect the result. See the Thermodynamic Functions tutorial for the full list of models and parameters.

Reference temperature

Always include :T => reference_temperature in the parameter dict. The functions are adjusted so that S°(T_ref), ΔₐH°(T_ref), ΔₐG°(T_ref) match the provided reference values exactly.


Requalifying a species: with_class ​

Species is an immutable struct — none of its fields can be modified after construction. with_class returns a copy of the species with only the class field changed. All other fields — name, symbol, formula, aggregate state, properties and thermodynamic functions — are shared by reference and unchanged.

julia
using ChemistryLab

# A crystal species with the class that databases typically assign
cal = Species("CaCO3"; aggregate_state = AS_CRYSTAL, class = SC_COMPONENT)

println("before: ", class(cal))        # SC_COMPONENT

em = with_class(cal, SC_SSENDMEMBER)

println("after:  ", class(em))         # SC_SSENDMEMBER
println("formula preserved: ", formula(em) == formula(cal))
before: SC_COMPONENT
after:  SC_SSENDMEMBER
formula preserved: true

Solid solutions: no requalification needed

SolidSolutionPhase automatically promotes end-members to SC_SSENDMEMBER at construction time. Database species with SC_COMPONENT can therefore be passed directly — no prior call to with_class is required:

julia
substances = build_species(datapath("cemdata18-thermofun.json"))
dict = Dict(symbol(s) => s for s in substances)

# Pass database species directly — SolidSolutionPhase requalifies internally
cshq = SolidSolutionPhase("CSHQ", [dict["CSHQ-TobD"], dict["CSHQ-TobH"],
                                    dict["CSHQ-JenH"], dict["CSHQ-JenD"]])

with_class is still useful when you need to track the requalified object explicitly (e.g. to inspect its class, or for types other than solid solution end-members).

Where to go next ​

Species combine into reactions in Chemical Reactions, and into the conservation matrix of a system in Stoichiometric Matrix. Species are seldom built by hand in practice: Database Interoperability reads them from the shipped databases.