Surfaces API
How much area a solid offers, and to what. One abstraction serves the reactive area of a dissolution rate law and the binding area that carries surface sites: the three areas of a problem — reactive, accessible, binding — are different numbers, but they are the same kind of object, and the measurement each comes from is carried by its type.
Areas
ChemistryLab.SHRINK_FLOOR Constant
SHRINK_FLOORFractional floor regularizing the power law of ShrinkingCoreArea near exhaustion, 1e-8.
What it is for, and why a floor rather than a branch
f^p with a fractional p has an infinite derivative at f = 0, so a solid that an integrator drives to exhaustion produces an infinite Jacobian entry and an automatic derivative that is Inf or NaN. The regularized form used here,
is exactly zero at f = 0, exactly one at f = 1, has a finite derivative everywhere, reduces to g(f) = f when p = 1, and differs from f^p by a relative f_c/(3f) at p = 2/3 — about 3e-9 at half depletion. It is written without a comparison so that nothing branches on a value that may be a Dual.
ChemistryLab.AbstractSpecificArea Type
abstract type AbstractSpecificArea <: AbstractSurfaceModel endA surface model defined by a specific area in m²/kg, together with the measurement it comes from.
The measurement is carried by the type, not by a field, and that is the whole point: area_ratio is defined only between two models of the same type, so comparing a BET area with a Blaine fineness is a MethodError-free, named refusal rather than a plausible wrong number.
Concrete subtypes implement specific_area and area_method, and inherit total_area as specific_area(m) * max(n, 0) * M.
See also: BETSurfaceArea, BlaineSurfaceArea, GeometricSurfaceArea.
ChemistryLab.AbstractSurfaceModel Type
abstract type AbstractSurfaceModel endHow much area a solid offers, in m², as a function of how much of it is left.
Concrete subtypes implement
total_area(model, n::Real, n₀::Real, M::Real) -> Realwhere n is the current molar amount [mol], n₀ the initial one [mol], and M the molar mass [kg/mol]. The result is a total area in m², never a specific one.
Why n₀ is in the signature from the start
Because an area that cannot see where it started cannot describe a microstructure that evolves. n alone expresses "area proportional to the remaining mass"; the ratio n/n₀ is what expresses a shrinking grain (ShrinkingCoreArea). Passing both costs nothing and means no rate law has to change its signature when a richer area model is added later.
surface_area is the three-argument form kept for callers that have no initial amount to give; it is total_area(model, n, n, M).
All methods are AD-compatible: no Float64 conversion, and the element type of the result follows its arguments.
See also: AbstractSpecificArea, total_area, FixedSurfaceArea, ShrinkingCoreArea.
ChemistryLab.BETSurfaceArea Type
struct BETSurfaceArea{T<:Real} <: AbstractSpecificAreaA specific area in m²/kg from a BET (Brunauer-Emmett-Teller) gas-adsorption measurement.
A = A_specific × n × M [m²]the standard route in reactive-transport modeling (Palandri & Kharaka 2004). Using it presumes the area accessible to the dissolving solute is the one the adsorbed gas saw, which is an assumption about the pore structure, not a measurement of it.
Fields
A_specific: specific BET area [m²/kg].
Examples
julia> BETSurfaceArea(90.0)
BETSurfaceArea{Float64}(90.0)
julia> BETSurfaceArea(0.09u"m^2/g")
BETSurfaceArea{Float64}(90.0)See also: BlaineSurfaceArea, area_ratio.
ChemistryLab.BlaineSurfaceArea Type
struct BlaineSurfaceArea{T<:Real} <: AbstractSpecificAreaA specific area in m²/kg from a Blaine air-permeability fineness.
Why this is a separate type from BETSurfaceArea
Because the two measure different things, and the package already says so in prose three times over. Silica fume is about 20 000 m²/kg by BET, while the effective Blaine fineness recommended for its Waller kinetics is about 2 000 m²/kg — a factor of ten. Passing the first where the second is expected produces a hydration rate ten times too fast, with nothing to catch it.
Making the measurement a type is what turns that warning into a refusal: area_ratio between a Blaine and a BET area raises, because no method covers the pair.
Fields
A_specific: Blaine fineness [m²/kg].
Examples
julia> BlaineSurfaceArea(385.0)
BlaineSurfaceArea{Float64}(385.0)
julia> BlaineSurfaceArea(380u"m^2/kg")
BlaineSurfaceArea{Float64}(380.0)See also: blaine_factor, area_ratio.
ChemistryLab.FixedSurfaceArea Method
FixedSurfaceArea(A) -> FixedSurfaceAreaBuild a FixedSurfaceArea from a total area.
A is a plain Real in m², or a Quantity convertible to m². Unlike the previous implementation the element type is kept, so a ForwardDiff.Dual area differentiates through.
ChemistryLab.GeometricSurfaceArea Type
struct GeometricSurfaceArea{T<:Real} <: AbstractSpecificAreaA specific area in m²/kg computed from a geometric idealization — a particle size distribution and an assumed shape — rather than measured by adsorption or permeability.
Kept distinct from the two measured kinds for the same reason they are distinct from each other: a geometric area is typically orders of magnitude below a BET area on the same powder, because it counts no internal roughness.
Fields
A_specific: geometric specific area [m²/kg].
ChemistryLab.ShrinkingCoreArea Type
struct ShrinkingCoreArea{A<:AbstractSpecificArea, T<:Real} <: AbstractSurfaceModelAn area that follows a population of grains being consumed, rather than the mass that remains.
with 𝒜₀ the initial area computed from initial, and g the regularized power law described under SHRINK_FLOOR.
Choosing the exponent
p = 2/3 is the geometric answer for spheres consumed from the outside: area goes as the square of a radius, amount as its cube. p = 1 recovers an area proportional to the remaining mass, that is, exactly what a plain BETSurfaceArea gives — so the two are one family, and the exponent is the modeling choice that separates them. Values in between are common fits and should be labeled as fits.
What this does not do
It does not describe an area blocked by something growing on it — a hydrate layer covering a clinker grain reduces the accessible area without consuming the grain, so it depends on the whole assemblage and not on n alone. That is a different model, and the precedent for writing it is PoreHumidity, which already reads the current composition to produce a geometric quantity.
Fields
initial: the specific-area model giving the area atn = n₀, carrying its own measurement method.exponent:p, dimensionless.
Examples
ShrinkingCoreArea(BETSurfaceArea(90.0), 2 // 3)
ShrinkingCoreArea(GeometricSurfaceArea(0.4u"m^2/g")) # p = 2/3 by defaultSee also: total_area, SHRINK_FLOOR.
ChemistryLab.ShrinkingCoreArea Method
ShrinkingCoreArea(initial; exponent = 2//3) -> ShrinkingCoreAreaBuild a ShrinkingCoreArea. The default exponent is the spherical 2/3; state it explicitly whenever it is a fit rather than a geometry.
ChemistryLab.SiteCoupling Type
SiteCouplingWhether a support's site budget is a number posed once or follows the amount of the species carrying it.
SITES_FIXED— the budget is whatever the state was initialized with, and the host's amount does not enter it. This is every support declared before the option existed, and the default.SITES_FOLLOW_HOST— the budget isνmoles of sites per mole of host, evaluated on the host's current amount, so a sorbent that precipitates brings its sites with it and one that dissolves takes them away.
The second is a different model, not a refinement of the first, which is why it is asked for rather than inferred. Naming a host is not enough on its own: the kinetics has named one since long before, to find the amount a rate law scales with.
See also: SurfaceSupport, SiteFamily.
ChemistryLab.SurfaceSupport Type
struct SurfaceSupport{M<:AbstractSurfaceModel}A support and the area it offers: a named host solid together with its area model.
What this factorizes
The rate factories used to carry a surface model and rediscover the mineral it belonged to from the reaction, through a helper that fell back to 0.1 kg/mol when the species had no molar mass — silently, and wrong by up to an order of magnitude. A SurfaceSupport names the host once, so the molar mass is looked up once and its absence is an error that names the species.
The same object is what a family of surface sites will hang from, which is why it lives here rather than in the kinetics: one support, one area, whether what happens on it is dissolution or binding.
Why not simply Surface
Because Plots exports that name, and every documentation page that draws a result does using ChemistryLab, Plots. The two would be ambiguous at the point of use, which is the least helpful moment to find out. SurfaceSupport also says what the object is: the plan's vocabulary calls the grain the support, and the sites are what it carries.
Fields
name: a label for the surface, free-form.host: the symbol of the species carrying it, ornothingwhen the support is externally prescribed and does not appear in the system.area: theAbstractSurfaceModel.coupling:SITES_FIXEDorSITES_FOLLOW_HOST— whether the site budget is a number posed once or tracks the host's amount.
Examples
SurfaceSupport("calcite", "Cal", BETSurfaceArea(90.0))
SurfaceSupport("inert sorbent", nothing, FixedSurfaceArea(0.5))
SurfaceSupport("C-S-H", "CSHQ-JenD", BETSurfaceArea(90.0); coupling = SITES_FOLLOW_HOST)ChemistryLab.SurfaceSupport Method
SurfaceSupport(name, host, area; coupling = SITES_FIXED) -> SurfaceSupport
SurfaceSupport(name, area; coupling = SITES_FIXED) -> SurfaceSupportBuild a SurfaceSupport. The two-argument form leaves the host unset, for a support whose amount is prescribed rather than solved for.
coupling is SITES_FIXED unless asked otherwise, so every support declared before this option existed behaves exactly as it did. Naming a host does not by itself couple the sites to it: the kinetics has named a host since long before, to find the amount a rate law scales with, and turning that into a coupling would change those systems without anyone asking.
ChemistryLab._area_si Method
_area_si(unit, x, what) -> Realx as a bare number in unit, refusing a quantity that does not convert.
A plain Real is taken to be already in SI, which is the documented contract of every constructor in this file. A Quantity is converted, and one whose dimension does not match raises an ArgumentError naming the offending value.
This is deliberately not safe_ustrip, whose documented behaviour is to fall back to stripping the quantity in its native unit when the dimensions differ. That fallback is right where it is used — a dimensionless placeholder unit in ThermoFactory — and wrong here, where it would silently turn 20_000u"m^2/kg" passed as a total area into the number 20 000 m².
ChemistryLab._shrink_fraction Method
_shrink_fraction(f, p) -> RealThe regularized power law g of SHRINK_FLOOR, evaluated on an already clamped fraction f ≥ 0. Exactly f when p == 1.
ChemistryLab.area_method Method
area_method(model) -> SymbolThe measurement a surface model's number comes from, for error messages.
:fixed for a FixedSurfaceArea, :BET, :Blaine or :geometric for the specific-area models. It exists so a refusal can name what it refused; the logic that keeps two measurements apart is dispatch, not this symbol.
ChemistryLab.area_ratio Method
area_ratio(model, reference) -> RealThe dimensionless ratio of two specific areas of the same measurement kind.
julia> area_ratio(BlaineSurfaceArea(462.0), BlaineSurfaceArea(385.0))
1.2
julia> area_ratio(BETSurfaceArea(20_000.0), BlaineSurfaceArea(385.0))
ERROR: ArgumentError: cannot compare a BET area with a Blaine area: they measure different things, and their ratio is not a fineness factor. Convert deliberately, or use two models of the same kind.Why this refuses rather than divides
Because the two numbers are not commensurable, and the consequence of pretending otherwise is quantitative: taking silica fume's BET area (about 20 000 m²/kg) for its effective Blaine fineness (about 2 000 m²/kg) multiplies its hydration rate by ten. The refusal is the mechanism behind blaine_factor being safe to call.
See also: specific_area, area_method.
ChemistryLab.specific_area Function
specific_area(model::AbstractSpecificArea) -> RealThe specific area of model in m²/kg.
Not defined for FixedSurfaceArea, which carries a total area and no mass to divide it by; asking is a MethodError, which is the honest answer.
ChemistryLab.surface_area Method
surface_area(model, n::Real, molar_mass::Real) -> RealTotal area in m², for a caller that has no initial amount to offer.
It is total_area(model, n, n, M) — exact for every model whose area does not depend on where it started, and therefore refused by ShrinkingCoreArea, for which it would silently always return the initial area.
Prefer total_area in new code.
ChemistryLab.total_area Method
total_area(model::AbstractSpecificArea, n, n₀, M) -> Realspecific_area(model) * max(n, 0) * M [m²] — an area proportional to the mass still present, which is the same as holding the specific area constant.
Clamped at zero so an amount that an ODE step has pushed slightly negative returns no area rather than a negative one. The initial amount is unused here; it matters only to a model that compares the two, such as ShrinkingCoreArea. AD-compatible.
ChemistryLab.total_area Method
total_area(model::FixedSurfaceArea, n, n₀, M) -> RealThe stored area, whatever the amounts. AD-compatible.
ChemistryLab.total_area Method
total_area(model::ShrinkingCoreArea, n, n₀, M) -> RealThe initial area scaled by the regularized power law of n/n₀. AD-compatible, and finite at n = 0.
Raises when n₀ is zero, because the model is written as a fraction of an initial amount and there is no sensible answer to "two thirds of nothing".
Site families
A site is a conserved quantity that is not a chemical element, and it is carried the way electric charge already was: as a pseudo-element in a species' formula, turned into a conservation row by the ordinary matrix assembly.
ChemistryLab.AbstractSiteCapacity Type
abstract type AbstractSiteCapacity endHow many moles of sites a family offers.
Concrete subtypes implement
site_moles(capacity, support, n_host::Real, n_host₀::Real, M_host::Real) -> Realreturning moles of sites.
Why three of them, and not one
Because the published data comes in three shapes, and converting between them needs a number nobody measured. A specific area with a site density per square meter (AreaSiteDensity) is the oxide literature's form; a capacity per kilogram of dry solid (MassSiteDensity) is the clay literature's, and turning the second into the first would mean inventing a BET area to divide by. A prescribed total (TotalSiteAmount) is what a fixed sorbent in a batch experiment actually gives.
See also: SiteFamily, site_moles.
ChemistryLab.AbstractSiteMixingModel Type
abstract type AbstractSiteMixingModel endHow the species of a SiteFamily mix on their shared budget of sites.
Concrete subtypes implement
_site_excess_ln_gamma(model, k, x, T) -> Realthe departure from ideality of the k-th member, given the site fractions x of the whole family. The ideal part, ln x_k, is added by the caller, exactly as it is for a solid solution.
Only IdealSiteMixing exists in this release. The others the literature uses — Frumkin's interaction term, the quasi-chemical approximation for a multidentate adsorbate — are separate models with their own parameters and their own validation, and this is where they will attach.
See also: IdealSiteMixing, SiteFamily.
ChemistryLab.AreaSiteDensity Type
struct AreaSiteDensity{T<:Real} <: AbstractSiteCapacitySites per unit area, in mol/m². The area comes from the family's SurfaceSupport, so the measurement it was made with travels with it.
Fields
Γ_C: site density [mol/m²].
ChemistryLab.ConstantCapacitance Type
struct ConstantCapacitance{M<:AbstractSiteMixingModel, T<:Real} <: AbstractSiteMixingModelA charged surface, in the constant-capacitance model: the potential is proportional to the charge the surface carries.
with C the capacitance in F/m², 𝒜 the surface area in m², and z_k the formal charge of each member. It decorates another mixing model rather than replacing one: the site fractions are still whatever base says they are, and this adds the electrical work of putting a charge on a charged surface.
Why this needs no unknown of its own
The literature presents an electrostatic surface model as one extra unknown per surface, Ψ, with one extra equation to close it. It is not needed here: σ = CΨ makes Ψ an explicit function of the composition,
so the whole model is a composition-dependent term in the chemical potential — which is what an activity coefficient is. It belongs with the mixing, not with the constraints, and putting it there is what keeps the solver unchanged.
The convexity, written rather than assumed
The electrical work of charging the surface is G_el = ∫₀^σ Ψ(s)\,ds \cdot 𝒜, and with Ψ = σ/C that integrates to
a quadratic form in n with Hessian \frac{F^2}{C\mathcal{A}} z z^{\mathsf T}, positive semi-definite for any positive capacitance. So this term is convex, the site mixing it decorates is convex, and the equilibrium certificate covers the sum unchanged. That is a proof, not an expectation, and it is the reason this model could be added without reopening the certificate.
Its gradient is ∂G_el/∂n_j = z_j F Ψ, the electrochemical work the physics asks for — which is the check that the energy above is the right one.
How far the solve reaches, measured
Convexity makes the minimum unique, so any failure to find it is numerical and not a second answer. And there is one: eliminating Ψ puts the whole electrostatic stiffness into the composition dependence of an activity, and the Newton loses it when that stiffness grows.
The scale of it is dimensionless and worth computing before a run:
N being the site budget — the potential the surface would reach with every site charged. Measured on hydrous ferric oxide, N = 2·10⁻⁴ mol on 53.4 m²:
C [F/m²] | ψ̃_max | stationarity of the solve |
|---|---|---|
| 10 | 1.4 | 3e-16 |
| 5 | 2.8 | 2e-16 |
| 3 | 4.7 | 2e-16 |
| 2 | 7.0 | 0.04 — lost |
| 1.2 | 11.7 | 0.07 — lost |
So ψ̃_max ≲ 5 is reached directly, and stepping C down from a large value while reusing the previous composition extends the range but does not remove the limit. Oxide capacitances of 1–3 F/m² therefore sit at the edge of it.
What lifts it is the formulation, not the tolerance. Carrying Ψ as an unknown of the solve with σ = CΨ as its closing equation is mathematically the same problem — that is what eliminating it proved — but the Newton then controls the potential directly instead of meeting it through a stiff exponential. The literature's extra unknown is a preconditioner, and it is what this model is missing rather than a correction to it.
Fields
base: the site mixing this decorates, usuallyIdealSiteMixing.C: capacitance [F/m²]. Values of 1–3 F/m² are the usual range for an oxide.area: the charged area [m²].
What it does to a titration
A surface that has already taken protons resists taking more, because the work of adding a charge to an object that is already charged grows with the charge. The visible effect is a flattened titration curve: the transitions spread over more pH units than the constants alone would give. A set of constants fitted with an electrostatic term and used without one — or the reverse — therefore describes a different surface.
See also: IdealSiteMixing, SiteFamily.
ChemistryLab.ConstantCapacitance Method
ConstantCapacitance(base, C, area) -> ConstantCapacitance
ConstantCapacitance(; C, area, base = IdealSiteMixing()) -> ConstantCapacitanceBuild a ConstantCapacitance. C is a capacitance in F/m² and area an area in m², each a plain Real in SI or a Quantity.
ChemistryLab.DiffuseLayer Type
DiffuseLayer(base, area, ε_r) -> DiffuseLayer
DiffuseLayer(; area, temperature = 298.15, pressure = 1.0e5,
ε_r = water_relative_permittivity(temperature, pressure),
base = IdealSiteMixing()) -> DiffuseLayerBuild a DiffuseLayer. area is in m², a plain Real in SI or a Quantity; ε_r is dimensionless and defaults to the permittivity of water at temperature and pressure, from this package's own model.
That default costs a few milliseconds of water-property evaluation, paid once here rather than once per solver iteration — which is the whole reason it is a stored field and not a call inside the activity model.
ChemistryLab.DiffuseLayer Type
struct DiffuseLayer{M<:AbstractSiteMixingModel, T<:Real} <: AbstractSiteMixingModelA charged surface, in the diffuse-layer (Gouy-Chapman) model: the potential is raised by the charge the surface carries and screened by the ions in solution, so it depends on the ionic strength as well as on the charge.
with I the molal ionic strength of the aqueous solution, ρ = 1000 kg/m³ the factor that turns it into a volumetric concentration, 𝒜 the surface area in m², and ε_r the relative permittivity of the solvent. Like ConstantCapacitance it decorates another mixing model: the site fractions stay whatever base says, and this adds the electrical work.
This is the model behind the calibration of Dzombak and Morel (1990) and the default of PHREEQC's SURFACE block — which is what makes it checkable against another code rather than only against itself.
It needs no unknown of its own either, and that is not obvious
A diffuse layer is usually presented as one extra unknown Ψ per surface with one extra equation to close it, on the grounds that the relation above is transcendental. It is transcendental in Ψ, but it is monotone in Ψ, so it inverts in closed form:
asinh is smooth and bounded in its derivative everywhere, so the term costs the solver nothing and differentiates cleanly. κ is computed from ε_r, and ε_r from this package's own Johnson-Norton model of water rather than typed in: at 25 °C that gives κ = 0.117215, against the 0.1174 PHREEQC writes into its source. Agreeing with PHREEQC while using a different dielectric constant is a stronger statement than agreeing with its arithmetic.
What this model costs the certificate, stated rather than hidden
Unlike ConstantCapacitance, this term is not the gradient of any Gibbs energy, and no implementation can make it one. At fixed I it is: the electrical work integrates to
whose derivative in n_k is exactly RT z_k \tilde\psi. But I is itself a function of the aqueous composition, and the aqueous activity of an ion does not depend in return on how much is bound to the surface. The Jacobian of the activity map is therefore asymmetric, and an asymmetric Jacobian is not the Hessian of anything.
That is a property of the Dzombak-Morel model, not of this code: it treats the bulk solution as a reservoir whose ionic strength is a parameter, which is precisely the approximation that lets the diffuse layer be written without carrying its ion inventory. PHREEQC's default SURFACE makes the same one.
The consequence here is named rather than papered over. is_gradient_consistent returns false for this model, a system that uses it says so, and what comes back from a solve is a self-consistent speciation — mass action and conservation satisfied together — not a certified minimum. Everything the other models certify, they still certify; this one buys agreement with the published calibrations at that price, and the price is written on it.
Fields
base: the mixing model this decorates,IdealSiteMixing()by default.area: the surface area carrying the charge, in m².ε_r: the relative permittivity of the solvent, dimensionless. Fixed at construction; differentiating a solve with respect to temperature does not propagate through it.
See also: ConstantCapacitance, is_gradient_consistent, water_relative_permittivity.
ChemistryLab.GainesThomasMixing Type
struct GainesThomasMixing <: AbstractSiteMixingModelCation exchange in the Gaines-Thomas convention: the activity of an exchanger species is its equivalent fraction, counting the charge it compensates.
with z_i read from the formula as the number of the family's pseudo-elements the species carries — which on a permanent-charge exchanger is the charge it neutralizes.
The two conventions are not interchangeable, and the conversion is not a factor
For a homovalent exchange, Na⁺/K⁺, the two fractions are proportional and the selectivity coefficients agree. For a heterovalent one, Na⁺/Ca²⁺, they do not: one calcium and one sodium are one particle each but one and two charges, so x_i and E_i diverge, and so do the constants fitted under each.
The literature sometimes quotes conversion factors of 2, 3 or 4. Those are trace-composition limits, not constant offsets: the exact relation depends on the exchanger's composition, which is what the calculation is solving for. This package therefore declares the convention and converts nothing implicitly. A constant fitted under one convention used under the other is a different model.
See also: VanselowMixing, supports_multidentate.
ChemistryLab.IdealSiteMixing Type
struct IdealSiteMixing <: AbstractSiteMixingModelOccupied and free sites mix ideally: the activity of a member is its site fraction, a_j = n_j / N_t.
What this is, and what it is not
It is the production form of the Langmuir model, and Langmuir falls out of it rather than being imposed: eliminating the free site from the equilibrium of n_free + Σ n_j = N_t gives n_j/N_t = β_j /(1 + Σ_k β_k) with β_j = K_j a_j, saturation and competition included, with no isotherm written anywhere.
It is not a place to also apply a surface activity coefficient of the 1/(1 − θ) kind. That factor is what an eliminated-free-site formulation needs in order to recover what this one already produces; applying both counts the same physics twice. The ratio is available as a diagnostic — see the tests — and the identity between the two forms is asserted rather than assumed.
ChemistryLab.MassSiteDensity Type
struct MassSiteDensity{T<:Real} <: AbstractSiteCapacitySites per kilogram of dry support, in mol/kg — the form a clay exchange capacity is published in, and the one that needs no area at all.
Fields
q: site capacity [mol/kg].
ChemistryLab.SiteFamily Type
struct SiteFamily{S<:AbstractSpecies, C<:AbstractSiteCapacity, U<:SurfaceSupport}One family of surface sites: a group of species that share a finite site budget and mix on it.
A family is to a surface what a SolidSolutionPhase is to a solid: a named group of species with its own mixing. The one thing it has that a solid solution does not is a conservation row — its total is fixed by a budget rather than free — and that row is produced by the ordinary matrix assembly, because every member carries the family's pseudo-element in its formula.
The free site is a species, and it is the reference
XsOH is as much a member as XsOH2+ is. It occupies a site, it takes part in the mixing, and its amount is what makes saturation happen: as free sites run out, adding another bound molecule costs more. Eliminating it gives the familiar Langmuir expression, but the production form keeps it explicit — one species per state of a site, with reaction-consistent standard potentials.
It is also the primary the system should be built on, and that is not a detail of taste. A bare Species("Xs") declared as the site primary is inconsistent in charge with a neutral free site, which flips the exact rank test in StoichMatrix and silently adds a spurious charge component; and it never enters cs.species, so saturation_indices would zero its potential and report a wrong index for every surface species without a word.
Denticity is read from the formula, not declared beside it
How many sites a molecule occupies is the coefficient of the family's symbol in its formula — Xs2OCa occupies two. Declaring it separately would create a way for the two to disagree. The first milestone refuses anything but one, because ideal mixing of occupied and free sites is exact only for a monodentate species; the quasi-chemical treatment of the rest is a later, separate model.
Fields
name: the family's label, free-form ("Hfo_s","Hfo_w").site: its pseudo-element, one ofSITE_SYMBOLS.free_site: the unoccupied site species.complexes: the occupied ones.capacity: how many moles of sites, as anAbstractSiteCapacity.support: theSurfaceSupportcarrying it.model: how the members mix on the budget, anAbstractSiteMixingModel.
Examples
support = SurfaceSupport("hydrous ferric oxide", "Fe(OH)3", BETSurfaceArea(600.0u"m^2/g"))
free = Species("XsOH"; aggregate_state = AS_SURFACE, class = SC_SURFCOMPLEX)
bound = Species("XsOH2+"; aggregate_state = AS_SURFACE, class = SC_SURFCOMPLEX)
SiteFamily("Hfo_s", free, [bound]; capacity = TotalSiteAmount(5.0e-6u"mol"), support)See also: site_moles, denticity, SITE_SYMBOLS.
ChemistryLab.SiteFamily Type
SiteFamily(name, free_site, complexes; capacity, support) -> SiteFamilyBuild and validate a SiteFamily.
Every member is requalified to AS_SURFACE / SC_SURFCOMPLEX, so a species built from a database record can be passed as it is.
Refused at construction, each because the alternative is a wrong number rather than an error:
a free site whose formula carries no site symbol, or two members carrying different ones — the family would have no budget to share;
a free site occupying anything other than one site;
a complex of denticity zero (it is not a member) or above one (ideal site mixing does not describe it; see the note on denticity above);
a duplicate member.
ChemistryLab.TotalSiteAmount Type
struct TotalSiteAmount{T<:Real} <: AbstractSiteCapacityA prescribed total number of moles of sites, independent of how much support is present. The honest description of a batch experiment on a fixed sorbent, and the form the first milestone's fixed-support scope uses.
Fields
N: total sites [mol].
ChemistryLab.VanselowMixing Type
struct VanselowMixing <: AbstractSiteMixingModelCation exchange in the Vanselow convention: the activity of an exchanger species is its mole fraction, counting molecules.
A calcium and a sodium on the exchanger count as one particle each, whatever charge they neutralize.
See also: GainesThomasMixing, and the note on conversion there.
ChemistryLab._refuse_stacked_electrostatics Method
_refuse_stacked_electrostatics(base, outer)Refuse an electrostatic decorator applied to another one.
Stacking a diffuse layer on a constant capacitance is how a Stern or a triple-layer model is drawn, and adding the two potentials is not how it works: the two capacitances belong to different charge planes, and each surface species sits on one plane or the other. Summing them puts every species on both. A plane-resolved model is a different object, and it is not in this package yet; refusing here is what keeps someone from assembling a wrong one out of right parts.
ChemistryLab._support_members Method
_support_members(cs) -> (indices, charges)Per family, the species indices and the formal charges of every member of every family on its support, in one flat list each and in the same order — what a surface charge density has to be summed over.
ChemistryLab.denticity Method
denticity(family::SiteFamily, sp::AbstractSpecies) -> IntHow many sites of family one molecule of sp occupies, read from the coefficient of the family's pseudo-element in its formula. Zero when sp does not belong to the family.
ChemistryLab.is_electrostatic Method
is_electrostatic(model) -> BoolWhether a site mixing model adds the work of charging a surface, on top of whatever mixing it decorates.
true for ConstantCapacitance and DiffuseLayer, false otherwise — including for a model that merely decorates one, which is why the predicate exists rather than an isa test at each use.
ChemistryLab.is_gradient_consistent Method
is_gradient_consistent(model) -> BoolWhether the activity contribution of a site mixing model is the gradient of a Gibbs energy — which is what the equilibrium certificate assumes about every term it certifies.
true for every model here but DiffuseLayer, whose potential depends on the ionic strength of a bulk solution that does not depend in return on the surface, making the activity Jacobian asymmetric. See that model's docstring for why this is the Dzombak-Morel approximation itself rather than a defect of the implementation, and site_gradient_asymmetry for the measurement.
ChemistryLab.name Method
name(family::SiteFamily) -> String
site_members(family::SiteFamily) -> Vector{<:AbstractSpecies}
site_capacity(family::SiteFamily) -> AbstractSiteCapacity
surface_support(family::SiteFamily) -> SurfaceSupportAccessors of a SiteFamily. members lists the free site first, which is the order the site mixing and the solver's reference member both expect.
ChemistryLab.needs_ionic_strength Method
needs_ionic_strength(model) -> BoolWhether evaluating a site mixing model requires the ionic strength of the aqueous solution.
Only DiffuseLayer does. The activity closures test this once, when they are built, and skip the ionic-strength sum entirely when no family asks for it — so a system without a diffuse layer pays nothing for the possibility of one.
ChemistryLab.reference_member Method
reference_member(family::SiteFamily) -> AbstractSpeciesThe member the mixing is written against, and the one the solver carries instead of inverting — site_members(family) puts it first for that reason.
On an oxide it is the unoccupied site, and family.free_site names it literally: as the surface fills, its amount falls and every occupied state costs more to form, which is where saturation comes from.
On a permanent-charge exchanger there is no unoccupied site at all: every charge is compensated by some cation, and what this returns is the form chosen as the reference of the exchange — usually the abundant monovalent one, Na-X. The field keeps the name free_site from the case it was written for; this accessor exists to say what it means in the case it was not.
ChemistryLab.site_mixing_model Method
site_mixing_model(family::SiteFamily) -> AbstractSiteMixingModelHow the members of family mix on their shared site budget.
ChemistryLab.site_moles Function
site_moles(capacity, support, n_host, n_host₀, M_host) -> RealMoles of sites the family offers, given how much host solid is present.
n_host and n_host₀ are the current and initial molar amounts of the support species [mol] and M_host its molar mass [kg/mol]. A TotalSiteAmount ignores all three; the other two do not, which is what will let a support that precipitates carry its sites with it.
Who calls this
You do. Nothing inside the solve does, and that is exactly what "the support is fixed" means here: the site budget reaches the calculation through the initial amounts of the site-bearing species, so this is the helper that computes the number you put there, evaluated once, before the solve.
Writing it as a function of the host's amount rather than as a constant is what makes an evolving support a change of when it is called, not of the data model — see the theory chapter §10 for the two things that still stand in the way.
ChemistryLab.site_moles Method
site_moles(family::SiteFamily, n_host, n_host₀, M_host) -> RealThe family's site budget, from its capacity and its support.
ChemistryLab.support_group Method
support_group(cs) -> Vector{Vector{Int}}For each site family of cs, the indices of every family sharing its support — itself included — identified by the support's name.
A surface carries one potential, not one per family. Ferrihydrite is the case that makes this concrete: Dzombak and Morel's strong and weak sites are two families on one oxide, so a proton bound to a weak site charges the same surface a proton bound to a strong site does, and both feel the same Ψ. Computing a potential from one family's members alone would make the two sites electrostatically invisible to each other, which is neither the published model nor the physics.
Empty when cs declares no surface.
ChemistryLab.supports_multidentate Method
supports_multidentate(model::AbstractSiteMixingModel) -> BoolWhether model describes a species occupying more than one site.
false by default, and deliberately: the site balance holds for any denticity — a species carrying two of the family's pseudo-elements contributes twice to it — but a mixing law does not follow from the balance. Counting the ways a molecule can straddle two neighboring sites is a combinatorial problem with its own answer, and returning a number without having solved it would be worse than refusing.
The ion-exchange conventions are the exception, and not because they solved that problem: on a permanent-charge exchanger the "sites" being counted are units of charge, which a divalent cation neutralizes two of without straddling anything. Their multidentate case is bookkeeping, not combinatorics.
See also: VanselowMixing, GainesThomasMixing.
ChemistryLab.water_relative_permittivity Function
water_relative_permittivity(T_K, P_Pa = 1.0e5) -> RealThe relative permittivity (dielectric constant) of liquid water at T_K kelvin and P_Pa pascal, from the Johnson-Norton (1991) model this package already carries for the HKF activity model.
It is 78.245 at 25 °C and 1 bar, and falls to 66.68 at 60 °C — which is why a surface electrostatic model calibrated at room temperature is not transferable to a hydrating paste without saying so.
This evaluates the water equation of state and costs milliseconds. It is meant for construction time, not for an inner loop; DiffuseLayer calls it once and stores the result.
ChemistryLab.with_electrostatic_scale Method
with_electrostatic_scale(model, λ) -> modelThe same model with its electrostatic term scaled by λ ∈ [0, 1], λ = 0 switching it off entirely and λ = 1 being the model itself.
This exists for one reason, and it is not a physical one: near the point of zero charge the diffuse-layer term is stiff, and a cold Newton solve walks off it. See ChemistryLab.electrostatic_stiffness for the number, and the manual for the continuation that uses this.
Returns the model unchanged for one that carries no electrostatics, so a continuation loop can be written without asking what it is solving.
The surface potential as an unknown
A DiffuseLayer written as an activity coefficient is exact and not always reachable: the solver's inner loop recovers a mixing phase with lnγ read at the previous iterate, which contracts only while electrostatic_stiffness stays below ELECTROSTATIC_STIFFNESS_LIMIT. Carrying the potential as an unknown of the outer Newton removes that fixed point rather than taming it.
ChemistryLab._compose_blocks Method
_compose_blocks(a, b) -> NamedTupleOne parameter block from two: the constraint's unknowns first, the system's after. Each side keeps its own indices into q, so neither has to know the other exists.
The one combination that is refused
Both sides may declare hq, the callback that rebuilds the activity model at the current unknowns — an adiabatic solve does, because the Debye-Hückel coefficients are functions of temperature, and a diffuse layer does, because it is told its potential. Composing two of them would mean threading one's parameter merge through the other's, and the shipped hq closures are opaque to that.
Rather than compose them wrongly, this refuses and says so. A diffuse layer under an adiabatic constraint is not a case anyone has yet; when someone does, the fix is to have each block contribute the params it merges instead of a whole hq, and this error message is where they will start.
ChemistryLab._potential_families Method
_potential_families(cs) -> Vector{Int}Which of cs's site families need a potential unknown, by index.
cs.site_families is nothing — not an empty vector — for a system that declares no surface, which is most of them, so the guard is the first line rather than an afterthought.
ChemistryLab._potential_supports Method
_potential_supports(cs) -> Vector{Vector{Int}}The distinct supports of cs that need a potential unknown, each as the list of family indices on it.
One unknown per surface, not per family: ferrihydrite's strong and weak sites are two families on one oxide and share one Ψ. See support_group.
Refuses a support whose families disagree — one asking for a potential and another not, or two declaring different areas for the same surface. Both are incoherent rather than merely unusual, and both would otherwise produce a number.
ChemistryLab._scatter Method
_scatter(cs, supports, q) -> VectorEach surface's potential placed at every family standing on it, nothing elsewhere, so the activity kernel can index by family without knowing which families share a surface.
ChemistryLab._surface_potential_blocks Method
_surface_potential_blocks(des, state, p, n0) -> NamedTuple or nothingThe parameter block the system contributes, as opposed to the one its constraint does: one unknown ψ̃ = FΨ/RT per surface that needs it, with the Gouy-Chapman closure as its equation.
nothing when no surface needs one, which is every system without a diffuse layer — so nothing pays for the possibility.
The closure, and why its residual is already dimensionless
σ_k is summed over every member of every family on the k-th surface. ψ̃ is in units of RT by construction, which is what the stationarity rows are in, so no scaling is needed — unlike the enthalpy residual of an adiabatic solve, which is 10⁵ J and has to be divided by RT before it can sit in the same Newton system.
q0 = 0 starts the solve on an uncharged surface, which is the composition a solve without electrostatics would return and therefore the natural cold start.
ChemistryLab.needs_potential_unknown Method
needs_potential_unknown(model) -> BoolWhether a site mixing model's electrostatic term must be carried as an unknown of the solve rather than evaluated from the composition.
true for DiffuseLayer and false for everything else, including ConstantCapacitance — whose potential is linear in the composition, so the fixed point contracts and eliminating it costs nothing.
The site budget, and the declaration it has to match
A family declares a capacity; a state carries amounts. Until these functions existed nothing compared the two, so a capacity could be changed with no effect on the answer and a certificate could report optimal = true over a budget the declaration contradicted.
ChemistryLab.REFERENCE_SITE_DENSITY Constant
REFERENCE_SITE_DENSITYREFERENCE_SITE_DENSITY_NM2 in mol/m², derived through AVOGADRO rather than written again — 2.0009e-5 mol/m².
That it comes out so close to a round 2 × 10⁻⁵ mol/m² is not a coincidence: that is the number the convention was chosen to be, and 12.05 nm⁻² is how it reads in the units the surface literature uses.
ChemistryLab.REFERENCE_SITE_DENSITY_NM2 Constant
REFERENCE_SITE_DENSITY_NM2The conventional reference site density Γ° = 12.05 nm⁻² of (Kulik, 2002), used to define the standard state of a monodentate surface species.
What it is for
An intrinsic adsorption constant is not a property of a surface alone: it is fitted at some total site density Γ_C, and the value depends on that choice. Dzombak and Morel fitted their hydrous-ferric-oxide constants at 2.254 nm⁻² for the weak sites and 0.056 nm⁻² for the strong ones, a factor of forty apart, so two of their own constants are not directly comparable with each other, let alone with a constant from another compilation.
Fixing one conventional Γ° for every sorbent and every surface makes them comparable, and convert_logk_site_density is the conversion. This is a choice of concentration scale, not a measurement: it is one number, it is stated here rather than assumed, and it can be changed.
ChemistryLab.InconsistentSiteBudget Type
InconsistentSiteBudget <: ExceptionA state whose site amounts contradict the capacity its family declares.
Carries the family, the residual in mol, and the scale it was judged against, so the message says how far off it is rather than only that it is off.
ChemistryLab._is_bare_site Method
_is_bare_site(sp, site::Symbol) -> BoolWhether sp carries the site pseudo-element site and nothing else — no real atom, no charge.
Such a species is not a chemical species at all. It is a component: the pure site, with no matter attached. That is exactly what a coupled family needs its primary to be, and why one is allowed to sit among the primaries without being among the species.
ChemistryLab._reference_matter_energy Method
_reference_matter_energy(cs, family, r, jf) -> Union{Float64, Nothing}The standard energy of the free site's own decomposition over the primaries other than its site component — the energy of the matter the free site carries. nothing if any primary it needs has no standard energy.
ChemistryLab._refuse_biased_coupling Method
_refuse_biased_coupling(cs, family)Refuse a coupled family whose free site is not referenced to the matter in it.
host_coupling_bias says what this measures and why a fixed budget has no equivalent. This is the gate: a modeling gap worth more than 0.05 log units on the host's own solubility is not a detail of the answer, it is the answer.
ChemistryLab._refuse_unidentifiable_site Method
_refuse_unidentifiable_site(cs, family, A, r)Refuse a coupled family whose site potential the basis cannot determine.
What goes wrong, measured
Every species carrying a site symbol carries it together with a fixed amount of charge: XsOH decomposes as H₂O − H⁺ + Xs + Zz and XsOCa⁺ as Ca²⁺ + H₂O − 2H⁺ + Xs + Zz. Where that is the only way Zz enters, the site row and the charge row of the coupled matrix are the same row up to the host entry, so only y_Xs + y_Zz is identifiable.
The solver finds that out the hard way. Measured on portlandite carrying sites, with a neutral bare component: the two multipliers ran to +234 650 and −234 710 — four decades past any chemical potential — while their sum stayed at −60; the dual Newton stalled at a KKT error of 2.3e-9; and the host came out at 0.0999 mol where the uncoupled answer is 0.0883.
Giving the component the charge the free site carries with its site symbol removes the Zz primary altogether. The same run then converges at 4.8e-11, max|y| is 227, the host lands on 0.08826 against 0.08827 uncoupled, and the coupling holds to 1.8e-7.
Why this is measured and not inferred from Zz being a primary
Refusing on the presence of a charge component is what this did first, and it refuses systems that are perfectly well posed. Zz survives as a primary whenever charge is genuinely independent of the element rows — which is the ordinary situation in a redox system. Measured, on hydrous ferric oxide over Fe(OH)₃(am) with chloride and a mixed-valence iron speciation, singular values of the coupled matrix:
| system | component | σ (smallest three) | identifiable rank |
|---|---|---|---|
portlandite, no Zz row possible | Xs+ | 1.19, 0.872 | 4 of 4 |
| portlandite | Xs | 1.21, 0.957, 1.7e-5 | 4 of 5 |
| HFO, Fe(III) only | Xs | 2.16, 1.39, 0.105 | 4 of 5 |
| HFO, Fe(II) and Fe(III), chloride | Xs+ | 1.90, 1.35, 0.916 | 6 of 6 |
| HFO, Fe(II) and Fe(III), chloride | Xs | 2.14, 1.42, 0.656 | 6 of 6 |
The last two carry a Zz primary and are not degenerate at all — the charge row is nonzero on an iron chloride complex, so it is not the site row. The old test refused both of them, and the charge it then suggested was itself refused on the next call: the two suggestions pointed at each other and the user went in a circle.
identifiable_rank on the singular values separates the two groups with the spectral gap it is built for — a factor of 13 at the tightest refusal against 2.2 at the tightest acceptance. The charge suggested on a refusal is the coefficient of Zz in the free site's own decomposition, which is correct exactly where the refusal now fires.
ChemistryLab._standard_gibbs Method
_standard_gibbs(sp) -> Union{Float64, Nothing}ΔₐG⁰ at 298.15 K and 1 bar, in J/mol, or nothing when the species carries none. A missing property reads back as an Int zero rather than as an error (species.jl), so the test is on the type and not on the value: a species whose energy is genuinely zero must not be confused with one that has none.
ChemistryLab.check_site_budget Method
check_site_budget(state::ChemicalState; rtol = 1e-6)Throw InconsistentSiteBudget when a family's state amounts and its declared capacity disagree by more than rtol of the declaration.
The tolerance is relative to the declaration, not absolute: a site budget is 1e-6 mol on an oxide and 1e-1 mol on a clay, so an absolute threshold would be meaningless on one of them. A declaration of exactly zero is compared absolutely against the same rtol, since nothing else is available.
Where 1e-6 comes from
From the floor, not from a round number. A state is normally initialized with the occupied sites at the solver's 1e-12 floor rather than at zero, so a family with two complexes and a 1e-3 mol capacity is correctly initialized and still 2e-9 off in relative terms — measured. A default of 1e-9 rejects that, which is a check that fires on the ordinary case.
This exists to catch a declaration error, and those are orders of magnitude: the case it was written for declared 1e-6 mol against a state carrying 1e-3, a factor of a thousand. 1e-6 catches that with nine decades to spare and leaves the floor alone.
ChemistryLab.conservation_matrix Method
conservation_matrix(cs::ChemicalSystem) -> Matrix{Float64}The matrix the equilibrium is constrained with: SM.A as it stands when no family follows its host, and SM.A with ν subtracted from each coupled family's (site row, host column) entry when one does.
That single entry is the whole coupling. It states
and site_coupling_rows returns the same statement in row form, which is how the two are checked against each other.
Why one entry is enough, and why it was not before
Subtracting from a row of the projected matrix subtracts the primary's whole composition, not the site symbol alone: M = M_indep A, so a correction v in primary coordinates removes M_indep v of matter. Getting the pure site out of it needs M_indep v = Xs_unit, a preimage of the bare pseudo-element.
With the free site for primary that preimage does not exist. Measured, as the least-squares residual ‖M_indep v − Xs_unit‖: 0.378 on an amphoteric oxide over [:H, :O, :Xs, :Zz], 0.500 on a cation exchanger over [:Na, :K, :H, :O, :Xc, :Zz]. The reason is structural — a site symbol never appears alone, every species carrying it carries it attached to matter, and only one site species can be primary. Subtracting anyway invents ν moles of oxygen and ν of hydrogen per mole of host: seven percent of the oxygen of Fe(OH)₃ at Dzombak and Morel's weak-site density.
Declaring the bare site as the primary removes the obstruction rather than working around it. The residual is then 0.0 exactly and the preimage is the unit vector, so subtracting ν from that one entry subtracts ν times a component carrying no atom and no charge. Element conservation is exact by construction, and nothing else in the matrix moves.
The component carries the charge the free site carries with its site symbol — XsOH is Xs⁺ + OH⁻, so the component is Xs+. A neutral one leaves Zz among the primaries and the basis is then degenerate; _refuse_parasitic_charge says so, with what it costs.
Measured
Uncoupled, the substitution costs nothing: the same system over a bare-site component and over the free site returns the same host amount to eight digits, converges in both, elements to 5.6e-16 against 1.8e-15.
Coupled, on portlandite carrying sites at Γ = 1e-5 mol/m² over 90 m²/kg, at three host amounts:
| host | converged | Σdn / n_host / ν − 1 | elements | host, coupled vs fixed |
|---|---|---|---|---|
| 0.05 | yes, kkt 1.4e-14 | 3.9e-7 | 4.2e-15 | 0.0382614 / 0.0382737 |
| 0.10 | yes, kkt 5.7e-14 | 1.7e-7 | 1.5e-15 | 0.0882592 / 0.0882715 |
| 0.20 | yes, kkt 4.5e-11 | 8.0e-8 | 2.1e-12 | 0.1882548 / 0.188267 |
The fixed budget is off by 13 %, 6 % and 31 % on the same three, which is not a defect of it: a budget that does not follow its host cannot track one.
ChemistryLab.convert_logk_site_density Method
convert_logk_site_density(logK, Γ_C; Γ0 = REFERENCE_SITE_DENSITY_NM2,
free_site_side = :reactant) -> Float64An intrinsic adsorption constant moved from the total site density it was fitted at to a reference one — equation (21) of (Kulik, 2002):
with the sign of the last term inverted when the neutral functional group sits on the product side of the reaction, which is what free_site_side selects. Written ≡OH + H⁺ = ≡OH₂⁺ the free site is a reactant, the default.
Only the ratio of the two densities enters, so they may be given in any unit as long as it is the same one. The default Γ0 is in nm⁻², which is how the surface literature quotes a site density.
Example
Dzombak and Morel's own two densities, which is the case Kulik uses to make the point that their weak and strong constants are not on one scale:
julia> using ChemistryLab
julia> round(convert_logk_site_density(0.0, 2.254), digits = 2) # weak sites
-0.73
julia> round(convert_logk_site_density(0.0, 0.056), digits = 2) # strong sites
-2.33A constant fitted at a density below the reference moves down, and by different amounts for the two site types — 1.6 log units apart here. Correlating one against the other without this conversion compares two different scales.
ChemistryLab.declared_site_moles Method
declared_site_moles(state::ChemicalState, family::SiteFamily) -> Float64The moles of sites family declares, evaluated on state — that is, site_moles supplied with the host amount and molar mass the state carries.
A TotalSiteAmount ignores all three and returns its number. The other two do not, so a family that measures its capacity per unit mass or per unit area needs a host, and one declared without naming a species is refused here rather than quietly evaluated at zero — a capacity of zero is a different model, not a missing input.
ChemistryLab.host_consistent_state Method
host_consistent_state(state::ChemicalState) -> ChemicalStatestate with each family's free-site amount derived from its declared capacity instead of taken from the caller:
so site_budget_residual comes back zero and the conservation row the solver forms says what the family declared.
The free site is the one adjusted, and deliberately: it is the family's reference member, the species whose amount carries no chemistry of its own. An occupied site holds a sorbate whose element budget the rest of the system accounts for, so moving it would silently move that element too.
Refused when the complexes already hold more sites than the capacity allows — that is not an initialization to repair but a declaration to correct, and scaling the complexes down to fit would invent a composition nobody asked for.
Everything outside the site families is copied unchanged.
ChemistryLab.host_coupling_bias Method
host_coupling_bias(cs::ChemicalSystem) -> OrderedDict{String, Float64}Per coupled family, the shift in log SI that the coupling imposes on the host at standard state — the size of the modeling gap described below, in the units the answer is read in.
Why a coupled family has one and a fixed one does not
With a fixed budget the free site's ΔₐG⁰ cancels out of every surface reaction, since both sides carry a site. It is a gauge, and the suite measures it as one: shifting a whole family by 20 kJ/mol moves nothing by more than 5e-14.
With a budget that follows its host, the host carries −ν of the site component, so the site potential enters the host's own chemical potential and the reference energy has stopped being a gauge. Where that shows depends on the host: a phase that is present at an equilibrium has log SI = 0 by stationarity whatever the potentials are, so the effect is on its amount; a phase that is absent shows it directly in its index.
What it has become is not a convention either. XsOH carries a real oxygen and a real hydrogen, so giving it ΔₐG⁰ = 0 states that a surface hydroxyl forms from the elements for nothing. That is wrong by the energy of the matter in it, and this function measures exactly that:
the second term being the standard energy of the free site's own decomposition over the other primaries — μ°(H₂O) − μ°(H⁺) for an oxide, μ°(Na⁺) for a sodium exchanger. It is general because the matrix supplies it.
What the number means, measured
At the site density a cement paste implies — Γ = 10⁻⁵ mol/m² over 90 m²/kg, so ν = 6.7e-5 — the bias is 0.003 log units and the coupling is harmless. At Dzombak and Morel's weak-site density for hydrous ferric oxide, ν = 0.2, it is 8.3 log units: with the reference at zero the host came back 2.3 log units undersaturated and dissolved completely, where the same system with a fixed budget holds its solid at equilibrium.
What setting it fixes, measured
With ΔₐG⁰(free site) = μ°(H₂O) − μ°(H⁺) = −237.2 kJ/mol, the bias is zero and the same hydrous ferric oxide at ν = 0.2 keeps its solid: 9.999993e-4 mol against 9.999693e-4 with a fixed budget — three parts in 10⁵ — the site total is 0.2 times the host amount to seven digits, the solve certifies, and the host reports log SI = −2e-13.
That is a reference, not a fitted number: it is what the free site is made of, read off the same matrix the constraint is built from. It is nonetheless a statement this package makes rather than one a database supplies, which is why it is measured here instead of assumed.
(Kulik, 2002) reaches the same place from the other side and is worth reading before relying on this: he keeps the free site out of the balance entirely, as a surface monolayer solvent of fixed activity with μ_n = 0, and carries the capacity in a surface activity term. That formulation needs no reference energy at all, and it is the one to move to if this ever has to hold at densities where the approximation shows.
See also: sites_per_host, conservation_matrix.
ChemistryLab.present_site_moles Method
present_site_moles(state::ChemicalState, family::SiteFamily) -> Float64The moles of sites the state actually carries for family: Σ dᵢ nᵢ over its members, with dᵢ the denticity read from each member's formula.
This is exactly the left-hand side of the site conservation row, so comparing it with declared_site_moles compares the declaration against what the solver will conserve.
ChemistryLab.site_budget_residual Method
site_budget_residual(state::ChemicalState) -> OrderedDict{String, Float64}Per declared family, present − declared in mol.
Zero means the state's site amounts and the family's capacity say the same thing. Anything else means the calculation will conserve the amounts and ignore the declaration, which is what it did unconditionally before this existed.
Returns an empty dictionary for a system with no surface, so it is safe to call on anything.
See also: host_consistent_state, InconsistentSiteBudget.
ChemistryLab.site_coupling_rows Method
site_coupling_rows(cs::ChemicalSystem) -> (Matrix{Float64}, Vector{String})One row per family whose support is SITES_FOLLOW_HOST, stating that the sites in use equal ν times the host's amount:
with d_k the denticity of each member. Returns the rows and the family names that label them; both are empty when nothing is coupled, so a system without a coupled surface gets back exactly what it had.
Why an added row, and not a coefficient in the composition matrix
Because the obvious alternative is impossible, and that is worth stating once rather than rediscovering.
Writing the coupling as A[site, host] -= ν in the projected matrix changes what the system conserves: the rows of A are indexed by primary species, so subtracting there subtracts the free site's whole composition — and a free site carries real atoms, an oxygen and a hydrogen for XsOH. The system then conserves M n − ν m_free n_host, which creates ν moles of oxygen and ν of hydrogen per mole of host. For Dzombak and Morel's weak sites, ν = 0.2: seven percent of the oxygen of Fe(OH)₃, invented.
The repair would be to subtract ν times a preimage of the pure site pseudo-element instead. No such preimage exists. Measured on two systems of different structure — an amphoteric oxide over [:H, :O, :Xs, :Zz] and a cation exchanger over [:Na, :K, :H, :O, :Xc, :Zz] — the least-squares residual ‖M_indep v − Xs_unit‖ comes out 0.378 and 0.500, not zero. The reason is structural rather than a quirk of a basis: a site symbol never appears alone. Every species carrying it carries it attached to matter, and only one site species can be primary, so no combination of primaries yields a bare site with every real atom and the charge at zero.
An added row has none of this to answer for. SM.A is the encoding of element conservation, and leaving it alone leaves that conservation exact: the surface species carry their own oxygen and hydrogen in their own formulas, the host carries its own, and growing the site population draws them from the water through the ordinary element rows. Automatically, with nothing to correct.
What it does cost is that saturation_indices reads SM.A and does not see this row; the host's reported index has to be taught about it separately, or it would disagree with the stationarity the solver actually reached.
These rows state the constraint; they are not how it is imposed
conservation_matrix imposes it, as a single −ν in the site row's host column. This returns the same statement in row form, which is what lets the two be checked against each other rather than believed.
Appending these rows instead does not work, and the measurement is worth keeping: SM.A already carries a site row, so a second one tying the same sum to the host leaves two equations on one quantity, and together they say n_host = n_host,0 — the host may not dissolve at all. Run on portlandite, the solve returned MaxIters, the host moved from 0.1 to 0.0883 mol regardless, and the site total stayed at exactly ν × 0.1: the solver satisfied the old row and violated the new one. The constraint has to replace the site row, not join it.
ChemistryLab.sites_per_host Method
sites_per_host(family::SiteFamily, M_host) -> Float64ν, the moles of sites one mole of the host carries, for a family whose support is SITES_FOLLOW_HOST.
This is Kulik's equation (30) read as a coefficient: the moles of sites of a surface type are ψ · A_s · M · Γ times the moles of sorbent, so the whole dependence on the host is one number multiplying its amount.
The capacity has to be homogeneous of degree one, and that is measured
ν only exists if the capacity really is proportional to the host's amount, and which capacities are is not a list to maintain but a property to check. This evaluates site_moles at two scaled amounts and requires the answer to scale with them:
with n₀ held fixed. Scaling n₀ along with n would make a ShrinkingCoreArea's ratio n/n₀ equal one at every λ and hide exactly the nonlinearity this exists to find.
What the probe accepts and refuses follows without being enumerated: MassSiteDensity and AreaSiteDensity over a specific area pass, being q M n and Γ a M n; a TotalSiteAmount and an AreaSiteDensity over a FixedSurfaceArea are refused, being constants that do not follow anything; and a ShrinkingCoreArea passes only at p = 1, where it genuinely is linear. A capacity type written later is judged on the same evidence rather than on whether somebody remembered to add it here.
A capacity of zero is refused too: it passes the proportionality test for the empty reason that 0 = λ·0, and it is a family with no sites, which is a declaration to correct.