Composite models

Fit Function Builder dialog with an Oscillatory + Exponential + Constant expression

The Build Fit Function dialog searches a component library on the left (type to match names, aliases, parameters, or descriptions) and assembles the model as structured rows on the right, one per component, joined by editable operators. Selecting two or more ‘+’-joined rows and pressing “Group as fractions” binds them into a shared-amplitude fraction group, shown as an accented container. The preview line at the bottom shows the compiled formula with mangled parameter names (A_1, A_2, …) for each component (Blundell et al., Muon Spectroscopy: An Introduction (Oxford University Press, Oxford, 2022), Ch. 6.4).

A realistic μSR asymmetry almost never has the algebraic form of a single depolarisation function. It is typically the product of a relaxation envelope with an oscillatory signal, or the sum of contributions from distinct muon populations (e.g. magnetic and paramagnetic fractions near a transition, multiple stopping sites in a molecular magnet), with a small constant offset added in. Composite models are how Asymmetry expresses these combinations: a free-form arithmetic expression over registered baseline-free components, parsed into a compiled callable that the fit engine drives like any other model. The two patterns the builder is designed for are multiplicative combinations, where one physical effect modulates another (a Gaussian envelope multiplying a transverse-field (TF) precession signal in the vortex state; an exponential damping multiplying a Larmor oscillation), and additive combinations, where independent populations of muons contribute separately. Fraction groups, documented below, let several additive components share one overall amplitude budget — the natural representation for two muonium states in a semiconductor whose fractions must sum to one, or for the magnetic and paramagnetic fractions of a sample passing through a transition.

For real examples that build composites step-by-step, see Temperature scan through a magnetic transition and Superconductor penetration depth from σ(T).

Fit Function Builder with a fraction-group expression

Building a composite function

from asymmetry.core.fitting import CompositeModel

model = CompositeModel(
    component_names=["Exponential", "Oscillatory", "Constant"],
    operators=["+", "+"],
)
print(model.formula_string())

The time-domain grammar combines components with +, -, *, / and parentheses. The quadrature combinator (\(\sqrt{f^2 + g^2}\)) is not part of this grammar — it belongs to the parameter-vs-x trend models, where quadrature composition of width-like quantities is physically meaningful; see Parameter trending.

Fraction groups

Composite models can also share one overall amplitude across several additive components while fitting the fractional weight of each term inside that group. In Python, write the grouped sum as (...){frac}:

fraction_model = CompositeModel.from_expression(
   "( Exponential + Gaussian + Constant ){frac} + Constant"
)

A group of \(n\) additive terms has \(n - 1\) free fraction parameters, named after their components — f_Exponential, f_Gaussian, … (duplicates are suffixed _2, _3, …). Each free fraction is a weight in \([0, 1]\). The last term of the group has no fitted parameter: its weight is the remainder, \(1 - \sum_i f_i\), clamped to \([0, 1]\). There is no sum-normalisation — the free fractions are the weights directly, so together with the derived remainder they partition the group’s amplitude.

In the GUI fit-function builder, you do not need to type {frac}. Instead, select two or more additive rows and press Group as fractions; the grouped terms then sit inside a single accented container, and the typeset preview colours each fraction group so it is clear which terms share an amplitude budget. In the fit-panel parameter table the free fractions are ordinary editable rows; the group’s remainder appears as a muted, read-only row that updates automatically as you edit the others.

Parameter naming rules

Composite models generate unique parameter names automatically:

  • Additive terms get their own amplitude parameters: A_1, A_2, …

  • Components joined by * or / share a single amplitude for that

    multiplicative chain. For example, Exponential * Gaussian uses only A_1.

  • Fraction groups share one amplitude across the whole grouped sum (A_1) and add one free fraction per term named after its component — f_Exponential, f_Gaussian, … (duplicates suffixed _2, _3, …) — for all but the last term. The last term carries no parameter; its weight is the derived remainder \(1 - \sum_i f_i\).

  • Repeated symbols are indexed: Lambda_1, Lambda_2

  • Unique symbols remain unindexed: frequency

  • Constant background uses A_bg

This keeps the parameterisation closer to the usual physics notation for products such as an exponentially damped oscillation, where the envelope and oscillation share one overall asymmetry.

For fraction groups the free fractions are the weights (each clamped to \([0, 1]\)); the final term takes whatever remains, \(1 - \sum_i f_i\) (floored at zero if the free fractions over-subscribe). There is no sum-normalisation, so the fitted free values are directly interpretable as the physical fractional weights.

Always read .param_names before building a ParameterSet

The numbering is collision-driven, not blanket per-component, so the only reliable way to know the parameter names is to ask the compiled model. A symbol that appears in more than one component is suffixed with that component’s 1-based position; a symbol that is unique across the whole expression stays bare. So in Oscillatory * Exponential + Constant the amplitude A collides (shared across the * chain → A_1) and Constant contributes A_bg, but frequency, phase, and Lambda are each unique and keep their plain names:

from asymmetry.core.fitting import CompositeModel

model = CompositeModel.from_expression("Oscillatory * Exponential + Constant")
print(model.param_names)
# ['A_1', 'frequency', 'phase', 'Lambda', 'A_bg']
# note: 'Lambda' (unique) is NOT 'Lambda_1'; 'Constant' contributes 'A_bg'

The 1-based position is counted across every component in the expression, not per repeated symbol — so when a symbol first collides at a later component, its suffix is that component’s index, which can skip numbers. Add a second Oscillatory and the precession symbols of the two oscillatory terms become _1 and _3 (their component positions), never _1 and _2:

model = CompositeModel.from_expression(
    "Oscillatory * Exponential + Oscillatory + Constant"
)
print(model.param_names)
# ['A_1', 'frequency_1', 'phase_1', 'Lambda', 'A_3',
#  'frequency_3', 'phase_3', 'A_bg']
# the second Oscillatory is component #3 → frequency_3 / phase_3, NOT _2

model.param_names and model.to_model_definition().param_names return the same list. Build your ParameterSet from that list rather than from guessed names — guessing Lambda_1 or frequency_2 here would silently fail to bind.

A ParameterSet is keyed by parameter name, not by position. Look a parameter up with its name; integer indexing raises KeyError (it is treated as a missing name, not a sequence index). Its own name list is the .names attribute (a plain list, not a method):

from asymmetry.core.fitting import Parameter, ParameterSet

ps = ParameterSet([Parameter("A_1", value=0.2), Parameter("Lambda", value=0.4)])
ps.names          # ['A_1', 'Lambda']  (attribute, not ps.names())
ps["A_1"]         # Parameter(name='A_1', value=0.2, ...)
ps[0]             # KeyError: 0  — there is no positional access

So the safe recipe is always: compile the model, read model.param_names, then build the ParameterSet entries under those exact names.

Expression (COMPONENTS) names are not the standalone (MODELS) names

Important

“GaussianRelaxation is not a component” is a naming mismatch, not a missing feature. GaussianRelaxation is a key of the standalone MODELS registry; inside a composite expression the same physics is the component Gaussian. Likewise ExponentialRelaxationExponential and LFKuboToyabeLongitudinalFieldKT. If a name you expect is “absent” from COMPONENTS, check the table below before concluding the model does not exist.

Asymmetry keeps two registries, and they use different names for the same physics:

  • COMPONENTS — the building blocks you combine inside a composite expression (CompositeModel.from_expression(...) and the GUI builder). Their natural parameters are baseline-free (A, Lambda, sigma, …) and the parser then mangles them into the composite names (A_1 / A_bg / Lambda_2 / …) described above.

  • MODELS — the standalone models you hand straight to FitEngine().fit(...) (see Fitting engine). Their parameters carry an explicit amplitude and baseline (A0, …, baseline).

Most names are shared between the two registries, but three of the everyday relaxation forms are spelled differently — exactly the ones testers most often report as “missing”:

MODELS (standalone) ↔ COMPONENTS (expression) name map

Standalone MODELS key

Its parameters

Expression COMPONENTS name

Its parameters

ExponentialRelaxation

A0, Lambda, baseline

Exponential + Constant

A, Lambda (+ A_bg)

GaussianRelaxation

A0, sigma, baseline

Gaussian + Constant

A, sigma (+ A_bg)

LFKuboToyabe

A0, Delta, B_L, baseline

LongitudinalFieldKT + Constant

A, Delta, B_L (+ A_bg)

StretchedExponential

A0, Lambda, beta, baseline

StretchedExponential

A, Lambda, beta

StaticGKT_ZF

A0, Delta, baseline

StaticGKT_ZF

A, Delta

Abragam

A0, Delta, nu, baseline

Abragam

A, Delta, nu

Keren

A0, Delta, nu, B_L, baseline

Keren

A, Delta, nu, B_L

DynamicGaussianKT

A0, Delta, nu, B_L, baseline

DynamicGaussianKT

A, Delta, nu, B_L

DynamicLorentzianKT

A0, a_L, nu, B_L, baseline

DynamicLorentzianKT

A, a_L, nu, B_L

Oscillatory

A0, frequency, phase, Lambda, baseline

Oscillatory

A, frequency, phase

The rows marked ⚠ are the three where the name itself differs; the rest share a name but still differ in their parameters (standalone models add an explicit A0 amplitude and baseline; the matching Constant component supplies the A_bg background in an expression). The standalone Oscillatory also carries its own Lambda damping and baseline that the bare Oscillatory component does not.

Mixing the two vocabularies is a common source of “unknown parameter” errors. Decide which API you are using (composite expression vs standalone MODELS entry) and take the names from that one.

Worked example — building GaussianRelaxation inside an expression

A standalone GaussianRelaxation is \(A_0\,e^{-(\sigma t)^2} + \mathrm{baseline}\). The expression-domain equivalent is the Gaussian component plus a Constant background — note how the amplitude is mangled to A_1 and the baseline becomes A_bg:

from asymmetry.core.fitting import CompositeModel

model = CompositeModel.from_expression("Gaussian + Constant")
print(model.param_names)
# ['A_1', 'sigma', 'A_bg']
# A_1 ≡ standalone A0, sigma ≡ sigma, A_bg ≡ standalone baseline

The authoritative component list is the live registry — the Available Components table below is a commonly-used subset. Print the full set (the output below is current as of this writing):

from asymmetry.core.fitting import COMPONENTS, MODELS
sorted(COMPONENTS)   # names usable inside CompositeModel expressions
# ['Abragam', 'Bessel', 'Constant', 'ConstantBackground', 'DipolarPairField',
#  'DipolarSpinJ', 'DynamicFmuF', 'DynamicGaussianKT', 'DynamicLorentzianKT',
#  'ElectronDipole', 'Exponential', 'FmuF_General', 'FmuF_Linear',
#  'FmuF_Triangle', 'Gaussian', 'GaussianBroadenedKT', 'GaussianPeak', 'Keren',
#  'LinearBackground', 'LongitudinalFieldKT', 'LorentzianPeak', 'MuF',
#  'MuoniumHighTF', 'MuoniumHighTFAniso', 'MuoniumLFRelax', 'MuoniumLowTF',
#  'MuoniumTF', 'MuoniumZF', 'Oscillatory', 'OscillatoryField', 'ProtonDipole',
#  'RischKehr', 'StaticGKT_ZF', 'StretchedExponential']

sorted(MODELS)       # standalone model keys (different names)
# ['Abragam', 'DynamicGaussianKT', 'DynamicLorentzianKT',
#  'ExponentialRelaxation', 'GaussianRelaxation', 'Keren', 'LFKuboToyabe',
#  'Oscillatory', 'StaticGKT_ZF', 'StretchedExponential']

Evaluate model and components

import numpy as np

t = np.linspace(0.0, 6.0, 200)
y = model.function(
    t,
    A_1=20.0,
    Lambda=0.4,
    A_2=8.0,
    frequency=2.0,
    phase=0.0,
    A_bg=0.6,
)

# Useful for plotting stacked contributions
additive_curves = model.evaluate_components(
    t,
    additive_only=True,
    A_1=20.0,
    Lambda=0.4,
    A_2=8.0,
    frequency=2.0,
    phase=0.0,
    A_bg=0.6,
)

For multiplicative models, pass the shared chain amplitude only once:

damped_cosine = CompositeModel(
    component_names=["Exponential", "Oscillatory"],
    operators=["*"],
)

y = damped_cosine.function(
    t,
    A_1=20.0,
    Lambda=0.4,
    frequency=2.0,
    phase=0.0,
)

In symbolic previews, downstream multiplicative factors suppress the redundant 1* amplitude term, so the displayed formula stays readable.

Use with FitEngine

from asymmetry.core.fitting import FitEngine, Parameter, ParameterSet

params = ParameterSet([
    Parameter("A_1", value=20.0, min=0.0),
    Parameter("Lambda", value=0.4, min=0.0),
    Parameter("A_2", value=8.0, min=0.0),
    Parameter("frequency", value=2.0, min=0.0),
    Parameter("phase", value=0.0),
    Parameter("A_bg", value=0.0),
])

result = FitEngine().fit(dataset, model.function, params)
print(result.success)

If the model contains a multiplicative chain, include only that chain’s shared amplitude parameter in the fit table.

Available components

The following components are registered in COMPONENTS and can be used by name in CompositeModel:

Key

Formula

Parameters

Exponential

\(A e^{-\Lambda t}\)

A, Lambda

Gaussian

\(A e^{-(\sigma t)^2}\)

A, sigma

Oscillatory

\(A \cos(2\pi f t + \phi)\)

A, frequency (MHz), phase

OscillatoryField

\(A \cos(2\pi \gamma_\mu B t + \phi)\)

A, field (Gauss), phase. The precession frequency is computed internally using \(\gamma_\mu = 13.554\,\text{MHz/kG}\). Use this component when the fit parameter should be the applied field rather than the precession frequency directly.

StretchedExponential

\(A e^{-(|\Lambda| t)^\beta}\)

A, Lambda, beta

StaticGKT_ZF

\(A \left[\tfrac{1}{3} + \tfrac{2}{3}(1-\Delta^2 t^2)e^{-\Delta^2 t^2/2}\right]\)

A, Delta

LongitudinalFieldKT

Hayano longitudinal-field Kubo–Toyabe (LF-KT) \(G_z(t;\Delta,B_L)\) — see LongitudinalFieldKT

A, Delta, B_L (Gauss)

MuF

Analytical single mu-F polarisation \(D_z(t)\)

A, r_muF (Å)

FmuF_Linear

Analytical collinear F-mu-F polarisation

A, r_muF (Å)

FmuF_General

Numerical powder-averaged F-mu-F polarisation

A, r1 (Å), r2 (Å), theta (°)

Constant

\(A_{\mathrm{bg}}\)

A_bg

Runnable example

See examples/composite_models.py for a complete executable script.