Skip to content

Utilities ​

Module ​

ChemistryLab.ChemistryLab Module
julia
ChemistryLab

Top-level module for parsing, representing and manipulating chemical formulas, species, stoichiometric matrices and ThermoFun / PHREEQC-like data.

Overview

  • Canonical containers: AtomGroup, Formula.

  • Species representations: Species, CemSpecies.

  • Parsers and IO helpers for ThermoFun / PHREEQC data (substances, reactions).

  • Stoichiometric matrix construction and reaction helpers.

  • Utilities for units, colored and Unicode terminal output.

Examples

julia
julia> using ChemistryLab

julia> f = Formula("H2O")
Formula{Int64}
    formula: H2O ◆ H₂O
composition: H => 2, O => 1
     charge: 0

julia> f[:H]
2

julia> H2O = Species(f; name="Vapour", symbol="H₂O", aggregate_state=AS_GAS, class=SC_GASFLUID)
Species{Int64}
           name: Vapour
         symbol: H₂O
        formula: H2O ◆ H₂O
          atoms: H => 2, O => 1
         charge: 0
aggregate_state: AS_GAS
          class: SC_GASFLUID
     properties: M = 0.0180149999937744 kg mol⁻¹

julia> CO2 = Species(
           Dict(:C=>1, :O=>2);
           name="Carbon dioxide",
           symbol="CO₂",
           aggregate_state=AS_GAS,
           class=SC_GASFLUID,
       ) # definition from Dict
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⁻¹

julia> O2 = Species("O2"; name="Dioxygen", symbol="O₂", aggregate_state=AS_GAS, class=SC_GASFLUID) # definition from String
Species{Int64}
           name: Dioxygen
         symbol: O₂
        formula: O2 ◆ O₂
          atoms: O => 2
         charge: 0
aggregate_state: AS_GAS
          class: SC_GASFLUID
     properties: M = 0.03199799998894218 kg mol⁻¹

julia> C3H8 = Species(
           "C₃H₈"; name="Propane", symbol="C₃H₈", aggregate_state=AS_GAS, class=SC_GASFLUID
       ) # definition from Unicode String
Species{Int64}
           name: Propane
         symbol: C₃H₈
        formula: C₃H₈ ◆ C3H8
          atoms: C => 3, H => 8
         charge: 0
aggregate_state: AS_GAS
          class: SC_GASFLUID
     properties: M = 0.04409699998476102 kg mol⁻¹

julia> r = Reaction([C3H8, O2, CO2, H2O])
  equation: C₃H₈ + 5O₂ = 4H₂O + 3CO₂
 reactants: C₃H₈ => 1, O₂ => 5
  products: H₂O => 4, CO₂ => 3
    charge: 0

Physical constants ​

The gas constant, the Faraday constant and the electric constant, taken from DynamicQuantities rather than written down, each in two forms: the bare SI number for arithmetic and the dimensioned quantity for anything that must carry its unit. See Where the numbers come from for how to reach these, and everything else the package already knows, from a script.

ChemistryLab.AVOGADRO Constant
julia
AVOGADRO

The Avogadro constant as a plain Float64 in mol⁻¹, derived from AVOGADRO_Q.

ChemistryLab.AVOGADRO_Q Constant
julia
AVOGADRO_Q

The Avogadro constant with its dimensions, 6.02214076e23 mol⁻¹, from DynamicQuantities.Constants.N_A (exact by the 2019 SI redefinition).

Surface chemistry states site densities per square nanometer and the rest of this package works in moles per square meter; this is the only bridge between them, so it is taken from the library rather than written down.

ChemistryLab.FARADAY Constant
julia
FARADAY

The Faraday constant as a plain Float64 in C/mol, derived from FARADAY_Q. Used by the Nernst relation between pe and Eh.

ChemistryLab.FARADAY_Q Constant
julia
FARADAY_Q

The Faraday constant with its dimensions, 96485.332… C/mol, from DynamicQuantities.Constants.F (CODATA, exact by the 2019 SI redefinition).

ChemistryLab.R_GAS Constant
julia
R_GAS

The molar gas constant as a plain Float64 in J/(mol·K), derived from R_GAS_Q rather than written again.

This is the form the inner loops want: an activity model is evaluated once per species per Newton iteration, on Float64 and on ForwardDiff.Dual, and a dimensional Quantity there costs an allocation each time for a factor whose units are known at the call site anyway.

ChemistryLab.R_GAS_Q Constant
julia
R_GAS_Q

The molar gas constant with its dimensions, 8.314462618… J/(mol·K), taken from DynamicQuantities.Constants.R (CODATA).

Use this wherever the surrounding quantities carry units. For the plain number used inside an activity model or a rate law, see R_GAS.

ChemistryLab.VACUUM_PERMITTIVITY Constant
julia
VACUUM_PERMITTIVITY

The electric constant as a plain Float64 in F/m, derived from VACUUM_PERMITTIVITY_Q. Used by the Gouy-Chapman relation between a surface charge density and the potential it raises, in DiffuseLayer.

ChemistryLab.VACUUM_PERMITTIVITY_Q Constant
julia
VACUUM_PERMITTIVITY_Q

The electric constant with its dimensions, 8.8541878128e-12 F/m, from DynamicQuantities.Constants.eps_0 (CODATA).

ChemistryLab.RT_over_F Method
julia
RT_over_F(T)

RT/F in volts at temperature T in kelvin — the natural scale of an electrochemical potential, and the factor in Nernst's relation. At 25 °C, RT ln(10)/F is the familiar 0.05916 V per unit of pe.

Miscellaneous helpers ​

ChemistryLab.COL_CHARGE Constant

Terminal color for charge display (cyan bold).

ChemistryLab.COL_PAR Constant

Terminal color for parentheses (magenta bold).

ChemistryLab.COL_STOICH_EXT Constant

Terminal color for external stoichiometric coefficients (yellow bold).

ChemistryLab.COL_STOICH_INT Constant

Terminal color for internal stoichiometric coefficients (red bold).

ChemistryLab._adim Method
julia
_adim(x) -> Real

x as a bare number, checking that it carries no dimension.

What it replaces, and why that had to go

thermo_factories.jl used to extend some thirty Base math functions to DynamicQuantities.Quantity by stripping the unit first:

julia
Base.log(x::Quantity) = log(ustrip(x))     # removed

That is type piracy — the function and the type both belong elsewhere — so it applied to every package loaded beside this one, whether or not it asked. And it did not merely remove a dimension check; it made the result depend on an invisible normalization, because ustrip returns the value in SI base units:

julia
log(2u"m")       = 0.693      instead of raising
log(1u"mol/L")   = 6.908      the same quantity written two ways,
log(1u"mmol/L")  = 0.0        two different answers

What this does instead

Dispatch rather than a branch, so Float64, ForwardDiff.Dual and Symbolics.Num all take the first method untouched — none of them carries a dimension — and only a genuine Quantity pays for the test.

A dimensionless Quantity passes, which is the case the extensions existed for: a ratio whose units cancel is a number, and saying so explicitly is the point. Anything else raises, which is the check being restored rather than a new restriction.

ChemistryLab._ensure_unit Method
julia
_ensure_unit(unit, x::Real) -> Quantity
_ensure_unit(unit, x) -> Quantity

Ensure a value is a Quantity with the given unit.

  • Plain Real → interpreted as SI, wrapped: x * unit.

  • Quantity → converted to unit via safe_uconvert.

ChemistryLab._primal Method
julia
_primal(x::Real) -> Real

Extract the primal (non-derivative) value of x for use in control-flow decisions.

For plain Real numbers this is a no-op. For forward-mode AD types such as ForwardDiff.Dual (which are subtypes of Real) this returns x itself, but since those types overload comparison operators to compare only the primal value, writing _primal(x) > threshold makes the intent explicit and ensures that branching decisions are always taken on physical values rather than derivatives.

Adding a method _primal(x::YourDualType) = x.value enables support for non-standard AD frameworks that do not overload comparison operators.

Examples

julia
julia> _primal(3.14)
3.14
ChemistryLab.force_uconvert Method
julia
force_uconvert(qout::UnionAbstractQuantity, q) -> AbstractQuantity

Strip units from q (converting to qout dimensions when possible) and re-attach the unit of qout. Unlike safe_uconvert, this always returns a quantity with the units of qout, even when the input is dimensionless.

ChemistryLab.print_title Method
julia
print_title(title; crayon=:none, indent="", style=:none)

Print a formatted title with optional styling and indentation.

Arguments

  • title: title string to display.

  • crayon: Crayon for colored output (default :none).

  • indent: left indentation string (default "").

  • style: formatting style - :none, :underline, or :box (default :none).

Examples

julia
julia> print_title("Test Title"; style=:none)
Test Title

julia> print_title("Section"; style=:underline, indent="  ")
  Section
  ───────
ChemistryLab.root_type Method
julia
root_type(T) -> Type

Get the root type wrapper from a potentially parameterized type.

ChemistryLab.safe_uconvert Method
julia
safe_uconvert(qout::UnionAbstractQuantity{<:Any, <:AbstractSymbolicDimensions}, q::UnionAbstractQuantity{<:Any, <:Dimensions}) -> UnionAbstractQuantity
safe_uconvert(qout::UnionAbstractQuantity{<:Any, <:AbstractSymbolicDimensions}, q) -> Any

Convert q to the unit type represented by qout using uconvert when q is a quantity with compatible dimensions. If q is a plain numeric value, return q unchanged.

Arguments

  • qout : unitful quantity target (e.g., u"m", u"kg").

  • q : a quantity or a plain numeric value.

Returns

  • Converted quantity (of type qout) or the original q when q has no units.

Examples

julia
julia> safe_uconvert(us"m", 5u"cm")
0.05 m

julia> safe_uconvert(us"m", 3.0)
3.0
ChemistryLab.safe_ustrip Method
julia
safe_ustrip(unit::UnionAbstractQuantity, q::UnionAbstractQuantity) -> Number
safe_ustrip(unit::UnionAbstractQuantity, q) -> Number

Safely remove units from q, converting to unit when their dimensions match. If q is a plain numeric value (no units) it is returned unchanged.

When the dimensions of unit and q differ (e.g. unit is dimensionless but q carries a physical dimension) the conversion is skipped and q is stripped in its native unit. This prevents a DimensionError in contexts such as ThermoFactory where a dimensionless placeholder unit u"1" may be passed alongside a quantity with physical dimensions.

Examples

julia
julia> safe_ustrip(u"m", 5u"m")
5.0

julia> safe_ustrip(u"cm", 1u"m")
100.0

julia> safe_ustrip(u"m", 3.0)
3.0

Sub/superscripts ​

ChemistryLab.dict_all_normal_to_sub Constant

Extended mapping from normal-line characters (digits, letters, operators) to Unicode subscripts.

ChemistryLab.dict_frac_unicode Constant

Mapping from Rational fractions to their Unicode vulgar fraction strings.

ChemistryLab.dict_normal_to_sub Constant

Mapping from normal-line digits and signs to their Unicode subscript equivalents.

ChemistryLab.dict_normal_to_super Constant

Mapping from normal-line characters to their Unicode superscript equivalents.

ChemistryLab.dict_sub_to_normal Constant

Mapping from Unicode subscript digits to their normal-line equivalents.

ChemistryLab.dict_super_to_normal Constant

Mapping from Unicode superscript characters to their normal-line equivalents.

ChemistryLab.dict_unicode_frac Constant

Mapping from Unicode vulgar fraction characters to Rational values.

ChemistryLab.all_normal_to_sub Method
julia
all_normal_to_sub(s::AbstractString) -> String

Convert all normal characters (including letters and operators) to subscripts.

ChemistryLab.from_subscriptnumber Method
julia
from_subscriptnumber(s::String) -> Int

Parse a Unicode subscript number string to an integer.

Examples

julia
julia> from_subscriptnumber("₄₂")
42

julia> from_subscriptnumber("₋₃")
-3
ChemistryLab.from_superscriptnumber Method
julia
from_superscriptnumber(s::String) -> Int

Parse a Unicode superscript number string to an integer.

Examples

julia
julia> from_superscriptnumber("⁴²")
42

julia> from_superscriptnumber("⁻²")
-2
ChemistryLab.issubscript Method
julia
issubscript(c::Char) -> Bool

Return whether c is a numeric subscript.

Examples

julia
julia> issubscript('₂')
true

julia> issubscript('2')
false
ChemistryLab.issuperscript Method
julia
issuperscript(c::Char) -> Bool

Return whether c is a numeric superscript or ⁺/⁻.

Examples

julia
julia> issuperscript('²')
true

julia> issuperscript('a')
false
ChemistryLab.normal_to_sub Method
julia
normal_to_sub(s::AbstractString) -> String

Convert all normal characters in s to numeric subscripts.

Examples

julia
julia> normal_to_sub("H2O")
"H₂O"
ChemistryLab.normal_to_super Method
julia
normal_to_super(s::AbstractString) -> String

Convert all normal characters or +/- in s to numeric superscripts.

Examples

julia
julia> normal_to_super("2+")
"²⁺"
ChemistryLab.sub_to_normal Method
julia
sub_to_normal(s::AbstractString) -> String

Convert all numeric subscripts in s to normal line.

Examples

julia
julia> sub_to_normal("H₂O")
"H2O"
ChemistryLab.subscriptnumber Method
julia
subscriptnumber(i::Integer) -> String

Convert an integer to its Unicode subscript representation.

Examples

julia
julia> subscriptnumber(42)
"₄₂"

julia> subscriptnumber(-3)
"₋₃"
ChemistryLab.super_to_normal Method
julia
super_to_normal(s::AbstractString) -> String

Convert all numeric superscripts or ⁺/⁻ in s to normal line.

Examples

julia
julia> super_to_normal("Ca²⁺")
"Ca2+"
ChemistryLab.superscriptnumber Method
julia
superscriptnumber(i::Integer) -> String

Convert an integer to its Unicode superscript representation.

Examples

julia
julia> superscriptnumber(42)
"⁴²"

julia> superscriptnumber(-2)
"⁻²"

Provenance ​

A number that says how it was obtained and from where, so the claim survives out of a data file and into a table, a figure or a fitted result. See Where the numbers come from for when to reach for it.

ChemistryLab.ProvenanceKind Type
julia
ProvenanceKind

How a number came to be, ordered from the weakest claim to the strongest.

  • PROV_PLACEHOLDER — a value standing in for one nobody has yet. It exists so a calculation can be demonstrated, and it is not evidence of anything.

  • PROV_ESTIMATED — borrowed from an analog or inferred from a correlation. Somebody reasoned, nobody measured.

  • PROV_FITTED — identified from data by an optimization. As sound as the data, the model and the identifiability of the parameter, which is three separate questions.

  • PROV_PUBLISHED — taken from a named source that reports it as a value.

  • PROV_MEASURED — determined experimentally in the work at hand.

  • PROV_UNSTATED — nothing is claimed. Deliberately the weakest, so a number that forgot to say where it came from never strengthens a result.

The order is what weakest uses, and the ordering is the point: a result is only as sound as its least sound input.

ChemistryLab.Traced Type
julia
struct Traced{T}

A value carrying how it was obtained and from where.

Traced is not a Real. That is deliberate: a number that flowed silently into arithmetic would arrive at the far end with its provenance gone, and the whole point is that losing it should be an act rather than an accident. Unwrap it with value at the boundary where it enters a calculation, which is where a reader can see the claim being dropped.

Fields

  • value: the number.

  • kind: a ProvenanceKind.

  • source: free text — a database and version, a paper, the analog a value was borrowed from, the dataset it was fitted to.

  • uncertainty: what the source says about how well it is known, in the same unit as value, or nothing when it says nothing. Published sorption compilations report it per entry — ClaySor 2023 writes error: 0.17 beside a log K — and a compilation where half the constants are known to 0.1 and half to 0.5 is not one number's worth of information.

Example

julia
julia> using ChemistryLab

julia> ε = Traced(0.05, PROV_ESTIMATED, "analog: SO4-2");

julia> ChemistryLab.value(ε), provenance(ε)
(0.05, PROV_ESTIMATED)

julia> is_evidence(ε)
false

value and source are not exported, and reaching for them through the module is not an oversight. Both names are taken: Symbolics and SciMLBase each export a value, so exporting one here would make the bare name ambiguous for anyone loading either beside this package — and a doc page that did would fail on use rather than on import.

See also: weakest, provenance_report.

ChemistryLab.is_evidence Method
julia
is_evidence(t) -> Bool

Whether t is a claim about the world somebody stands behind — PROV_MEASURED or PROV_PUBLISHED — as opposed to something estimated, fitted, standing in, or unstated.

A fitted value is deliberately not evidence here. It may be excellent, and whether it is depends on the data, the model and the parameter's identifiability, which is a judgement this predicate has no way to make.

ChemistryLab.provenance Method
julia
provenance(t::Traced) -> ProvenanceKind

How t was obtained.

ChemistryLab.provenance_report Method
julia
provenance_report(values) -> NamedTuple

How a collection of Traced values divides by provenance: a count per kind, the weakest present, and whether every one of them is evidence.

Meant to be printed beside a result. A table of coefficients where nine are published and one is a placeholder is a different object from one where all ten are published, and the difference does not show in any of the numbers.

values may be any iterable of Traced, including the values of a Dict.

ChemistryLab.source Method
julia
source(t::Traced) -> String

Where t came from, as free text.

ChemistryLab.uncertainty Method
julia
uncertainty(t::Traced)

What the source says about how well t is known, or nothing when it says nothing — which is not the same as saying it is exact.

ChemistryLab.value Method
julia
value(t::Traced)

The number, with its provenance dropped — explicitly, which is the only way it should be dropped.

ChemistryLab.weakest Method
julia
weakest(ts...) -> ProvenanceKind

The least sound provenance among its arguments — what a result computed from them can honestly claim.

Nothing here propagates it automatically, because Traced is not a number and does not do arithmetic. This is the function to call where a derived quantity is assembled, so that the claim is written down rather than inherited by accident.

Identifiability ​

Which parameters a measurement can actually determine, and which it only appears to. See Where the numbers come from for how this closes onto Traced: a parameter the data did not constrain comes back as a placeholder rather than as a fitted value.

ChemistryLab.Identifiability Type
julia
struct Identifiability

What a measurement can and cannot determine about a parameter vector, at one point.

Fields

  • J: the log-sensitivity matrix ∂y/∂log θ.

  • S, U, V: its singular value decomposition. V[:, k] is the parameter combination the k-th singular value belongs to.

  • condition: S[1]/S[end], or Inf when the problem is exactly rank-deficient — see below.

  • rank: how many directions the data constrain, by the largest gap in the spectrum that exceeds gap — see identifiable_rank. It is a count of directions and not of parameters; null_participation is what says which parameters those directions leave undetermined.

  • correlation: the parameter correlation matrix from (JᵀJ)⁻¹.

  • stderr: approximate relative standard errors, σ√diag((JᵀJ)⁻¹), or nothing when no observation was given to get σ from.

  • rmse: the residual root-mean-square, or nothing.

  • names: parameter names, for reading the output.

These are linearized errors at one point. They say which numbers in a fit deserve to be quoted; they are not confidence intervals, and a model that is nonlinear in its parameters — which is most of them — will have a likelihood that is not the ellipse this describes.

Fewer observations than parameters

The problem is then exactly rank-deficient, and that case is reported rather than smoothed over: condition is Inf, and null_participation accounts for the directions the data cannot see at all — not only the ones they see badly. correlation and stderr, on the other hand, come from a pseudo-inverse of a singular JᵀJ, so they are finite where the truth is not, and understate the error on any parameter with weight in that null space. The participation is what to read there; it is also what marks those parameters PROV_PLACEHOLDER in as_traced.

ChemistryLab.as_traced Method
julia
as_traced(id::Identifiability, θ; source, null_threshold = 0.1)
    -> Vector{Traced}

The fitted parameters, each carrying PROV_FITTED, the dataset it was fitted to, and its linearized standard error as uncertainty.

This is where an identification meets Traced: a number that came out of an optimization is not the same kind of claim as one somebody measured, and is_evidence returns false for it on purpose.

A parameter with more than null_threshold of its weight in the directions the data do not constrain is marked PROV_PLACEHOLDER instead — a value the fit had to leave somewhere is not a value the fit determined. That test is null_participation and not the parameter's position relative to the rank: the rank counts directions, the position is the caller's packing order, and confusing the two flags whichever of a trading-off pair was listed second. Pass null_threshold = Inf to report them all as fitted and take the responsibility.

ChemistryLab.identifiability Method
julia
identifiability(forward, θ; observed = nothing, relstep = 0.05,
                names = nothing, gap = 5.0) -> Identifiability

How much of θ the output of forward determines.

observed turns the linearized errors into numbers: without it there is no residual to scale them by, and stderr comes back nothing rather than pretending.

Reading it

A condition of a few means every direction is constrained. Several orders of magnitude means the data determine a combination of parameters, and V[:, end] names which one — the entries of that column say which parameters trade off against each other.

correlation is the sharper instrument when two parameters are collinear: hydration_calibration.jl found k₁ and n₁ correlated at −0.985, which says the data see a product and not its factors, and therefore that only one of the two can be fitted. Which one to keep is a modeling judgement, not a statistical one — there the rate constant was kept and the shape exponent fixed, because a rate constant is the quantity a different clinker plausibly changes.

See also: identifiable_rank, as_traced.

ChemistryLab.identifiable_rank Method
julia
identifiable_rank(S; gap = 5.0) -> Int

How many directions a singular spectrum constrains: the number of singular values before the first ratio larger than gap.

A rank is read off a gap rather than a threshold because a threshold has units and a gap does not.

Where the default comes from

From the one case in this repository where the answer is known independently. scripts/hydration_calibration.jl measured [420, 100, 60, 6.3, 1.4, 0.20] and concluded, on the correlation structure, that the data determine three combinations — and the largest ratio in that spectrum is 9.5, between the third singular value and the fourth. A default of 10 would have returned 6 on the very case the rule exists for, which is how this default came to be 5 rather than a round number chosen for looking careful.

Which gap, when there are several

The cut is at the largest qualifying ratio, not the first one. That distinction has a case behind it. Measuring a rate law against its own shrinking-core exponent gives [1.62e-5, 2.71e-6, 5.1e-11]: a ratio of 6.0 and then one of fifty thousand. The first rule cut at 6.0 and answered one determined direction, which says the amplitude alone is visible; the truth is that the amplitude and one exponent combination are both determined and only their split is not, which is two. A factor of six is ordinary conditioning. A factor of fifty thousand is a structure.

Returns length(S) when no ratio exceeds gap, which is the honest answer for a flat spectrum: everything is constrained, or nothing distinguishes what is not.

ChemistryLab.log_sensitivity Method
julia
log_sensitivity(forward, θ; relstep = 0.05) -> Matrix

∂y/∂log θⱼ by central differences, with y = forward(θ).

Differentiating with respect to the logarithm is what makes the columns comparable: a rate constant and a dimensionless exponent have no common unit, and a matrix mixing ∂y/∂k with ∂y/∂n has a singular spectrum that says more about the units than about the data.

Central differences rather than automatic differentiation, because a forward model is often a solver whose parameters do not carry duals — hydration_calibration.jl names exactly why for its own: the integrator casts to Float64 on the way in. forward is free to be AD-clean; this does not require it, and costs exactly 2n evaluations: the output is sized from the first perturbed call rather than from an extra unperturbed one. On a forward model that is a solver, that saved call is a whole solve.

relstep is a relative step, so a parameter near zero needs rescaling before it is passed here — which is the same condition as the logarithm being defined.

A coarse step can hide an exact degeneracy

The default 5 % is chosen so a noisy forward model still gives a usable derivative, and it is coarse. On a rate law that goes as (1-ξ)^{n₃}, a 5 % step moves n₃ = 3.3 by 0.165 in the exponent, which is far enough that the second-order error differs between two parameters that are exactly collinear — and the collinearity then shows up as a condition number of 80 rather than of 3 × 10⁵. Measured on that case, the third singular value goes from 2.0e-7 at relstep = 0.05 to 5.1e-11 at 0.01 and is unchanged below.

So a condition number of a few hundred is not evidence that a model is well posed. Refine relstep and see whether the answer moves; if it does, the coarse one was measuring the differencing and not the model.

ChemistryLab.nparameters Method
julia
nparameters(id::Identifiability) -> Int

How many parameters were differentiated against.

Not length(id.S): the singular spectrum is as long as the SMALLER of the two dimensions, so with fewer observations than parameters it is shorter than the parameter vector. Every count of parameters reads this, and every count of directions reads length(id.S) — conflating the two is what made an under-determined fit report a shorter answer than it was asked about.

ChemistryLab.null_participation Method
julia
null_participation(id::Identifiability) -> Vector{Float64}

For each parameter, how much of it lies in the directions the data do not constrain: Σ_{k > rank} V[i,k]², which is between 0 and 1.

Why this and not "beyond the rank"

A rank of 2 out of 3 says the data constrain two directions in parameter space. It says nothing about which parameters are determined, because the directions are the singular vectors and the parameter order is whatever the caller packed. Reading the rank as "the first two parameters are fine" confuses an index with a subspace, and for a model where two parameters trade off it picks the wrong one — the one that happened to be listed second.

The participation is the honest version. In a model where a and c enter only as their product, it comes out [0.5, 0, 0.5]: neither a nor c is determined on its own, b is, and no ordering was consulted to say so.