Fitting

The fitting subsystem is split into four parts: the iminuit-backed FitEngine that minimises any f(t, **params) -> array callable; the MODELS registry of standalone time-domain models with explicit baselines (one-shot Python use); the CompositeModel builder that parses arithmetic expressions over the COMPONENTS registry into a compiled callable (the canonical path for any non-trivial muSR model, mirroring the GUI Edit Function… dialog); and the parameter_models package that fits extracted fit parameters as a function of field, temperature, or run number with the same machinery. Superconducting models live under asymmetry.core.fitting.sc and are surfaced through both the composite registry and the parameter-trending registry — the physics context is in Superconducting penetration depth models. Symbols, units, and physical descriptions for every fit parameter are sourced from the PARAM_INFO_REGISTRY, which is canonical: GUI labels, GLE export labels, and the autodoc parameter tables all derive from it.

Frequency-domain peak fitting reuses the same engine and composite-model path. The spectral helper module supplies the default Fourier-spectrum peak model, MHz/G conversion, and derived field parameters for trend tables.

Fit engine

class asymmetry.core.fitting.engine.FitEngine[source]

Bases: object

Fit μSR asymmetry data to a model function using iminuit.

Example

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

engine = FitEngine()
model = MODELS["ExponentialRelaxation"]

# Set up parameters
params = ParameterSet()
params.add(Parameter(name="A0", value=0.2, min=0, max=1))
params.add(Parameter(name="lambda", value=0.5, min=0))

result = engine.fit(dataset, model.function, params)
print(f"χ²ᵣ = {result.reduced_chi_squared:.3f}")
fit(dataset: MuonDataset, model_fn: Callable[[...], ndarray[tuple[int, ...], dtype[_ScalarType_co]]], parameters: ParameterSet, t_min: float | None = None, t_max: float | None = None, method: str = 'migrad', minos: bool = False, cancel_callback: Callable[[], bool] | None = None, frequency_offsets: dict[str, float] | None = None, cost_factory: CostFactory | None = None, migrad_kwargs: dict | None = None, *, error_oversampling: float = 1.0) FitResult[source]

Run a single-dataset fit.

Parameters:
  • dataset (MuonDataset) – The data to fit. The engine uses the dataset object’s time, asymmetry, and error arrays as provided, optionally clipped only by t_min/t_max.

  • model_fn (callable) – f(t, **params) -> array.

  • parameters (ParameterSet) – Initial parameter values and constraints.

  • t_min (float, optional) – Restrict fit range.

  • t_max (float, optional) – Restrict fit range.

  • method (str) – Minimization method ("migrad" for gradient-based, "simplex" for Nelder-Mead).

  • cost_factory (CostFactory, optional) – Selectable fit objective (GAUSSIAN_COST / POISSON_COST). None keeps the historical √-weighted least squares, byte-for-byte. With POISSON_COST the chi_squared/reduced_chi_squared report the Cash statistic (asymptotically χ²-distributed), and the data must be raw Poisson counts with a count-expectation model.

  • migrad_kwargs (dict, optional) – Forwarded to m.migrad/m.simplex via the shared drive_minuit() seam (e.g. ncall to cap iterations for a cheap screening fit). None keeps the historical unbounded drive.

  • frequency_offsets (dict[str, float], optional) – Rotating-reference-frame offsets {param: ν₀} resolved by asymmetry.core.fitting.rrf_offset.rrf_frequency_offsets(). When given, each named parameter is shifted by its offset before the model is evaluated, so the raw lab-frame data is fitted while the fitted values read as rotating-frame offsets δν (lab = δν + ν₀ via apply_rrf_offsets()). This is the engine-level home of the RRF offset wrapper — fitting through it is bit-for-bit identical to wrapping model_fn with rrf_offset_model().

  • error_oversampling (float, optional) –

    Correction for correlated samples, keyword-only, default 1.0 (no correction). A zero-padded FFT spectrum is sinc-interpolated: padding by a factor s densifies the sampled points but does not add independent information, so only ~1/s of the points are statistically independent. Fitting every sample as if independent overcounts the χ² sum by ~s and underestimates parameter uncertainties by ~√s. Pass the spectrum’s zero-padding factor (metadata["fourier_padding"] from asymmetry.core.fourier.spectrum.compute_average_group_spectrum()) here; for time-domain or count fits this correction is meaningless and callers pass the default 1.0.

    WiMDA’s precedent (Analyse.pas:5228) is to divide the reported dof by the zero-pad factor (dof := n div zpad - nv) but leaves χ² and the parameter errors untouched. This implementation is the fully consistent version: when error_oversampling (s) is greater than 1,

    • the effective point count is ndata_eff = max(round(ndata / s), 1);

    • dof = max(ndata_eff - nfree, 1);

    • the minimised χ² summed too many correlated terms, so chi_squared = m.fval / s and reduced_chi_squared = chi_squared / dof;

    • Minuit’s HESSE/MINOS errors come from the curvature of the overcounted χ², so every entry of uncertainties is scaled by √s, the covariance matrix by s, and every minos_errors bound by √s;

    • the fitted parameter values are untouched — the overcounting scales the curvature, not the location of the minimum.

    A FitResult.warnings advisory records the applied correction so it stays visible wherever the fit result is surfaced.

Returns:

Container with fit results including χ², parameters, and uncertainties.

Return type:

FitResult

global_fit(datasets: list[MuonDataset], model_fn: Callable[[...], ndarray[tuple[int, ...], dtype[_ScalarType_co]]], global_params: list[str], local_params: list[str], initial_params: dict[int, ParameterSet], t_min: float | None = None, t_max: float | None = None, method: str = 'migrad', max_calls: int = 10000, migrad_iterations: int = 5, use_simplex_rescue: bool = True, minuit_strategy: int | None = None, minuit_tol: float | None = None, initial_step_sizes: dict[str, float] | None = None, minos: bool = False, screening: bool = False, strategy: str = 'joint', use_varpro: bool = False, cancel_callback: Callable[[], bool] | None = None, cost_factory: CostFactory | None = None, local_param_groups: dict[str, dict[int, Hashable]] | None = None, *, error_oversampling: float = 1.0) tuple[dict[int, FitResult], ParameterSet][source]

Simultaneous fit of multiple datasets with shared and local parameters.

Parameters:
  • datasets – List of datasets to fit simultaneously.

  • model_fn – Model function applied to each dataset.

  • global_params – Names of parameters shared across all datasets (e.g., [“A0”]).

  • local_params – Names of parameters that vary per dataset (e.g., [“lambda”]).

  • initial_params – Dictionary mapping dataset run_number to initial ParameterSet. Global parameters should have the same value in all sets.

  • t_min – Optional time range restriction applied to all datasets.

  • t_max – Optional time range restriction applied to all datasets.

  • method – Minimization method (“migrad” or “simplex”).

  • max_calls – Maximum function evaluations for minimization. Limits runtime for large global fits.

  • strategy – Minimiser architecture for the shared-parameter problem. "joint" (default) builds one Minuit problem over the globals plus every dataset’s locals — the historical, byte-for-byte path. "profiled" runs an outer Minuit over the free globals only and, for each candidate global vector, solves each dataset’s locals independently (via fit() with the globals held fixed). The two share the same objective, so at the optimum they agree on values and χ²; profiled drops the per-fit Hessian from (n_global+n_local·G)² to n_global² plus G small block Hessians, so cost scales ~linearly in G rather than super-linearly. Profiled requires at least one free global (with none, the joint problem is already block-separable and the fast path below handles it).

  • use_varproDeferred — not implemented. Passing use_varpro=True currently raises NotImplementedError; the default False is the only supported value. Variable projection (technique M) is planned as a follow-up: it would solve the parameters flagged linear in the model metadata (amplitudes, constant backgrounds; see asymmetry.core.fitting.models.default_linear_params()) by weighted linear least-squares inside each residual and remove them from the nonlinear Minuit vector, still counting them in dof / the IC k. It is deferred because, once the "profiled" strategy has already separated the per-dataset locals, VarPro is only a constant-factor per-fit win (it does not change the G-exponent), and reproducing the current engine’s marginal parameter errors needs a final full Hessian over all parameters. The role-based linear-parameter metadata is in place for that follow-up.

Returns:

  • results (dict[int, FitResult]) – Dictionary mapping run_number to individual FitResult for each dataset.

  • global_result (ParameterSet) – The fitted global parameters with uncertainties.

  • local_param_groups – Optional per-local-parameter sharing. local_param_groups[pname] maps a dataset run number to a group key; datasets with the same key share one fitted value for pname (a third scope between fully global and fully per-dataset). Absent → pname is per-dataset.

  • error_oversampling (float, optional) – Correction for correlated samples (zero-padded FFT spectra), applied to every dataset’s reported chi_squared/reduced_chi_squared/ dof/uncertainties/covariance/minos_errors the same way as fit() — see that method’s docstring for the exact relations, the WiMDA precedent, and why fitted parameter values are unaffected. Keyword-only, default 1.0 (no correction); pass the common zero-padding factor of the fitted spectra (metadata["fourier_padding"]), or 1.0 for time-domain/count fits.

Notes

Fixed parameters (where param.fixed=True) are held constant during fitting.

Models

Built-in μSR fit functions.

Each model is a callable f(t, **params) -> array plus metadata describing its parameters. Models are collected in the MODELS registry.

asymmetry.core.fitting.models.LINEAR_PARAM_ROLE_NAMES: frozenset[str] = frozenset({'A', 'A0', 'A_bg', 'BG', 'a_Dia', 'baseline', 'bg', 'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6'})

Groundwork for the deferred variable-projection (VarPro) follow-up. VarPro is not wired into a fit path yet (see the PR body: it is a constant-factor win once profiled locals already separate the per-dataset solves, and its marginal errors need a final full Hessian), so this metadata is presently consumed only by tests and the follow-up plan — but it is the generic, name-role marking the study asks for, kept here so the follow-up does not re-derive it.

Parameter role names that enter a μSR model linearly — amplitudes and additive constant backgrounds. These are roles, not component names: an amplitude A scales its term and a constant baseline baseline adds to the model, so both appear to first order as model = a·term + .... Variable projection (VarPro) solves such parameters by linear least-squares inside the objective instead of by Minuit. The set is deliberately generic (matched by name, never by which component a parameter belongs to); an f_<Component> fraction weight is not here because a normalised fraction group is not a free linear scale. VarPro always verifies affineness numerically at runtime and falls back to the nonlinear treatment when a flagged name is not actually affine in a given model, so this list only has to be a safe over-approximation of “usually linear”.

asymmetry.core.fitting.models.default_linear_params(param_names: list[str]) list[str][source]

Role-based linear-parameter guess for a model’s parameter list.

Returns the subset of param_names whose (index-stripped) base name is a known linear role (LINEAR_PARAM_ROLE_NAMES) — i.e. an amplitude or an additive constant background. This is only a candidate set: VarPro verifies affineness numerically before using it, so a false positive is corrected at runtime, never silently trusted.

class asymmetry.core.fitting.models.ModelDefinition(name: str, description: str, function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], param_names: list[str], param_defaults: dict[str, float], param_info: dict[str, ParamInfo], domain: str = 'time', linear_params: list[str] | None = None)[source]

Descriptor for a built-in fit function.

name: str
description: str
function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]]
param_names: list[str]
param_defaults: dict[str, float]
param_info: dict[str, ParamInfo]
domain: str = 'time'
linear_params: list[str] | None = None

Names of parameters that enter the model linearly (amplitudes, constant backgrounds). None means “derive from role names via default_linear_params()”. Variable projection solves these by linear least-squares, but only after a numeric affineness check, so an entry here is a hint, not a guarantee.

resolved_linear_params() list[str][source]

The linear-parameter set for this model (explicit or role-derived).

__init__(name: str, description: str, function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], param_names: list[str], param_defaults: dict[str, float], param_info: dict[str, ParamInfo], domain: str = 'time', linear_params: list[str] | None = None) None
asymmetry.core.fitting.models.exponential_relaxation(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, Lambda: float, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Simple exponential: A(t) = A0 exp(−Λt) + baseline.

asymmetry.core.fitting.models.gaussian_relaxation(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, sigma: float, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Gaussian relaxation: A(t) = A0 exp(−σ²t²) + baseline.

asymmetry.core.fitting.models.oscillatory(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, frequency: float, phase: float = 0.0, Lambda: float = 0.0, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Damped oscillation: A0 cos(2πft + φ) exp(−Λt) + baseline.

asymmetry.core.fitting.models.stretched_exponential(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, Lambda: float, beta: float = 1.0, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Stretched exponential: A0 exp(−(Λt)^β) + baseline.

asymmetry.core.fitting.models.static_gkt_zf(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, Delta: float, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Static Gaussian Kubo-Toyabe (zero field).

GKT(t) = A0 [1/3 + 2/3 (1 − Δ²t²) exp(−Δ²t²/2)] + baseline

asymmetry.core.fitting.models.longitudinal_field_kubo_toyabe(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, Delta: float, B_L: float, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Static Gaussian Kubo-Toyabe relaxation in a longitudinal field.

The longitudinal-field depolarisation function for a muon in a static, isotropic Gaussian distribution of local fields of width Delta with an applied longitudinal decoupling field B_L. As B_L is swept through the decoupling crossover the polarisation recovers toward unity, which is the experimental signature of a static (rather than dynamic) local field.

\[G_z(t) = 1 - \frac{2\Delta^2}{\omega_0^2} \left[1 - e^{-\Delta^2 t^2/2}\cos(\omega_0 t)\right] + \frac{2\Delta^4}{\omega_0^3} \int_0^t e^{-\Delta^2\tau^2/2}\sin(\omega_0\tau)\,d\tau ,\]

with \(\omega_0 = \gamma_\mu B_L\) (B_L in Gauss, converted to Tesla internally). The returned asymmetry is \(A(t) = A_0\,G_z(t) + baseline\). For B_L = 0 it reduces exactly to the zero-field Gaussian Kubo-Toyabe function (static_gkt_zf()); for large B_L it tends to 1 (decoupling).

Parameters:
  • t (NDArray) – Time values in microseconds.

  • A0 (float) – Initial asymmetry amplitude at t = 0.

  • Delta (float) – Static Gaussian field-distribution width in us^-1 (Delta = gamma_mu * sqrt(<B^2>)).

  • B_L (float) – Applied longitudinal magnetic field in Gauss.

  • baseline (float, optional) – Constant additive baseline.

Notes

The oscillatory-decaying integral term is evaluated for all requested times at once by cumulative trapezoidal integration on a shared fine grid whose step is sized from omega0 and Delta. This is both faster and smoother than per-point adaptive quadrature (which left the integral noisy), so it speeds up the static component and the dynamic Kubo-Toyabe that builds on it.

References

R. S. Hayano, Y. J. Uemura, J. Imazato, N. Nishida, T. Yamazaki and R. Kubo, “Zero- and low-field spin relaxation studied by positive muons”, Phys. Rev. B 20, 850 (1979).

asymmetry.core.fitting.models.abragam(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, Delta: float, nu: float, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Abragam relaxation function (Abragam, Principles of Nuclear Magnetism, 1961).

G(t) = A0 exp[ -(Delta^2 / nu^2) (e^{-nu t} - 1 + nu t) ] + baseline

A single-component relaxation that interpolates between the static Gaussian line shape and the motionally-narrowed exponential:

  • nu -> 0 : G -> A0 exp(-Delta^2 t^2 / 2) (Gaussian)

  • nu >> Delta : G -> A0 exp(-(Delta^2 / nu) t) (exponential)

Notation and form follow Blundell, De Renzi, Lancaster & Pratt, Muon Spectroscopy: An Introduction (OUP, 2022), eqn 5.52 (the damping factor of the transverse-field Abragam function), with the same Gaussian width symbol Delta as the Kubo-Toyabe family.

Parameters:
  • t (NDArray) – Time values in microseconds.

  • A0 (float) – Initial asymmetry amplitude.

  • Delta (float) – Static Gaussian field-distribution width in us^-1.

  • nu (float) – Field fluctuation (hop) rate in MHz (== us^-1). nu <= 0 gives the static Gaussian limit.

  • baseline (float, optional) – Constant baseline offset.

asymmetry.core.fitting.models.keren(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, Delta: float, nu: float, B_L: float, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Keren dynamic Gaussian relaxation in a longitudinal field (Keren, PRB 50, 10039 (1994)).

P(t) = A0 exp[-Gamma(t)] + baseline, with omega0 = gamma_mu * B_L and:

Gamma(t) = (2 Delta^2 / (omega0^2 + nu^2)^2) * [
    (omega0^2 + nu^2) nu t + (omega0^2 - nu^2) (1 - e^{-nu t} cos(omega0 t))
    - 2 nu omega0 e^{-nu t} sin(omega0 t) ]

Keren’s analytic generalisation of the Abragam function to a longitudinal field. At B_L = 0 it reduces to the Abragam exponent (x2, for the two transverse zero-field components): Gamma = (2 Delta^2 / nu^2)(e^{-nu t} - 1 + nu t).

Parameters:
  • t (NDArray) – Time values in microseconds.

  • A0 (float) – Initial asymmetry amplitude.

  • Delta (float) – Static Gaussian field-distribution width in us^-1.

  • nu (float) – Field fluctuation rate in MHz (== us^-1).

  • B_L (float) – Longitudinal magnetic field in Gauss (omega0 = gamma_mu * B_L).

  • baseline (float, optional) – Constant baseline offset.

asymmetry.core.fitting.models.static_lorentzian_kt_zf(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, a_L: float, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Static Lorentzian Kubo-Toyabe, zero field (Uemura et al. PRB 31, 546 (1985)).

G(t) = A0 [1/3 + 2/3 (1 - a t) exp(-a t)] + baseline

For a dilute / Lorentzian distribution of local fields (e.g. spin glasses), where a_L is the half-width of the field distribution expressed as a rate in us^-1.

asymmetry.core.fitting.models.static_lorentzian_kt_lf(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, a_L: float, B_L: float, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Static Lorentzian Kubo-Toyabe in a longitudinal field (computed numerically).

Blundell, De Renzi, Lancaster & Pratt, Muon Spectroscopy (OUP, 2022), sec 5.3, note that the Kubo-Toyabe function “becomes modified in applied field … [and] must be computed numerically”. This evaluates the stochastic field average (eqn 5.3) over an isotropic Lorentzian local-field distribution of half-width a_L (us^-1) with applied longitudinal field B_L (Gauss), via the analytic angular + time reduction in _lorentzian_lf_lineshape().

  • B_L -> 0 : reduces to the zero-field Lorentzian KT (eqn 5.47), 1/3 + 2/3 (1 - a_L t) e^{-a_L t}.

  • B_L -> inf : decoupling, G -> 1.

Accurate to better than ~0.1-0.3 % for B_L >~ 20 G (finer at higher field); very small fields (omega0 < 0.05 a_L) are treated as zero field, where the eqn 5.47 form is exact. The line shape is computed once on a grid and interpolated, and cached per (a_L, B_L, tmax).

asymmetry.core.fitting.models.risch_kehr(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], Gamma: float) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Risch-Kehr relaxation for spin transport by 1D diffusion.

G(t) = e^{Gamma t} erfc(sqrt(Gamma t))

Relaxation of the muon (or muonium) polarisation when the depolarising agent diffuses in one dimension (e.g. a polaron on a conducting-polymer chain): the return probability of a 1D random walk gives a (pi Gamma t)^{-1/2} long-time tail instead of an exponential.

Evaluated as erfcx(sqrt(Gamma t)) (the scaled complementary error function), which is numerically stable for all Gamma t — no asymptotic branch switch is needed (WiMDA switches forms at Gamma t = 20). Gamma is used as |Gamma|; WiMDA’s mirrored 2 - G branch for negative rates is an unphysical fitting convenience and is not ported.

References

  1. Risch and K. W. Kehr, Phys. Rev. B 46, 5246 (1992).

asymmetry.core.fitting.models.bessel_oscillation(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], frequency: float, phase: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Zeroth-order Bessel oscillation J0(2 pi f t + phase).

The polarisation produced by an incommensurate (spin-density-wave) field distribution: an Overhauser distribution of local fields between -B_1 and +B_1 gives P(t) = J0(gamma_mu B_1 t) (Blundell, De Renzi, Lancaster & Pratt, Muon Spectroscopy, OUP 2022, eqn 6.47), which at late times looks like a damped cosine with a -45 degree phase shift (eqn 6.48). Here the field-distribution edge is parameterised as a frequency f = gamma_mu B_1 / 2 pi in MHz.

asymmetry.core.fitting.models.gaussian_broadened_kt(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], Delta: float, B_L: float, w_rel: float) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Static Gaussian Kubo-Toyabe averaged over a Gaussian distribution of Delta.

G(t) = integral dDelta’ p(Delta’) G_KT(t; Delta’, B_L), with p a Gaussian of mean Delta and standard deviation w_rel * Delta (w_rel is the fractional width). Models disordered hosts where a single Kubo-Toyabe width is too sharp — the distribution of static widths fills in the dip and softens the 1/3-tail recovery (cf. the Gaussian-broadened Gaussian of Noakes & Kalvius, Phys. Rev. B 56, 2352 (1997); WiMDA’s Gau broad KT).

Evaluated by Gauss-Hermite quadrature (21 nodes) over the Delta distribution, with negative quadrature widths reflected to |Delta'| (as in WiMDA). The quadrature is evaluated directly at the requested times (vectorised over the nodes, with the Hayano longitudinal-field integral computed once on a shared fine grid for all nodes), so the model is smooth in w_rel and w_rel = 0 reduces exactly and continuously to the static (LF) Kubo-Toyabe.

Note: WiMDA’s rel width parameter enters as weight exp(-(i/7)^2) over Delta(1 + w i/7), i.e. a Gaussian of fractional standard deviation w/sqrt(2); w_rel here is the fractional standard deviation (w_rel = w_WiMDA / sqrt(2)).

asymmetry.core.fitting.models.dynamic_gaussian_kt(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, Delta: float, nu: float, B_L: float = 0.0, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Dynamic Gaussian Kubo-Toyabe (strong collision; Hayano et al. PRB 20, 850 (1979)).

Strong-collision generalisation of the static Gaussian KT: a Gaussian local field of width Delta (us^-1) fluctuating at rate nu (MHz), with optional longitudinal field B_L (Gauss).

  • nu -> 0 : recovers the static (LF) Gaussian Kubo-Toyabe.

  • nu >> Delta : motional narrowing, G -> exp(-2 Delta^2 t / nu) (B_L = 0).

  • B_L -> inf : decoupling, G -> 1.

asymmetry.core.fitting.models.dynamic_lorentzian_kt(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], A0: float, a_L: float, nu: float, B_L: float = 0.0, baseline: float = 0.0) ndarray[tuple[int, ...], dtype[_ScalarType_co]][source]

Dynamic Lorentzian Kubo-Toyabe (strong collision; Uemura et al. PRB 31, 546 (1985)).

Strong-collision generalisation of the static Lorentzian KT for a dilute / Lorentzian local-field distribution of half-width a_L (us^-1) fluctuating at rate nu (MHz), with optional longitudinal field B_L (Gauss).

  • nu -> 0 : recovers the static Lorentzian KT (zero-field analytic eqn 5.47; longitudinal field computed numerically per Blundell et al. 2022).

  • B_L -> inf : decoupling, G -> 1.

Composite models

class asymmetry.core.fitting.composite.ComponentDefinition(name: str, description: str, function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], param_names: list[str], param_defaults: dict[str, float], param_info: dict[str, ParamInfo], formula_template: str, latex_equation: str = '', category: str = 'General', domain: str = 'time', fixed_params: tuple[str, ...] = (), user: bool = False, missing: bool = False, knight_observable: str | None = None, field_geometries: frozenset[FieldGeometry] = frozenset({FieldGeometry.LF, FieldGeometry.TF, FieldGeometry.ZF}), physics_classes: frozenset[PhysicsClass] = frozenset({PhysicsClass.CUSTOM}), cost: ComputationalCost = ComputationalCost.MODERATE)[source]

Bases: object

Descriptor for a baseline-free component function.

name: str
description: str
function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]]
param_names: list[str]
param_defaults: dict[str, float]
param_info: dict[str, ParamInfo]
formula_template: str
latex_equation: str = ''
category: str = 'General'
domain: str = 'time'
fixed_params: tuple[str, ...] = ()

Parameters that should start fixed in a fit (e.g. a nuclear spin the model is piecewise-constant in, or a hyperfine constant that is known). The GUI pre-checks the fix box; the user can always free them.

user: bool = False

True for components registered through the user-function facade (asymmetry.core.fitting.user_functions). Provenance is keyed off this flag — picker badges and the docs-enforcement exemptions — never off name lists.

missing: bool = False

True for the per-instance placeholder definitions that stand in for a user component referenced by a project but not currently registered. Placeholders evaluate to zero and are never inserted into COMPONENTS.

knight_observable: str | None = None

For a precession component whose fitted value is the local field/frequency at the muon (so it can be converted to a Knight shift), the name of that parameter ("frequency" or "field"). None for everything else — crucially including muonium components, whose field parameter is the applied field fed into the Breit–Rabi levels, not a local field.

field_geometries: frozenset[FieldGeometry] = frozenset({FieldGeometry.LF, FieldGeometry.TF, FieldGeometry.ZF})

Applied-field geometries the component is physically meaningful for. The fit wizard uses this to drop components that cannot apply to a run’s geometry. Defaults to all three (no restriction).

physics_classes: frozenset[PhysicsClass] = frozenset({PhysicsClass.CUSTOM})

Physics-class tags for wizard scoping. CUSTOM is the untagged sentinel default for user functions; every built-in must override it with real class(es) — enforced by tests/test_component_tags.py.

cost: ComputationalCost = 'moderate'

Relative evaluation-cost hint for tiered wizard screening (the wizard trials cheap components before expensive ones).

__init__(name: str, description: str, function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], param_names: list[str], param_defaults: dict[str, float], param_info: dict[str, ParamInfo], formula_template: str, latex_equation: str = '', category: str = 'General', domain: str = 'time', fixed_params: tuple[str, ...] = (), user: bool = False, missing: bool = False, knight_observable: str | None = None, field_geometries: frozenset[FieldGeometry] = frozenset({FieldGeometry.LF, FieldGeometry.TF, FieldGeometry.ZF}), physics_classes: frozenset[PhysicsClass] = frozenset({PhysicsClass.CUSTOM}), cost: ComputationalCost = ComputationalCost.MODERATE) None
class asymmetry.core.fitting.composite.CompositeModel(component_names: list[str], operators: list[str] | None = None, open_parentheses: list[int] | None = None, close_parentheses: list[int] | None = None, fraction_groups: list[tuple[int, int]] | None = None, *, allow_missing: bool = False)[source]

Bases: object

A flat composite model built from baseline-free components.

allow_missing lets a model materialise even when some component names are not registered (see placeholder_component_definition()); callers that fit or edit the model must check missing_component_names.

__init__(component_names: list[str], operators: list[str] | None = None, open_parentheses: list[int] | None = None, close_parentheses: list[int] | None = None, fraction_groups: list[tuple[int, int]] | None = None, *, allow_missing: bool = False) None[source]
missing_component_names: tuple[str, ...]
classmethod from_expression(expression: str) CompositeModel[source]

Construct a CompositeModel from a component-name expression.

parameter_mapping() list[dict[str, str]][source]

Return per-component maps of local parameter name → unique fit name.

One dict per entry of components, in the same order. Copies are returned so callers (e.g. the RRF frequency-offset wrapper) cannot mutate the model’s internal mapping.

knight_observable_params() dict[str, str][source]

Map fitted parameter name → kind for Knight-convertible components.

For each component whose value is a local precession field/frequency (ComponentDefinition.knight_observable set — Oscillatory/Bessel give "frequency", OscillatoryField gives "field"), return its unique fitted parameter name pointing at that kind. Components whose field is the applied field (muonium TF) are excluded, so a Knight-shift conversion never mistakes the applied field for a local one.

component_expression_string() str[source]

Return the builder-facing expression using component names.

fraction_weights(values: dict[str, float]) dict[str, float][source]

Return {name: weight} for every fraction term across all groups.

Both the n-1 free parameters (keyed by their real names, carrying the clamped [0, 1] value) and the derived remainder of each group (keyed by its synthesized display name, carrying 1 - Σ free) appear. A group is skipped entirely when any of its free parameters is missing from values, so callers never receive a partial partition.

normalized_parameter_values(values: dict[str, float]) dict[str, float][source]

Return a copy with free fraction parameters clamped into [0, 1].

Under the n-1 scheme the fitted values are already the physical free weights, so this only clamps each free fraction into its [0, 1] range (leaving every other entry untouched). Kept because callers rely on it to present display-ready values.

fraction_parameter_groups() list[list[str]][source]

Return the free fraction-parameter names grouped by fraction group.

Each group contributes its n-1 free parameter names; the remainder term has no parameter and is not listed here (see derived_fraction_names() for its display label).

derived_fraction_names() list[str][source]

Return one synthesized display name per group for its remainder term.

These are the f_<Component> labels of each group’s last additive term — display-only names (no fitted parameter) that never collide with a real parameter name. Ordered to match fraction_groups.

derived_fraction_terms() list[tuple[str, tuple[int, int]]][source]

Return (display_name, group) for each group’s remainder term.

Pairs each derived remainder name with the fraction group it labels, so a caller can place the remainder row and know which group it belongs to.

with_default_fraction_groups() CompositeModel[source]

Return a copy with a top-level additive fraction group when suitable.

domains() set[str][source]

Return the set of analysis domains of the model’s components.

A well-formed model has a single domain ({"time"} or {"frequency"}); a mixed set indicates a model that combines time- and frequency-domain components (e.g. restored from a project saved before domain filtering existed) and should be surfaced to the user rather than silently fitted.

Missing-component placeholders are skipped: their domain is unknowable, and the missing-ness itself is surfaced separately (fit blocking via missing_component_names).

fixed_by_default_params() set[str][source]

Unique parameter names that should start fixed in a fit.

Collected from each component’s ComponentDefinition.fixed_params through the model’s parameter mapping (so duplicated components yield their indexed names, e.g. J_spin_2).

function(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], **kwargs: float) ndarray[tuple[int, ...], dtype[float64]][source]

Evaluate the composite function with standard arithmetic precedence.

additive_component_indices() list[int][source]

Return component indices that contribute in additive (+) form.

This includes the first component and any component joined with a + operator. Components joined with -, *, or / are excluded because their visual contribution is not an additive area.

evaluate_components(t: ndarray[tuple[int, ...], dtype[_ScalarType_co]], *, additive_only: bool = False, **kwargs: float) list[tuple[str, ndarray[tuple[int, ...], dtype[float64]]]][source]

Evaluate individual component curves.

Parameters:
  • t (array-like) – Time points where components are evaluated.

  • additive_only (bool, optional) – If True, only return additive components (first component and components joined with + operators).

  • **kwargs (float) – Composite-model parameters using unique parameter names.

formula_string() str[source]

Return a symbolic formula preview string.

latex_terms() list[LatexTerm][source]

Return the model as a list of typeset (mathtext) additive terms.

Splits at top-level +/- operators; each term carries its separator (”” for the first, “ + “/” - “ otherwise) and the sole fraction group its component range intersects (or None). Never raises and never returns an empty list for a valid model — a single multiplicative chain yields one term.

latex_string() str[source]

Return the whole model as one mathtext string.

The concatenation "".join(sep + latex) over latex_terms().

to_model_definition(name: str = 'Composite') ModelDefinition[source]

Create a ModelDefinition-compatible wrapper for the fit engine.

to_dict() dict[source]

Return a JSON-serializable representation of the model.

classmethod from_dict(data: dict, *, allow_missing: bool = False) CompositeModel[source]

Construct a CompositeModel from serialized data.

With allow_missing=True, component names that are not registered materialise as named zero-valued placeholders instead of raising — the degrade path for projects referencing user functions that are not installed in this session (the original names round-trip unchanged through to_dict()).

Note

The available time-domain component registry is exposed as asymmetry.core.fitting.composite.COMPONENTS.

Diffusive longitudinal-field relaxation model.

Implements field-dependent relaxation rates for diffusive spin excitations:

  • lambda(B_LF) = lambda_diff(B_LF) + lambda_0D

  • lambda_diff(B_LF) = (C^2 / 4) * J(omega)

  • omega = gamma_e * B_LF

The diffusion autocorrelation uses the nD form from Pratt (J. Phys.: Conf. Ser. 2462 012038, 2023), with n in {1, 2, 3}:

S_nD(t) = [exp(-2 D_nD t) I0(2 D_nD t)]^n

[exp(-2 D_perp t) I0(2 D_perp t)]^(3-n)

Numerics

The spectral density is a one-sided cosine transform:

J(omega) = 2 * integral_0^inf S(t) cos(omega t) dt

Two evaluation strategies are available:

  • A fast, vectorized Filon transform (the default): S(t) is sampled once on a shared log-spaced grid up to a finite t_max chosen from the diffusion rates, and J(omega) is evaluated for the whole omega vector at once. Within each grid segment S(t) is treated as piecewise linear and the oscillatory cos(omega t) factor is integrated analytically. Because the oscillation is handled in closed form per segment, the grid only has to resolve the smooth envelope S(t) – not the (arbitrarily fast) cosine – so a modest grid stays accurate even when omega is very large. See _spectral_density_fast for the design notes and accuracy bounds.

  • The original scalar scipy.integrate.quad cosine-weighted integrator, retained as _spectral_density_quad. It is used as a parity reference in the tests, as an automatic fall-back for the very-slow-diffusion corner where the fixed grid would need an impractically large t_max (see _DIFFUSION_MIN_RATE_FLOOR), and as a global escape hatch when the environment variable ASYMMETRY_DIFFUSION_QUAD is set to a truthy value.

A finite upper limit t_max is chosen adaptively from the diffusion rates to provide stable and reproducible behavior suitable for GUI fitting workflows.

asymmetry.core.fitting.diffusion.autocorrelation_nD(t: ndarray[tuple[int, ...], dtype[float64]] | list[float], D_nD: float, D_perp: float = 0.0, n: int = 2) ndarray[tuple[int, ...], dtype[float64]][source]

Return S_nD(t) for n-dimensional diffusion.

Parameters:
  • t – Time in microseconds.

  • D_nD – In-plane diffusion rate in us^-1.

  • D_perp – Perpendicular diffusion rate in us^-1.

  • n – Dimensionality, one of 1, 2, or 3.

asymmetry.core.fitting.diffusion.spectral_density(omega: float, D_nD: float, D_perp: float = 0.0, n: int = 2) float[source]

Return one-sided cosine spectral density J(omega).

Uses the convention J(omega) = 2 * integral_0^inf S(t) cos(omega t) dt. For omega = 0, this function returns +inf because the integral diverges for algebraic long-time tails relevant to low-dimensional diffusion.

asymmetry.core.fitting.diffusion.lambda_diff(B_LF: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, C: float, D_nD: float, D_perp: float = 0.0, n: int = 2) ndarray[tuple[int, ...], dtype[float64]][source]

Return field-dependent diffusive relaxation rate lambda_diff(B_LF).

asymmetry.core.fitting.diffusion.lambda_total(B_LF: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, C: float, D_nD: float, lambda_0D: float = 0.0, D_perp: float = 0.0, n: int = 2) ndarray[tuple[int, ...], dtype[float64]][source]

Return total LF relaxation lambda(B_LF) including field-independent term.

asymmetry.core.fitting.diffusion.is_scipy_available() bool[source]

Return whether SciPy is available.

With eager imports, SciPy is now a hard requirement loaded at module import. This function always returns True; it exists for backward compatibility.

Muon-fluorine models

Dipolar Hamiltonian helpers for muon-fluorine spin systems.

asymmetry.core.fitting.muon_fluorine.dipolar.omega_d_mu_f_rad_per_us(distance_angstrom: float) float[source]

Return mu-F dipolar angular frequency in rad/us.

asymmetry.core.fitting.muon_fluorine.dipolar.omega_d_f_f_rad_per_us(distance_angstrom: float) float[source]

Return F-F dipolar angular frequency in rad/us.

asymmetry.core.fitting.muon_fluorine.dipolar.omega_dipolar_rad_per_us(distance_angstrom: float, gamma_i_mhz_per_t: float, gamma_j_mhz_per_t: float) float[source]

Return dipolar angular frequency in rad/us for a spin pair.

asymmetry.core.fitting.muon_fluorine.dipolar.r_mu_f_from_omega_d(omega_d_rad_per_us: float) float[source]

Invert omega_d_mu_f_rad_per_us(): mu-F distance in angstrom.

omega_d scales as r^-3, so r = (omega_d(1 A) / omega_d)^(1/3).

asymmetry.core.fitting.muon_fluorine.dipolar.three_spin_hamiltonian_rad_per_us(coupling_mu_f1: float, coupling_mu_f2: float, coupling_f1_f2: float, n_mu_f1: ndarray[tuple[int, ...], dtype[float64]], n_mu_f2: ndarray[tuple[int, ...], dtype[float64]], n_f1_f2: ndarray[tuple[int, ...], dtype[float64]]) ndarray[tuple[int, ...], dtype[complex128]][source]

Construct the full 8x8 dipolar Hamiltonian (angular-frequency units).

Muon-fluorine polarization functions for entangled spin states.

asymmetry.core.fitting.muon_fluorine.polarization.mu_f_polarization(t: ndarray[tuple[int, ...], dtype[float64]], r_muF: float) ndarray[tuple[int, ...], dtype[float64]][source]

Analytical mu-F longitudinal polarization, D_z(t), for one fluorine.

asymmetry.core.fitting.muon_fluorine.polarization.linear_fmuf_polarization(t: ndarray[tuple[int, ...], dtype[float64]], r_muF: float) ndarray[tuple[int, ...], dtype[float64]][source]

Analytical collinear F-mu-F polarization from the classic ionic-crystal model.

asymmetry.core.fitting.muon_fluorine.polarization.dynamic_fmuf_polarization(t: ndarray[tuple[int, ...], dtype[float64]], r_muF: float, nu: float) ndarray[tuple[int, ...], dtype[float64]][source]

Strong-collision dynamicized linear F-mu-F polarization (WiMDA dyn F-u-F).

The static collinear F-mu-F polarization G_s (eqn 4.81 of Blundell, De Renzi, Lancaster & Pratt, Muon Spectroscopy, OUP 2022) dynamicized by the strong-collision integral equation (eqn 5.30):

G_d(t) = e^{-nu t} G_s(t) + nu * integral_0^t G_d(t - t’) e^{-nu t’} G_s(t’) dt’

modelling muon hopping away from the F-mu-F site (or fluctuation of the coupling) at rate nu (µs⁻¹). nu = 0 reduces exactly to the static linear_fmuf_polarization(); large nu gives motional narrowing toward exp(-2 omega_d^2 t / nu). Unlike WiMDA, the integration horizon is derived from the requested time range rather than a user-visible tmax parameter, the solution is cached per (r_muF, nu, tmax), and the solver is used over its full accuracy range with an Abragam-form interpolation beyond it (WiMDA jumps to the bare narrowing exponential at a fixed rate, leaving a discontinuity in the model).

asymmetry.core.fitting.muon_fluorine.polarization.general_fmuf_polarization(t: ndarray[tuple[int, ...], dtype[float64]], r1: float, r2: float, theta: float) ndarray[tuple[int, ...], dtype[float64]][source]

Numerical powder-averaged polarization for a general F-mu-F geometry.

The geometry is parameterized by two mu-F distances (r1, r2) in Angstrom and a bond angle theta in degrees. The eigenspectrum for each geometry is cached to keep fitting workloads feasible when the same geometry is re-evaluated.

asymmetry.core.fitting.muon_fluorine.polarization.fmuf_triangle_polarization(t: ndarray[tuple[int, ...], dtype[float64]], r_muF: float, r3: float, phi3: float) ndarray[tuple[int, ...], dtype[float64]][source]

Powder-averaged polarization for F-mu-F plus a third fluorine (16-dim).

A collinear F-mu-F pair (both fluorines at r_muF, as in linear_fmuf_polarization()) plus a third fluorine at distance r3 and angle phi3 (degrees) from the F-mu-F axis, solved exactly in the 16-dimensional muon + 3F Hilbert space with all pairwise dipolar couplings (mu-F and F-F) and a proper powder average.

This supersedes WiMDA’s F-u-F-F ZF PCR, which neglects the F-F couplings and approximates the powder average by (P_z + 2 P_x)/3 for a single crystal orientation pair; see docs/porting/wimda-fit-function-parity/comparison.md. As r3 -> infinity the result approaches the linear F-mu-F polarization.

Parameter trend models

class asymmetry.core.fitting.parameter_models.ParameterModelComponentDefinition(name: str, description: str, function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], param_names: list[str], param_defaults: dict[str, float], param_info: dict[str, ParamInfo], formula_template: str, latex_equation: str = '', scopes: tuple[str, ...] = ('common',), fwhm_factor: float | None = None, user: bool = False, category: str = 'General')[source]

Bases: object

Descriptor for a parameter-vs-x basis function.

name: str
description: str
function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]]
param_names: list[str]
param_defaults: dict[str, float]
param_info: dict[str, ParamInfo]
formula_template: str
latex_equation: str = ''
scopes: tuple[str, ...] = ('common',)
fwhm_factor: float | None = None

For centred-peak components, FWHM = fwhm_factor * Bwid (None otherwise).

user: bool = False

True for components registered through the user-function facade (asymmetry.core.fitting.user_functions); see ComponentDefinition.user.

category: str = 'General'

Library-panel grouping (mirrors ComponentDefinition.category). Assigned in bulk via _PARAMETER_MODEL_CATEGORIES below rather than per-definition, so the taxonomy is reviewable in one place.

__init__(name: str, description: str, function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], param_names: list[str], param_defaults: dict[str, float], param_info: dict[str, ParamInfo], formula_template: str, latex_equation: str = '', scopes: tuple[str, ...] = ('common',), fwhm_factor: float | None = None, user: bool = False, category: str = 'General') None
class asymmetry.core.fitting.parameter_models.ParameterCompositeModel(component_names: list[str], operators: list[str] | None = None, open_parentheses: list[int] | None = None, close_parentheses: list[int] | None = None)[source]

Bases: object

Flat composite model for parameter-vs-x fitting.

__init__(component_names: list[str], operators: list[str] | None = None, open_parentheses: list[int] | None = None, close_parentheses: list[int] | None = None) None[source]
classmethod from_expression(expression: str) ParameterCompositeModel[source]

Construct a ParameterCompositeModel from a component-name expression.

component_expression_string() str[source]

Return the builder-facing expression using component names.

component_param_name(component_index: int, local_name: str) str[source]

Global (possibly index-suffixed) param name for a component’s param.

For ["GaussianLCR", "LorentzianLCR"] the second component’s "B0" is "B0_2"; for a single component it is the bare "B0". Lets callers address a component’s parameters by name instead of positional arithmetic over param_names.

formula_string() str[source]

Return a readable formula string for display.

latex_terms() list[LatexTerm][source]

Return the trend model as typeset (mathtext) additive terms.

group is always None (parameter-vs-x models have no fraction groups). The quadrature operator renders a flat chain as \sqrt{a^{2} + b^{2}}. Parenthesised or -mixed expressions that are ambiguous to lay out fall back to a single \mathrm{...} term. Never raises and never returns an empty list for a valid model.

latex_string() str[source]

Return the whole trend model as one mathtext string.

function(x: ndarray[tuple[int, ...], dtype[_ScalarType_co]], **kwargs: float) ndarray[tuple[int, ...], dtype[float64]][source]

Evaluate the composite model with arithmetic precedence.

additive_component_indices() list[int][source]

Return indices for additive components (first term, ‘+’ and ‘⊕’ terms).

Quadrature () combines whole components like + does, so each operand is a distinct curve worth plotting on its own; subtractive and multiplicative terms are not standalone contributions.

evaluate_components(x: ndarray[tuple[int, ...], dtype[_ScalarType_co]], *, additive_only: bool = False, **kwargs: float) list[tuple[str, ndarray[tuple[int, ...], dtype[float64]]]][source]

Evaluate individual component curves for plotting/export.

to_dict() dict[str, list[str] | list[int]][source]

Return a JSON-serializable representation of the model.

classmethod from_dict(data: dict) ParameterCompositeModel[source]

Construct a ParameterCompositeModel from serialized data.

class asymmetry.core.fitting.parameter_models.ParameterModelFitResult(success: bool, chi_squared: float = 0.0, reduced_chi_squared: float = 0.0, parameters: ~asymmetry.core.fitting.parameters.ParameterSet = <factory>, uncertainties: dict[str, float] = <factory>, message: str = '', error_mode: str = 'column', n_points: int = 0, n_starts_tried: int = 0, seed_beat_user_start: bool = False, params_at_bound: tuple[str, ...] = (), covariance: tuple[list[str], list[list[float]]] | None = None)[source]

Bases: object

Result of fitting a parameter-vs-x model.

success: bool
chi_squared: float = 0.0
reduced_chi_squared: float = 0.0
parameters: ParameterSet
uncertainties: dict[str, float]
message: str = ''
error_mode: str = 'column'

Error mode the fit was run with (χ²ᵣ carries no goodness information for "none"/"scatter" — quality verdicts should be suppressed).

n_points: int = 0

Number of points that entered the fit (0 when unknown/legacy).

n_starts_tried: int = 0

How many initial-value candidates the multi-start machinery actually tried (1 for the default single-start path; more with extra_starts).

seed_beat_user_start: bool = False

True iff the winning fit came from a non-user start (a data-aware or perturbed seed beat the user’s provided parameters). Advisory only.

params_at_bound: tuple[str, ...] = ()

Names of free parameters whose fitted value sits within tolerance of a finite min/max bound — a sign the data did not determine them.

covariance: tuple[list[str], list[list[float]]] | None = None

(names, matrix) of the free-parameter covariance from Minuit, in the same order as names (free parameters only, fixed parameters excluded). None when unavailable: the fit failed/invalid or Minuit produced no covariance (e.g. HESSE did not run/converge).

__init__(success: bool, chi_squared: float = 0.0, reduced_chi_squared: float = 0.0, parameters: ~asymmetry.core.fitting.parameters.ParameterSet = <factory>, uncertainties: dict[str, float] = <factory>, message: str = '', error_mode: str = 'column', n_points: int = 0, n_starts_tried: int = 0, seed_beat_user_start: bool = False, params_at_bound: tuple[str, ...] = (), covariance: tuple[list[str], list[list[float]]] | None = None) None
class asymmetry.core.fitting.parameter_models.ModelFitRange(x_min: float | None, x_max: float | None, model: ParameterCompositeModel, parameters: ParameterSet, result: ParameterModelFitResult | None = None, windows: list[tuple[float, float]] | None = None)[source]

Bases: object

A model and fit results over a specific x-range.

windows optionally restricts the range to a union of (min, max) intervals: a point enters the fit if it falls in any window (OR-combination), with one model fitted across the union. When absent, the plain x_min/x_max bounds apply.

x_min: float | None
x_max: float | None
model: ParameterCompositeModel
parameters: ParameterSet
result: ParameterModelFitResult | None = None
windows: list[tuple[float, float]] | None = None
__init__(x_min: float | None, x_max: float | None, model: ParameterCompositeModel, parameters: ParameterSet, result: ParameterModelFitResult | None = None, windows: list[tuple[float, float]] | None = None) None
class asymmetry.core.fitting.parameter_models.ParameterModelFit(parameter_name: str, x_key: str, ranges: list[ModelFitRange] = <factory>, active: bool = True, use_x_errors: bool = False)[source]

Bases: object

Model fits attached to a single parameter trace.

parameter_name: str
x_key: str
ranges: list[ModelFitRange]
active: bool = True
use_x_errors: bool = False

When the x-axis is a fitted parameter (x_key == "param:<name>"), account for its per-point uncertainty via effective-variance weighting.

__init__(parameter_name: str, x_key: str, ranges: list[ModelFitRange] = <factory>, active: bool = True, use_x_errors: bool = False) None
class asymmetry.core.fitting.parameter_models.ParameterModelFitExecution(range_index: int, x: ndarray[tuple[int, ...], dtype[float64]], y: ndarray[tuple[int, ...], dtype[float64]], result: ParameterModelFitResult)[source]

Bases: object

Fitted model with sampled curve ready for plotting/export.

range_index: int
x: ndarray[tuple[int, ...], dtype[float64]]
y: ndarray[tuple[int, ...], dtype[float64]]
result: ParameterModelFitResult
__init__(range_index: int, x: ndarray[tuple[int, ...], dtype[float64]], y: ndarray[tuple[int, ...], dtype[float64]], result: ParameterModelFitResult) None
class asymmetry.core.fitting.parameter_models.ParameterGroupData(group_id: str, group_name: str, x: ndarray[tuple[int, ...], dtype[float64]], y: ndarray[tuple[int, ...], dtype[float64]], yerr: ndarray[tuple[int, ...], dtype[float64]], group_variable_value: float, xerr: ndarray[tuple[int, ...], dtype[float64]] | None = None)[source]

Bases: object

Input data for one selected group in a cross-group parameter fit.

group_id: str
group_name: str
x: ndarray[tuple[int, ...], dtype[float64]]
y: ndarray[tuple[int, ...], dtype[float64]]
yerr: ndarray[tuple[int, ...], dtype[float64]]
group_variable_value: float
xerr: ndarray[tuple[int, ...], dtype[float64]] | None = None

Optional per-point x-uncertainty (aligned to x), used only for an effective-variance fit when the abscissa is itself a fitted parameter.

to_dict() dict[source]

Canonical, JSON-safe serialization (numpy arrays -> lists).

classmethod from_dict(data: Mapping[str, object]) ParameterGroupData[source]

Tolerant deserialization: missing arrays default to empty.

__init__(group_id: str, group_name: str, x: ndarray[tuple[int, ...], dtype[float64]], y: ndarray[tuple[int, ...], dtype[float64]], yerr: ndarray[tuple[int, ...], dtype[float64]], group_variable_value: float, xerr: ndarray[tuple[int, ...], dtype[float64]] | None = None) None
class asymmetry.core.fitting.parameter_models.CrossGroupFitResult(success: bool, chi_squared: float, reduced_chi_squared: float, global_parameters: ~asymmetry.core.fitting.parameters.ParameterSet = <factory>, local_parameters: dict[str, ~asymmetry.core.fitting.parameters.ParameterSet] = <factory>, fixed_parameters: ~asymmetry.core.fitting.parameters.ParameterSet = <factory>, global_uncertainties: dict[str, float] = <factory>, local_uncertainties: dict[str, dict[str, float]] = <factory>, message: str = '', error_mode: str = 'column', n_points: int = 0, per_group_chi_squared: dict[str, float] = <factory>, per_group_n_points: dict[str, int] = <factory>, global_correlations: tuple[list[str], list[list[float]]] | None = None)[source]

Bases: object

Result container for cross-group parameter fits.

success: bool
chi_squared: float
reduced_chi_squared: float
global_parameters: ParameterSet
local_parameters: dict[str, ParameterSet]
fixed_parameters: ParameterSet
global_uncertainties: dict[str, float]
local_uncertainties: dict[str, dict[str, float]]
message: str = ''
error_mode: str = 'column'

Error mode the fit was run with (χ²ᵣ carries no goodness information for "none"/"scatter" — quality verdicts should be suppressed).

n_points: int = 0

Number of points that entered the fit across all groups (0 if unknown).

per_group_chi_squared: dict[str, float]

Per-group χ², keyed by group_id. Computed from the same masked/ windowed residuals (and xerr effective-variance term, when active) that the joint cost function used, so sum(per_group_chi_squared.values()) == chi_squared. Under SCATTER error mode this is reported on the pre-rescale basis — the same basis chi_squared itself uses (the rescale only touches parameter uncertainties, never the fitted χ²).

per_group_n_points: dict[str, int]

Number of points that entered the fit for each group (post mask/window).

global_correlations: tuple[list[str], list[list[float]]] | None = None

(names, matrix) of free GLOBAL parameter correlations derived from the Minuit covariance (corr_ij = cov_ij / sqrt(cov_ii * cov_jj)). None when unavailable: fewer than two free global parameters, a failed fit, or no covariance from Minuit.

to_dict() dict[source]

Canonical, JSON-safe serialization.

ParameterSets serialize as lists of {name, value, min, max, fixed} dicts (see _parameter_set_to_dicts()), matching the shape already used by the GUI’s project-state serializers.

classmethod from_dict(data: Mapping[str, object]) CrossGroupFitResult[source]

Tolerant deserialization: missing/malformed keys fall back to empty defaults rather than raising, so a legacy payload (recorded before these fields existed) still loads.

__init__(success: bool, chi_squared: float, reduced_chi_squared: float, global_parameters: ~asymmetry.core.fitting.parameters.ParameterSet = <factory>, local_parameters: dict[str, ~asymmetry.core.fitting.parameters.ParameterSet] = <factory>, fixed_parameters: ~asymmetry.core.fitting.parameters.ParameterSet = <factory>, global_uncertainties: dict[str, float] = <factory>, local_uncertainties: dict[str, dict[str, float]] = <factory>, message: str = '', error_mode: str = 'column', n_points: int = 0, per_group_chi_squared: dict[str, float] = <factory>, per_group_n_points: dict[str, int] = <factory>, global_correlations: tuple[list[str], list[list[float]]] | None = None) None
asymmetry.core.fitting.parameter_models.component_names_for_x(x_key: str) list[str][source]

Return available basis functions for a given x-variable.

asymmetry.core.fitting.parameter_models.fit_parameter_model(x: ndarray[tuple[int, ...], dtype[_ScalarType_co]], y: ndarray[tuple[int, ...], dtype[_ScalarType_co]], yerr: ndarray[tuple[int, ...], dtype[_ScalarType_co]] | None, model: ParameterCompositeModel, parameters: ParameterSet, x_min: float | None = None, x_max: float | None = None, method: str = 'migrad', error_mode: ErrorMode | str = ErrorMode.COLUMN, error_value: float | None = None, windows: Sequence[tuple[float, float]] | None = None, xerr: ndarray[tuple[int, ...], dtype[_ScalarType_co]] | None = None, extra_starts: int = 0, seed: int = 0) ParameterModelFitResult[source]

Fit a parameter-vs-x model using iminuit.

error_mode/error_value select the per-point σ assignment (see ErrorMode); windows optionally restricts the fit to a union of (min, max) intervals, overriding x_min/x_max.

xerr optionally supplies per-point x-uncertainties for an errors-in-variables (Orear/York effective-variance) fit — used for parameter-vs-parameter trending where the abscissa is itself a fitted quantity. When xerr is None or all zero/non-finite the fit is ordinary least squares (the abscissa is treated as exact). It is ignored under the NONE/SCATTER error modes, whose unit y-weights have no physical scale to combine with the x-variance term.

extra_starts adds multi-start robustness: with extra_starts > 0 the fit additionally tries the generic data-aware seed (suggest_model_seeds()) and extra_starts deterministic perturbed starts around the user’s parameters, keeping the best converged candidate by the existing success-then-χ² rule. The perturbation is driven solely by numpy.random.default_rng(seed), so a given seed is fully reproducible. extra_starts = 0 (the default) is byte-identical to the single-start behaviour — no RNG is constructed and the candidate list is unchanged.

asymmetry.core.fitting.parameter_models.global_fit_parameter_model(groups: list[ParameterGroupData], model: ParameterCompositeModel, global_params: list[str], local_params: list[str], fixed_params: dict[str, float], initial_params: dict[str, float] | None = None, parameter_bounds: dict[str, tuple[float, float]] | None = None, method: str = 'migrad', error_mode: ErrorMode | str = ErrorMode.COLUMN, error_value: float | None = None, windows: Sequence[tuple[float, float]] | None = None, xerr: Mapping[str, ndarray[tuple[int, ...], dtype[_ScalarType_co]]] | None = None) CrossGroupFitResult[source]

Jointly fit a parameter model across multiple groups.

Parameters are classified as: - global: one shared value across all groups - local: independent value per group - fixed: fixed constant

error_mode/error_value select the per-point σ assignment for every group (see ErrorMode); windows optionally restricts each group to a union of (min, max) intervals (OR-combined, one model across the union). SCATTER rescales all parameter errors (global and local) by √(χ²/ν) after the fit — the fixed point of WiMDA’s Estimate iteration.

xerr optionally maps group_id → per-point x-uncertainties (aligned to that group’s stored x) for an errors-in-variables (Orear/York effective-variance) fit, used for parameter-vs-parameter trending where the abscissa is itself a fitted quantity. It uses the same central-difference estimator as the single-series fit_parameter_model(). A group with no entry, or all-zero/non-finite σ_x, keeps ordinary least squares. Like the single-series path, xerr is ignored under NONE/SCATTER, whose unit y-weights carry no physical scale to combine with the x-variance term.

asymmetry.core.fitting.parameter_models.evaluate_parameter_model_fit(fit: ParameterModelFit, num_points: int = 200) list[ParameterModelFitExecution][source]

Return sampled curves for each successful active range.

Note

The available parameter-trend component registry is exposed as asymmetry.core.fitting.parameter_models.PARAMETER_MODEL_COMPONENTS.

The angle-only \(K(\theta)\) basis models KnightAnisotropy, AngularCos2, and AngularFourier2 are registered alongside the others (scope="angle").

Experiment design

The Laplace/Fisher “where to measure next” acquisition shared by Suggest next point (a scalar trend) and the Knight-shift window’s Suggest next angle (see Knight shift and Parameter trending) — expected information gain, model discrimination, and the Hungarian set-matching utility used when a candidate’s components are unlabelled.

Bayesian experimental design for parameter trending.

Given a converged trend-model fit (fit_parameter_model), suggest where the next measurement should go — and how many events it should collect — to most constrain the model’s parameters. The acquisition is the Laplace (Gaussian-posterior) expected information gain for a single new datum:

IG(x) = 1/2 log[1 + g(x)^T Sigma g(x) / sigma_new^2(x)] (D-optimal)

DeltaVar_k(x) = (Sigma g(x))_k^2 / (sigma_new^2(x) + g(x)^T Sigma g(x))

(c-optimal)

where g(x) is the model’s parameter sensitivity at the fitted values and Sigma the fit covariance. The rank-one c-optimal update is exact for a linear model; for strongly nonlinear models (critical exponents) it ranks candidates correctly but underestimates the realised post-fit variance — see docs/studies/prototype-results.md — so the counting recommendation must be calibrated with calibrate_suggestion before being shown to a user.

Everything here is GUI-free and pure numpy; refits happen only inside calibrate_suggestion.

class asymmetry.core.fitting.experiment_design.NextPointSuggestion(x_candidates: ndarray[tuple[int, ...], dtype[float64]], utility: ndarray[tuple[int, ...], dtype[float64]], extrapolated: ndarray[tuple[int, ...], dtype[bool]], best_x: float, target: str | None, sigma_new: ndarray[tuple[int, ...], dtype[float64]], predicted_post_sigma: float | None = None, events_factor_to_target: float | None = None, target_unreachable: bool = False, floor_sigma: float | None = None, risk_mask: ndarray[tuple[int, ...], dtype[bool]] | None = None, warnings: tuple[str, ...] = ())[source]

Bases: object

Utility curve and recommendation for the next measurement.

utility is DeltaVar_k (variance reduction of the target parameter) in c-optimal mode, or the information gain IG in D-optimal mode (target is None). Two degenerate shapes exist, both explained by warnings: unusable inputs (bad covariance, no noise model, …) yield empty arrays with best_x NaN, while a valid setup where no candidate carries information keeps the full curve (all-zero utility) with best_x NaN.

x_candidates: ndarray[tuple[int, ...], dtype[float64]]
utility: ndarray[tuple[int, ...], dtype[float64]]
extrapolated: ndarray[tuple[int, ...], dtype[bool]]
best_x: float
target: str | None
sigma_new: ndarray[tuple[int, ...], dtype[float64]]
predicted_post_sigma: float | None = None

Predicted post-measurement sigma of the target at best_x for a reference-statistics point (c-optimal mode only). Rank-one estimate — optimistic near critical points; calibrate before display.

events_factor_to_target: float | None = None

N/N_ref needed at best_x to reach sigma_goal (rank-one estimate). 0.0 means the goal is already met without a new point.

target_unreachable: bool = False
floor_sigma: float | None = None

sigma of the target in the N -> infinity limit of a single new point at best_x — the floor set by the other parameters’ uncertainty.

risk_mask: ndarray[tuple[int, ...], dtype[bool]] | None = None

Per-candidate assignment-risk flag (multi-series acquisition only). True where two series’ predicted values lie within _ASSIGNMENT_RISK_K sigma of each other, so a new datum there may attach to the wrong curve. None for single-series suggestions, which have no crossing to worry about.

warnings: tuple[str, ...] = ()
__init__(x_candidates: ndarray[tuple[int, ...], dtype[float64]], utility: ndarray[tuple[int, ...], dtype[float64]], extrapolated: ndarray[tuple[int, ...], dtype[bool]], best_x: float, target: str | None, sigma_new: ndarray[tuple[int, ...], dtype[float64]], predicted_post_sigma: float | None = None, events_factor_to_target: float | None = None, target_unreachable: bool = False, floor_sigma: float | None = None, risk_mask: ndarray[tuple[int, ...], dtype[bool]] | None = None, warnings: tuple[str, ...] = ()) None
class asymmetry.core.fitting.experiment_design.SuggestionCalibration(x_new: float, events_factor: float, target: str, n_trials: int, n_failed: int, realized_post_sigma: float, realized_post_sigma_p16: float, realized_post_sigma_p84: float, predicted_post_sigma: float | None = None, calibration_ratio: float | None = None, warnings: tuple[str, ...] = ())[source]

Bases: object

Monte-Carlo check of a suggestion: simulate the point, refit, measure.

realized_post_sigma is the median post-fit sigma of the target over the successful trials; the p16/p84 band shows its spread. calibration_ratio is realised variance / rank-one predicted variance (> 1 means the analytic figure was optimistic).

x_new: float
events_factor: float
target: str
n_trials: int
n_failed: int
realized_post_sigma: float
realized_post_sigma_p16: float
realized_post_sigma_p84: float
predicted_post_sigma: float | None = None
calibration_ratio: float | None = None
warnings: tuple[str, ...] = ()
__init__(x_new: float, events_factor: float, target: str, n_trials: int, n_failed: int, realized_post_sigma: float, realized_post_sigma_p16: float, realized_post_sigma_p84: float, predicted_post_sigma: float | None = None, calibration_ratio: float | None = None, warnings: tuple[str, ...] = ()) None
asymmetry.core.fitting.experiment_design.suggest_next_point(model: ParameterCompositeModel, parameters: ParameterSet, covariance: tuple[list[str], list[list[float]]] | None, x_data: ndarray[tuple[int, ...], dtype[float64]], y_err: ndarray[tuple[int, ...], dtype[float64]], x_min: float, x_max: float, *, target: str | None = None, sigma_goal: float | None = None, n_candidates: int = 257) NextPointSuggestion[source]

Rank candidate x values by how much a new point there constrains the fit.

covariance is the (names, matrix) pair from ParameterModelFitResult.covariance; x_data/y_err are the measured series (the per-point errors define the empirical noise model for the hypothetical new point at reference statistics). target selects c-optimality for that parameter; None means D-optimal. sigma_goal (c-optimal only) additionally solves for the event-count factor N/N_ref needed at the best x — a rank-one planning figure that should be calibrated with calibrate_suggestion() before display.

Never raises on degenerate inputs: returns an empty suggestion with an explanatory warning instead.

class asymmetry.core.fitting.experiment_design.SeriesSpec(model: ParameterCompositeModel, parameters: ParameterSet, covariance: tuple[list[str], list[list[float]]] | None, x_data: ndarray[tuple[int, ...], dtype[float64]], y_err: ndarray[tuple[int, ...], dtype[float64]])[source]

Bases: object

One series’ inputs to a multi-series acquisition.

Mirrors suggest_next_point()’s per-series argument types: a fitted model/parameters pair, the (names, matrix) fit covariance (None when the fit produced none), and the measured x_data/y_err that define the empirical new-point noise model. One physical measurement at a candidate x is assumed to yield one datum for every series (§3.1).

model: ParameterCompositeModel
parameters: ParameterSet
covariance: tuple[list[str], list[list[float]]] | None
x_data: ndarray[tuple[int, ...], dtype[float64]]
y_err: ndarray[tuple[int, ...], dtype[float64]]
__init__(model: ParameterCompositeModel, parameters: ParameterSet, covariance: tuple[list[str], list[list[float]]] | None, x_data: ndarray[tuple[int, ...], dtype[float64]], y_err: ndarray[tuple[int, ...], dtype[float64]]) None
asymmetry.core.fitting.experiment_design.suggest_next_point_multi(series: Sequence[SeriesSpec], x_min: float, x_max: float, *, target: tuple[int, str] | None = None, sigma_goal: float | None = None, n_candidates: int = 257, labels: Sequence[str] | None = None) NextPointSuggestion[source]

Rank candidate x by how much a shared new point constrains several series.

Each entry of series is a fitted SeriesSpec; one measurement at x adds one datum to every series. With target is None the utility is the D-optimal information-gain sum over series (§3.1):

U(x) = Σ_m ½ log[1 + g_m(x)ᵀ Σ_m g_m(x) / σ_m²(x)]

With target = (series_index, param_name) the objective is that one series’ c-optimal variance reduction for param_name — the other series still receive the datum but do not enter the objective — and the sigma_goal/event-count solve applies to the target exactly as in suggest_next_point(). Either way the returned suggestion carries a per-candidate NextPointSuggestion.risk_mask (§3.1): the rank-one update assumes the new datum attaches to the right curve, so candidates near a predicted crossing are flagged.

A series whose covariance is invalid (_validated_covariance()) contributes nothing to the utility and adds a warning naming it (by labels when supplied, else by index); the suggestion still succeeds if at least one series is usable. All-unusable inputs degrade to an empty suggestion. Never raises.

asymmetry.core.fitting.experiment_design.calibrate_suggestion(model: ParameterCompositeModel, parameters: ParameterSet, x_data: ndarray[tuple[int, ...], dtype[float64]], y_data: ndarray[tuple[int, ...], dtype[float64]], y_err: ndarray[tuple[int, ...], dtype[float64]], x_new: float, *, target: str, events_factor: float = 1.0, n_trials: int = 50, seed: int = 0, predicted_post_sigma: float | None = None) SuggestionCalibration[source]

Monte-Carlo-calibrate a suggestion by simulating the point and refitting.

Each trial draws the hypothetical new datum from the fitted model plus Gaussian noise at the empirical sigma for x_new (scaled by 1/sqrt(events_factor)), appends it to the measured series, refits from the fitted values, and records the post-fit sigma of target. The median over trials is the calibrated post-measurement sigma — the honest counterpart of the rank-one predicted_post_sigma, which is known to be optimistic near critical points (see docs/studies/prototype-results.md).

y_err must be the effective per-point sigmas the trend fit was weighted with (already resolved through the fit’s error mode).

asymmetry.core.fitting.experiment_design.calibrate_events_for_goal(model: ParameterCompositeModel, parameters: ParameterSet, x_data: ndarray[tuple[int, ...], dtype[float64]], y_data: ndarray[tuple[int, ...], dtype[float64]], y_err: ndarray[tuple[int, ...], dtype[float64]], x_new: float, *, target: str, sigma_goal: float, initial_events_factor: float = 1.0, n_trials: int = 30, seed: int = 0, max_events_factor: float = 1024.0, max_iterations: int = 6) tuple[float | None, SuggestionCalibration][source]

Find the event-count factor whose calibrated post-sigma meets the goal.

Multiplicative search starting from initial_events_factor (use the rank-one events_factor_to_target when available): each step scales the factor by the shortfall (median/goal)^2 — exact if the new point’s variance dominates, conservative otherwise. Returns (factor, last_calibration); factor is None when the goal is not met by max_events_factor (the single-point floor applies).

asymmetry.core.fitting.experiment_design.suggest_discriminating_point(lead_model: ParameterCompositeModel, lead_parameters: ParameterSet, alternatives: Sequence[tuple[ParameterCompositeModel, ParameterSet]], x_data: ndarray[tuple[int, ...], dtype[float64]], y_err: ndarray[tuple[int, ...], dtype[float64]], x_min: float, x_max: float, *, n_candidates: int = 257) NextPointSuggestion[source]

Rank candidate x values by how well they discriminate between models.

Scores the lead_model (the currently favoured fit) against every entry in alternatives (each a fitted (model, parameters) pair over the same series), taking the elementwise max over alternatives — per the TAS-AI lock-in lesson (§4/§8.1 of the study), a leader must be tested against all live competitors, not just the runner-up:

U_disc(x) = max_i [f_lead(x) - f_i(x)]^2 / (2 sigma_new^2(x))

Returns a NextPointSuggestion with target=None (this mode has no single target parameter) and no event-count/goal fields set. Never raises: degenerate inputs (no alternatives, no noise model, bad range) degrade to an empty suggestion with an explanatory warning, and a utility that is everywhere within noise of zero is flagged rather than silently returning a meaningless argmax.

asymmetry.core.fitting.experiment_design.set_matching_divergence(hypothesis_a: Sequence[tuple[ParameterCompositeModel, ParameterSet]], hypothesis_b: Sequence[tuple[ParameterCompositeModel, ParameterSet]], sigma: Sequence[ndarray[tuple[int, ...], dtype[float64]]], x_candidates: ndarray[tuple[int, ...], dtype[float64]]) ndarray[tuple[int, ...], dtype[float64]][source]

Per-candidate min-cost set-matching divergence between two hypotheses (§3.3).

Each hypothesis is an equal-length list of (model, parameters) curves. At every candidate x the two hypotheses’ predicted N-vectors are matched one-to-one (Hungarian) to minimise the total weighted cost, with per-pair cost

[f_i^A(x) − f_j^B(x)]² / (2 σ_i²(x))

(sigma[i] is series i of hypothesis A’s per-candidate noise). Because a new run’s components are unlabelled, two assignment hypotheses are only distinguishable through the sets of values they predict — this is that set-level discrepancy. It is zero where the hypotheses predict the same value set (e.g. at a crossing where the labellings coincide) and peaks where they imply genuinely different curve sets. For N = 1 it reduces exactly to the labelled discrimination integrand of suggest_discriminating_point().

Returns U(x) over x_candidates. Non-finite predictions or zero sigmas are treated as a large finite cost so the matching stays well-posed.

asymmetry.core.fitting.experiment_design.aic_weights(chi_squareds: Sequence[float], n_free_params: Sequence[int]) list[float][source]

Akaike-weight a set of candidate models from their fit chi-squareds.

AIC_i = chi2_i + 2 p_i; weights are exp(-(AIC_i - min AIC)/2) normalised to sum to 1 (the minimum is subtracted before exponentiating for numerical safety, per Burnham & Anderson). BIC is deliberately not used here — its p * ln(n) penalty drifts under the sequential-n growth of an in-progress trend series (§4 of the study).

A model with non-finite chi-squared gets weight 0.0 and is excluded from the normalisation; if every model is non-finite, returns a list of 0.0 (there is nothing to rank). Raises ValueError if the two input lists differ in length — a mismatch here is a programming error, not a user-data problem.

asymmetry.core.fitting.experiment_design.cost_weighted_utility(x_candidates: ndarray[tuple[int, ...], dtype[float64]], utility: ndarray[tuple[int, ...], dtype[float64]], x_current: float, *, count_time: float, up_rate: float, down_rate: float, gamma: float = 0.7) ndarray[tuple[int, ...], dtype[float64]][source]

Weight a utility curve by movement + counting cost (TAS-AI’s IG^gamma/t).

move_time(x) = up_rate * (x - x_current) for x > x_current, else down_rate * (x_current - x); all rates/times share a unit (hours). Returns clip(utility, 0)**gamma / (count_time + move_time).

Pure array-in/array-out with defensive guards rather than raising: an invalid cost model (count_time <= 0, a negative rate, or a non-finite x_current) must not silently distort the ranking, so the original utility is returned unchanged in that case. The GUI is responsible for validating these inputs before calling; this function does not emit warnings.

Knight shift

Convert fitted oscillation components to the muon Knight shift and fit its angular dependence. See Knight shift for the physics and the GUI workflow.

Muon Knight-shift conversion from fitted precession frequencies.

The Knight shift is the relative frequency shift of the muon precession away from the free-muon Larmor frequency of the applied field:

K = (ν_µ − ν_ref) / ν_ref (dimensionless)

with two reference conventions (both supported here):

  • Applied fieldν_ref = γ_µ · B_ext, the free-muon Larmor frequency of the applied field. This needs no measured reference line, so it is the default for low-background systems. (Amato, Intro to MuSR, Eq. 5.54; Blundell, Muon Spectroscopy, Eq. 16.14: K = (B_µ B_ext)·B_ext / B_ext².)

  • Designated componentν_ref is the fitted frequency of a reference oscillation component measured in the same fit (e.g. a diamagnetic line). The two frequencies are then correlated, so the covariance is carried through the error propagation.

This module computes the directly-measured K_exp. The Lorentz/demagnetizing correction to the intrinsic K_µ (Amato Eqs. 5.59–5.60) needs sample geometry and susceptibility and is intentionally out of scope.

Knight shifts span a wide range — tens of ppm for diamagnets up to a few percent for paramagnets (Amato p.194) — so resolve_auto_unit() picks a sensible display unit from the data.

asymmetry.core.fitting.knight_shift.MUON_LARMOR_MHZ_PER_G = 0.013553881700000001

Free-muon Larmor frequency per unit applied field, γ_µ/(2π) in MHz/G (≈ 0.013554 MHz/G). Derived from the single γ_µ·B conversion in asymmetry.core.fitting.spectral so the Larmor slope cannot drift from the frequency-domain field axis.

asymmetry.core.fitting.knight_shift.larmor_frequency_mhz(field_gauss: float) float[source]

Free-muon Larmor frequency (MHz) for an applied field in Gauss.

asymmetry.core.fitting.knight_shift.knight_shift(nu: float, nu_ref: float, *, sigma_nu: float = 0.0, sigma_ref: float = 0.0, cov: float = 0.0) tuple[float, float][source]

Knight shift K = ν/ν_ref 1 and its 1σ uncertainty.

cov is the covariance between nu and nu_ref — non-zero only when both are fitted in the same fit (the designated-component reference). For the applied-field reference nu_ref = γ_µ·B is treated as exact, so the caller leaves sigma_ref and cov at zero.

Returns (nan, nan) when nu_ref is zero (no reference to shift against). The error propagation is the standard first-order expansion of the ratio:

σ_K² = σ_ν²/ν_ref² + ν²·σ_ref²/ν_ref⁴ − 2·ν·cov/ν_ref³

class asymmetry.core.fitting.knight_shift.KnightShiftUnit(*values)[source]

Bases: Enum

Display unit for a Knight shift (stored internally as a fraction).

FRACTION = 'fraction'
PPM = 'ppm'
PERCENT = 'percent'
AUTO = 'auto'
asymmetry.core.fitting.knight_shift.resolve_auto_unit(values: Iterable[float]) KnightShiftUnit[source]

Pick ppm vs percent for a set of Knight shifts (fractions).

Returns KnightShiftUnit.PPM when the largest finite \(|K|\) is below _AUTO_PPM_MAX, else KnightShiftUnit.PERCENT. An empty or all-non-finite set defaults to ppm.

asymmetry.core.fitting.knight_shift.concrete_unit(unit: KnightShiftUnit, values: Iterable[float]) KnightShiftUnit[source]

Resolve AUTO against the data; pass concrete units through unchanged.

asymmetry.core.fitting.knight_shift.scale_for_unit(unit: KnightShiftUnit) float[source]

Factor to multiply an internal fraction by for display in unit.

asymmetry.core.fitting.knight_shift.label_for_unit(unit: KnightShiftUnit) str[source]

Short unit suffix for an axis/legend label (empty for a bare fraction).

asymmetry.core.fitting.knight_shift.REFERENCE_APPLIED_FIELD = 'applied_field'

Reference conventions for the Knight shift.

class asymmetry.core.fitting.knight_shift.KnightShiftConfig(enabled: bool = False, reference_mode: str = 'applied_field', reference_component: str | None = None, unit: KnightShiftUnit = KnightShiftUnit.AUTO, components: tuple[str, ...]=<factory>)[source]

Bases: object

User configuration for converting fitted frequencies to Knight shifts.

reference_mode is "applied_field" (ν_ref = γ_µ·B, needs no reference line) or "component" (ν_ref is reference_component’s fitted frequency). components lists the oscillation-frequency parameter names to convert; an empty tuple means “all discovered components”. unit is the display unit (the conversion is stored internally as a dimensionless fraction).

enabled: bool = False
reference_mode: str = 'applied_field'
reference_component: str | None = None
unit: KnightShiftUnit = 'auto'
components: tuple[str, ...]
to_dict() dict[source]
classmethod from_dict(data: object) KnightShiftConfig[source]
__init__(enabled: bool = False, reference_mode: str = 'applied_field', reference_component: str | None = None, unit: KnightShiftUnit = KnightShiftUnit.AUTO, components: tuple[str, ...]=<factory>) None
class asymmetry.core.fitting.knight_shift.ClogstonJaccarinoResult(slope: float, intercept: float, slope_err: float, intercept_err: float, n: int)[source]

Bases: object

Linear K-vs-χ fit (Clogston–Jaccarino), with temperature implicit.

slope is dK/dχ — proportional to the muon hyperfine coupling at the site — and intercept is the χ-independent (orbital/diamagnetic) shift K0. *_err are 1σ uncertainties; n is the number of points used.

slope: float
intercept: float
slope_err: float
intercept_err: float
n: int
__init__(slope: float, intercept: float, slope_err: float, intercept_err: float, n: int) None
asymmetry.core.fitting.knight_shift.clogston_jaccarino_fit(chi: Sequence[float], knight: Sequence[float], sigma_knight: Sequence[float] | None = None) ClogstonJaccarinoResult[source]

Weighted linear fit of Knight shift vs susceptibility (API-only).

Fits K = slope·χ + intercept with temperature as the implicit parameter — the Clogston–Jaccarino construction — where the slope dK/dχ measures the muon hyperfine coupling and the intercept the χ-independent shift. Points with a non-finite χ, K, or σ are dropped; sigma_knight (if given) weights the fit by 1/σ² (else unit weights). Raises ValueError with fewer than two points or no spread in χ (the slope is then undetermined).

There is no GUI entry point because the muon experiment does not measure χ; pair the trended Knight shift (exported per Knight shift) with an independent susceptibility and call this directly.

Joint K(θ) fit with per-angle component assignment.

Fitting each raw-labelled Knight-shift component trace independently against angle breaks at crossings, where the underlying grouped fit swaps which physical site a label refers to. This module fits N angle-dependent curves jointly and, at each angle, assigns that angle’s N measured component values to the curves they best match (a one-to-one optimal assignment). Each physical site is then followed continuously through crossings, and the assignment realigns the component identity.

The procedure is a classification-EM / alternating least squares: hold the assignment, fit each curve to its assigned points; then hold the curves, reassign each angle’s points to curves by minimum weighted residual (Hungarian). Because the objective is non-convex (label switching), a few seeds are tried and the lowest-χ² solution kept.

Pure/deterministic — no Qt. Reuses the K(θ) basis models and fit_parameter_model.

asymmetry.core.fitting.angular_assignment.ANGULAR_MODELS: tuple[str, ...] = ('KnightAnisotropy', 'AngularCos2', 'AngularFourier2')

K(θ) basis models eligible for the joint fit (angle-scoped, Phase 5/6).

class asymmetry.core.fitting.angular_assignment.AngularAssignmentAlternative(assignment: list[tuple[int, ...]], curves: list[ParameterModelFitResult], total_chi_squared: float, converged: bool)[source]

Bases: object

A near-degenerate runner-up assignment kept for discrimination (§3.3).

Carries the competing per-point assignment (distinct from the winner’s), its converged per-curve curves (canonicalised like the winner), the seed’s total_chi_squared and whether that seed converged. These are the labellings within a Δχ² window of the winner — the ones a new angle could resolve.

assignment: list[tuple[int, ...]]
curves: list[ParameterModelFitResult]
total_chi_squared: float
converged: bool
__init__(assignment: list[tuple[int, ...]], curves: list[ParameterModelFitResult], total_chi_squared: float, converged: bool) None
class asymmetry.core.fitting.angular_assignment.AngularAssignmentResult(success: bool, converged: bool, model_name: str, angles: list[float], curves: list[~asymmetry.core.fitting.parameter_models.ParameterModelFitResult], assignment: list[tuple[int, ...]], curve_values: list[list[float]], curve_errors: list[list[float]], total_chi_squared: float = 0.0, dof: int = 0, message: str = '', alternatives: list[~asymmetry.core.fitting.angular_assignment.AngularAssignmentAlternative] = <factory>)[source]

Bases: object

Outcome of a joint K(θ) fit with per-angle component assignment.

curves[m] is the fit of curve m; curve_values[m]/curve_errors[m] are the realigned per-point values assigned to curve m (so curve m is a continuous trace through crossings). assignment[p][c] gives the curve index that component c at scan point p was assigned to. alternatives holds the distinct near-degenerate runner-up labellings (empty unless keep_alternatives > 0 was requested).

success: bool
converged: bool
model_name: str
angles: list[float]
curves: list[ParameterModelFitResult]
assignment: list[tuple[int, ...]]
curve_values: list[list[float]]
curve_errors: list[list[float]]
total_chi_squared: float = 0.0
dof: int = 0
message: str = ''
alternatives: list[AngularAssignmentAlternative]
__init__(success: bool, converged: bool, model_name: str, angles: list[float], curves: list[~asymmetry.core.fitting.parameter_models.ParameterModelFitResult], assignment: list[tuple[int, ...]], curve_values: list[list[float]], curve_errors: list[list[float]], total_chi_squared: float = 0.0, dof: int = 0, message: str = '', alternatives: list[~asymmetry.core.fitting.angular_assignment.AngularAssignmentAlternative] = <factory>) None
asymmetry.core.fitting.angular_assignment.fit_assigned_angular_curves(angles: Sequence[float], component_values: Sequence[Sequence[float]], component_errors: Sequence[Sequence[float]] | None = None, *, model_name: str = 'KnightAnisotropy', max_iter: int = 25, keep_alternatives: int = 0, alternative_delta_chi2: float = 9.0) AngularAssignmentResult[source]

Jointly fit N K(θ) curves, assigning each angle’s components one-to-one.

component_values is [scan_point][component]; N curves are fitted (one per component). Returns the per-curve fits, the per-point assignment, and the realigned per-curve traces. The fit is seeded from both the identity (raw-label) and value-continuity assignments and the lowest-χ² result kept.

When keep_alternatives > 0, each seed’s converged outcome is collected and the runners-up whose assignment differs from the winner (and each other) and whose total_chi_squared lies within alternative_delta_chi2 of the winner are exposed, χ²-ordered and capped at keep_alternatives, on AngularAssignmentResult.alternatives (canonicalised like the winner). The default (keep_alternatives = 0) leaves behaviour unchanged.

Knight-shift analysis session: a GUI-free pipeline from fitted frequencies to K.

This module is the core model behind the Knight shift analysis window. It owns the derivation pipeline that previously lived inline in the trend panel:

fitted components (ν or B_µ per run) → K per component per run

→ crossing detection along the scan

The three pieces are deliberately separate:

  • KnightAnalysisInput — an immutable snapshot of the measured quantities: one KnightPoint per source run (abscissa, applied field, fitted component values/errors/covariance). The GUI builds it from the live trend rows; scripts can build it directly.

  • KnightShiftConfig — the user’s conversion choices (reference, unit, component subset), reused unchanged.

  • evaluate() — the pure derivation producing a KnightAnalysisResult of per-component KnightBranch traces plus detected crossings. Nothing upstream is mutated.

Only the configuration and the source binding (which series, which axis) are persisted — see KnightAnalysisState. The point snapshot is rebuilt from the source series on load, so saved projects cannot carry a stale copy of the fitted values.

asymmetry.core.fitting.knight_analysis.KNIGHT_COMPONENT_KINDS = ('frequency', 'field')

Oscillation components convertible to a Knight shift, by base parameter name. frequency components are in MHz (referenced to γ_µ·B); field components are the fitted local field in Gauss (referenced to B itself).

class asymmetry.core.fitting.knight_analysis.KnightPoint(run_number: int, run_label: str, x: float, field_gauss: float, values: Mapping[str, float], errors: Mapping[str, float], covariance: Mapping[str, Mapping[str, float]] | None = None, include: bool = True)[source]

Bases: object

One source run’s contribution to the analysis snapshot.

x is the scan abscissa (already resolved by the caller — for a rotation scan the unfolded angle in degrees; NaN drops the point). values / errors carry the fitted component parameters; covariance is the (optional) nested parameter-covariance map used by the designated-component reference. include mirrors the trend include-gate so excluded runs stay visible-but-skipped rather than silently vanishing.

run_number: int
run_label: str
x: float
field_gauss: float
values: Mapping[str, float]
errors: Mapping[str, float]
covariance: Mapping[str, Mapping[str, float]] | None = None
include: bool = True
__init__(run_number: int, run_label: str, x: float, field_gauss: float, values: Mapping[str, float], errors: Mapping[str, float], covariance: Mapping[str, Mapping[str, float]] | None = None, include: bool = True) None
class asymmetry.core.fitting.knight_analysis.KnightAnalysisInput(x_key: str, x_label: str, components: tuple[tuple[str, str], ...], points: tuple[KnightPoint, ...], source_label: str = '', batch_id: str | None = None, group_id: str | None = None)[source]

Bases: object

Immutable snapshot of everything evaluate() reads.

components lists the convertible components as (name, kind) in model order (kind per KNIGHT_COMPONENT_KINDS); it is the source series’ model-derived observable map when available, so muonium components whose field is the applied field are already excluded. x_key/x_label identify the scan axis for display and persistence; source_label names the originating series/batch for the window header.

x_key: str
x_label: str
components: tuple[tuple[str, str], ...]
points: tuple[KnightPoint, ...]
source_label: str = ''
batch_id: str | None = None
group_id: str | None = None
component_names() tuple[str, ...][source]
__init__(x_key: str, x_label: str, components: tuple[tuple[str, str], ...], points: tuple[KnightPoint, ...], source_label: str = '', batch_id: str | None = None, group_id: str | None = None) None
class asymmetry.core.fitting.knight_analysis.KnightBranch(name: str, component: str, kind: str, subscript: str, x: tuple[float, ...], k: tuple[float, ...], k_err: tuple[float, ...], run_numbers: tuple[int, ...], included: tuple[bool, ...])[source]

Bases: object

One component’s Knight-shift trace along the scan.

Values are dimensionless fractions; the result-level unit applies a display scale. Entries align with run_numbers (points with a NaN abscissa or a missing component are dropped and counted at the result level; excluded points are kept, flagged by included, so the GUI can grey them out).

name: str
component: str
kind: str
subscript: str
x: tuple[float, ...]
k: tuple[float, ...]
k_err: tuple[float, ...]
run_numbers: tuple[int, ...]
included: tuple[bool, ...]
__init__(name: str, component: str, kind: str, subscript: str, x: tuple[float, ...], k: tuple[float, ...], k_err: tuple[float, ...], run_numbers: tuple[int, ...], included: tuple[bool, ...]) None
class asymmetry.core.fitting.knight_analysis.KnightAnalysisResult(unit: KnightShiftUnit, unit_label: str, scale: float, branches: tuple[KnightBranch, ...], crossings: tuple[CrossingEvent, ...], skipped_points: int = 0)[source]

Bases: object

Output of evaluate(): branches, crossings, and the resolved unit.

unit: KnightShiftUnit
unit_label: str
scale: float
branches: tuple[KnightBranch, ...]
crossings: tuple[CrossingEvent, ...]
skipped_points: int = 0

non-finite abscissa or missing component.

Type:

Points dropped from every branch

branch(name: str) KnightBranch | None[source]
__init__(unit: KnightShiftUnit, unit_label: str, scale: float, branches: tuple[KnightBranch, ...], crossings: tuple[CrossingEvent, ...], skipped_points: int = 0) None
asymmetry.core.fitting.knight_analysis.SAMPLE_SHAPE_DEMAG_FACTORS: dict[str, float | None] = {'custom': None, 'cylinder_axial': 0.0, 'cylinder_transverse': 0.5, 'plate_parallel': 0.0, 'plate_perpendicular': 1.0, 'sphere': 0.3333333333333333}

Demagnetization factor N along the applied field for standard sample shapes (SI convention, ΣN = 1). custom defers to the user-entered value.

class asymmetry.core.fitting.knight_analysis.KnightCorrection(enabled: bool = False, shape: str = 'sphere', custom_n: float = 0.3333333333333333, chi_volume_si: float = 0.0)[source]

Bases: object

Lorentz/demagnetizing-field correction to the measured shift.

Applies Amato & Morenzoni Eq. 5.60: K_µ = K_exp (1/3 N)·χ, with N the demagnetization factor along the applied field (SI convention, sphere = 1/3 — for which Lorentz and demagnetizing fields cancel and the correction vanishes) and chi the volume susceptibility in SI dimensionless units (multiply a CGS emu/cm³ value by 4π). The correction is a constant offset per branch, exact for an ellipsoidal sample whose shape is fixed relative to the field; for a rotating non-spheroidal sample N itself varies with angle, which this scalar form does not capture.

enabled: bool = False
shape: str = 'sphere'
custom_n: float = 0.3333333333333333
chi_volume_si: float = 0.0
demag_factor() float[source]
offset() float[source]

The additive correction to a dimensionless K fraction (Eq. 5.60).

to_dict() dict[source]
classmethod from_dict(data: object) KnightCorrection[source]
__init__(enabled: bool = False, shape: str = 'sphere', custom_n: float = 0.3333333333333333, chi_volume_si: float = 0.0) None
asymmetry.core.fitting.knight_analysis.branch_name(component: str) str[source]

Trace name for a component’s Knight shift (K[<component>]).

asymmetry.core.fitting.knight_analysis.selected_components(analysis_input: KnightAnalysisInput, config: KnightShiftConfig) tuple[tuple[str, str], ...][source]

The (name, kind) components the config actually converts.

Applies the config’s component subset (empty = all) and, for the designated-component reference, restricts to components of the same kind as the reference — dividing a frequency (MHz) by a field (Gauss) is meaningless — excluding the reference itself. Returns an empty tuple when the designated reference is not among the snapshot’s components (emit nothing rather than guess).

asymmetry.core.fitting.knight_analysis.evaluate(analysis_input: KnightAnalysisInput, config: KnightShiftConfig, correction: KnightCorrection | None = None) KnightAnalysisResult[source]

Derive the Knight-shift branches and crossings for a snapshot + config.

Pure: the snapshot is not mutated, and calling twice with the same inputs gives the same result. A disabled config yields an empty result (no branches, no crossings) with the unit still resolved so axis labels stay stable while the user toggles the conversion. correction (optional) applies the Lorentz/demagnetizing offset of Eq. 5.60 to every shift; the offset is common to all branches, so crossings and branch ordering are unaffected. K uncertainties do not include a χ uncertainty.

class asymmetry.core.fitting.knight_analysis.KnightJointCurve(branch_name: str, parameters: tuple[tuple[str, float, float], ...], chi_squared: float, reduced_chi_squared: float, n_points: int, success: bool, covariance: tuple[tuple[str, ...], tuple[tuple[float, ...], ...]] | None = None)[source]

Bases: object

One fitted physical curve of a joint K(θ) fit.

branch_name is the K[...] trace this curve occupies after realignment. parameters are (name, value, error) triples in the fit unit (see KnightJointFitState.unit). covariance mirrors ParameterModelFitResult.covariance(names, matrix) for the curve’s free parameters, in the same order as names. It is already in the display unit: run_joint_fit() scales the values into the display unit before fitting, so the Minuit covariance it reads back is already unit² in that display unit — no extra scaling is applied here. None when the underlying fit produced no covariance (HESSE failed/didn’t run) or the curve predates this field (legacy project).

branch_name: str
parameters: tuple[tuple[str, float, float], ...]
chi_squared: float
reduced_chi_squared: float
n_points: int
success: bool
covariance: tuple[tuple[str, ...], tuple[tuple[float, ...], ...]] | None = None
to_dict() dict[source]
classmethod from_dict(data: object) KnightJointCurve | None[source]
__init__(branch_name: str, parameters: tuple[tuple[str, float, float], ...], chi_squared: float, reduced_chi_squared: float, n_points: int, success: bool, covariance: tuple[tuple[str, ...], tuple[tuple[float, ...], ...]] | None = None) None
class asymmetry.core.fitting.knight_analysis.KnightJointFitState(model_name: str = 'KnightAnisotropy', max_iter: int = 25, unit: str = 'percent', correction_offset: float = 0.0, converged: bool = False, total_chi_squared: float = 0.0, dof: int = 0, message: str = '', assignment: dict[int, tuple[int, ...]]=<factory>, curves: tuple[~asymmetry.core.fitting.knight_analysis.KnightJointCurve, ...]=())[source]

Bases: object

Persisted joint K(θ) fit: model, per-run assignment, per-curve parameters.

assignment[run_number][component] is the curve index that component was assigned to at that run — the durable representation (run numbers survive refits and reordering; scan-point indices would not). unit records the concrete display unit the fit ran in: the assignment is unit-independent, but the curve parameters are not, so a unit change marks the curves stale until the fit is re-run.

model_name: str = 'KnightAnisotropy'
max_iter: int = 25
unit: str = 'percent'
correction_offset: float = 0.0

Lorentz/demag offset (fraction units) active when the fit ran — like unit, a bookkeeping value: a changed correction shifts every K, so the fitted curves go stale (the assignment does not — a common offset cannot reorder branches).

converged: bool = False
total_chi_squared: float = 0.0
dof: int = 0
message: str = ''
assignment: dict[int, tuple[int, ...]]
curves: tuple[KnightJointCurve, ...] = ()
to_dict() dict[source]
classmethod from_dict(data: object) KnightJointFitState | None[source]
__init__(model_name: str = 'KnightAnisotropy', max_iter: int = 25, unit: str = 'percent', correction_offset: float = 0.0, converged: bool = False, total_chi_squared: float = 0.0, dof: int = 0, message: str = '', assignment: dict[int, tuple[int, ...]]=<factory>, curves: tuple[~asymmetry.core.fitting.knight_analysis.KnightJointCurve, ...]=()) None
asymmetry.core.fitting.knight_analysis.run_joint_fit_outcome(result: KnightAnalysisResult, *, model_name: str = 'KnightAnisotropy', max_iter: int = 25, correction_offset: float = 0.0, keep_alternatives: int = 0) tuple[KnightJointFitState, AngularAssignmentResult][source]

Run the joint K(θ) fit and return both the persisted state and the outcome.

The persisted KnightJointFitState is exactly what run_joint_fit() returns; the second element is the raw AngularAssignmentResult from the classification-EM fit. The outcome carries the near-degenerate runner-up labellings on AngularAssignmentResult.alternatives when keep_alternatives > 0 — the input the assignment-discrimination bridge (suggest_assignment_discriminating_angle()) needs. It is deliberately not folded into the state: the state is persisted, but the alternatives are in-memory only (per the study), so a project load never carries them.

keep_alternatives is forwarded to fit_assigned_angular_curves(). Raises ValueError with fewer than two branches or two shared points.

asymmetry.core.fitting.knight_analysis.run_joint_fit(result: KnightAnalysisResult, *, model_name: str = 'KnightAnisotropy', max_iter: int = 25, correction_offset: float = 0.0) KnightJointFitState[source]

Jointly fit all branches’ K(θ) with per-angle component assignment.

A thin bridge to asymmetry.core.fitting.angular_assignment. fit_assigned_angular_curves(): builds the aligned matrices in the result’s display unit (so curve parameters read in the plotted unit), runs the classification-EM fit, and re-keys the assignment by run number. Delegates to run_joint_fit_outcome() and returns only the persisted state, so its public behaviour is unchanged (the EM runner-up alternatives are discarded). Raises ValueError with fewer than two branches or two shared points.

asymmetry.core.fitting.knight_analysis.apply_assignment(result: KnightAnalysisResult, joint: KnightJointFitState) KnightAnalysisResult[source]

Realign the branches so each follows its physical curve through crossings.

For every run in the joint fit’s assignment, the component values are permuted onto the branch of the curve they were assigned to (perm[component] = curve). Runs outside the assignment (new points, or points not shared by all branches at fit time) keep their raw labels. The input result is not mutated.

asymmetry.core.fitting.knight_analysis.assignment_swap_positions(result: KnightAnalysisResult, joint: KnightJointFitState) tuple[float, ...][source]

Scan positions (midpoints) where the fitted assignment swaps curves.

These are the crossings the joint fit actually resolved — a firmer signal than the raw proximity flags, so the window marks these when a fit is active. Points not covered by the assignment are skipped.

asymmetry.core.fitting.knight_analysis.suggest_next_angle(result: KnightAnalysisResult, joint: KnightJointFitState, *, x_min: float, x_max: float, target: tuple[str, str] | None = None, sigma_goal: float | None = None, n_candidates: int = 257) NextPointSuggestion[source]

Suggest the next scan angle that best constrains the joint K(θ) fit (§3.1).

Builds one series per fitted curve (model from joint.model_name, parameters/covariance from the curve, empirical noise from its realigned trace) and delegates to suggest_next_point_multi(). target names (branch_name, param_name) for a c-optimal solve (with sigma_goal); None gives the D-optimal information-gain sum. Curves without stored covariance degrade with a warning naming the branch; when every curve lacks covariance the suggestion is empty with a “re-run the joint fit” warning (legacy fits predate stored covariance).

asymmetry.core.fitting.knight_analysis.joint_fit_aic_inputs(joint: KnightJointFitState) tuple[float, int][source]

(total_chi_squared, n_curves·n_params) for AIC-weighting a joint fit.

Feeds aic_weights() so the GUI can rank an aligned (lead) fit against a misalignment (alternative) fit (§3.2). n_params is per-curve free-parameter count (all curves share the model), multiplied by the number of curves.

asymmetry.core.fitting.knight_analysis.suggest_model_discriminating_angle(result: KnightAnalysisResult, joint_lead: KnightJointFitState, joint_alt: KnightJointFitState, *, x_min: float, x_max: float, n_candidates: int = 257) NextPointSuggestion[source]

Suggest the angle best distinguishing an aligned vs misaligned fit (§3.2).

Curve identity is preserved: curves are matched across the two joint states by branch_name (an error if the branch sets differ), so the utility is the labelled disagreement sum

U(θ) = Σ_m [f_m^lead(θ) − f_m^alt(θ)]² / 2σ_m²(θ)

with no matching step. σ_m is the lead fit’s realigned per-curve noise (display unit). When the two fits agree within noise everywhere the standard “agree within noise” warning is carried (as in suggest_discriminating_point()).

asymmetry.core.fitting.knight_analysis.suggest_assignment_discriminating_angle(result: KnightAnalysisResult, outcome: AngularAssignmentResult, *, x_min: float, x_max: float, n_candidates: int = 257) NextPointSuggestion[source]

Suggest the angle best resolving competing per-angle assignments (§3.3).

outcome is an AngularAssignmentResult carrying near-degenerate runner-up labellings (keep_alternatives > 0). The utility is the elementwise max over alternatives of the Hungarian set-matching divergence between the winner’s and each alternative’s predicted value sets (leader-vs-all — the TAS-AI lock-in lesson): it is ~0 where the labellings coincide (e.g. at a crossing) and peaks where they imply genuinely different curve sets. With no alternatives the suggestion is empty (“no near-degenerate assignments to discriminate”).

Per-curve noise comes from the winner’s realigned trace errors (outcome.curve_errors); because the utility is a value²/σ² ratio it is invariant to the common display-unit scale, so result is used only to confirm the curve/branch counts agree.

class asymmetry.core.fitting.knight_analysis.KnightAnalysisState(config: KnightShiftConfig = <factory>, correction: KnightCorrection = <factory>, source_batch_id: str | None = None, source_group_id: str | None = None, x_key: str = 'angle', fold_180: bool = False, show_markers: bool = True, rescale_errors: bool = False, joint: KnightJointFitState | None = None)[source]

Bases: object

Persisted window state: conversion config + source binding + view hints.

Deliberately excludes the point snapshot (rebuilt from the source series on open — a saved copy could go stale against a refit). source_batch_id / source_group_id re-bind the window to its series; x_key pins the scan axis. fold_180 and show_markers are plot-view preferences. joint carries the (optional) joint K(θ) fit; its run-keyed assignment stays valid across snapshot rebuilds.

config: KnightShiftConfig
correction: KnightCorrection
source_batch_id: str | None = None
source_group_id: str | None = None
x_key: str = 'angle'
fold_180: bool = False
show_markers: bool = True
rescale_errors: bool = False
joint: KnightJointFitState | None = None
to_dict() dict[source]
classmethod from_dict(data: object) KnightAnalysisState[source]
__init__(config: KnightShiftConfig = <factory>, correction: KnightCorrection = <factory>, source_batch_id: str | None = None, source_group_id: str | None = None, x_key: str = 'angle', fold_180: bool = False, show_markers: bool = True, rescale_errors: bool = False, joint: KnightJointFitState | None = None) None
asymmetry.core.fitting.knight_analysis.migrate_legacy_state(fit_parameters_state: object) KnightAnalysisState | None[source]

Lift a pre-window trend-panel Knight-shift block into the new state.

Projects saved before the analysis window stored the conversion config under fit_parameters_state["knight_shift"] (with the scan axis in x_axis_key). Returns None when there is nothing to migrate (missing block or conversion never enabled) so callers can skip writing an empty state; the legacy block itself is left untouched for older app versions.

asymmetry.core.fitting.knight_analysis.snapshot_from_rows(rows: Sequence[object], *, x_values: Sequence[float], x_key: str, x_label: str, components: Sequence[tuple[str, str]], source_label: str = '', batch_id: str | None = None, group_id: str | None = None) KnightAnalysisInput[source]

Build a snapshot from trend-row-shaped objects.

rows are duck-typed against the trend panel’s row records (attributes run_number, run_label, field, values, errors, covariance, include_in_trend); x_values supplies the resolved (unfolded) abscissa for each row, letting the caller keep ownership of the axis resolution. Rows and abscissae must align.

Frequency-domain helpers

Frequency-domain fitting helpers.

asymmetry.core.fitting.spectral.append_frequency_field_derived_parameters(parameters: ParameterSet, uncertainties: dict[str, float] | None = None) tuple[ParameterSet, dict[str, float]][source]

Return a copy with derived centre/width field parameters appended.

The canonical fitted quantities remain nu0 and fwhm in MHz. The derived B0 and Bwid values are appended for trend tables and exports.

asymmetry.core.fitting.spectral.default_frequency_model() CompositeModel[source]

Return the default V1 frequency-domain peak model.

asymmetry.core.fitting.spectral.field_gauss_to_frequency_mhz(value_gauss: float) float[source]

Convert a magnetic field or width in Gauss to MHz.

asymmetry.core.fitting.spectral.frequency_mhz_to_field_gauss(value_mhz: float) float[source]

Convert a muon precession frequency or width in MHz to Gauss.

asymmetry.core.fitting.spectral.seed_peak_parameters_from_dataset(dataset, model: CompositeModel, *, guard_bins: int = 3, guard_freq_mhz: float = 2.0) dict[str, float][source]

Return simple peak/background seeds for one displayed Fourier spectrum.

The dominant-magnitude bin of a (Power)^1/2 spectrum is usually the DC/apodisation spike, not the physical precession peak, so the peak search excludes a low-frequency guard band before taking the argmax. The guard width is max(guard_bins * df, guard_freq_mhz), where df is the spectrum’s bin spacing (a proxy for 1/T_obs); it falls back to the unguarded global argmax if the guard would empty the search array.

When the model declares two or more peak components, seeding delegates to _seed_multiple_peaks(), which assigns the strongest detected peaks to each component (see there for the under-detection fallback).

Superconductivity models

The superconductivity API is organised around normalised superfluid density

\[\rho_s(T)=\left[\frac{\lambda(0)}{\lambda(T)}\right]^2,\]

with measured \(\sigma(T)\) provided in additive and quadrature forms.

Kernel and gap helpers

Thermal kernel and angular averaging:

asymmetry.core.fitting.sc.kernel.energy_integral(delta_reduced: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, t_reduced: float, *, n_energy: int = 96) ndarray[tuple[int, ...], dtype[float64]][source]

Evaluate the reduced thermal integral for one temperature.

\[I(\Delta,t)=\int_{\Delta}^{\infty} \frac{\partial f}{\partial E} \frac{E\,dE}{\sqrt{E^2-\Delta^2}}\]

where energies are expressed in units of \(k_B T_c\).

The integral is transformed to x in [0, 1) via E = delta + x/(1-x).

asymmetry.core.fitting.sc.kernel.superfluid_density_2d(t_values: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, tc: float, gap_ratio: float, gap_function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], gap_params: dict[str, float] | None = None, n_phi: int = 128, n_energy: int = 96, reduced_gap_function: Callable[[ndarray[tuple[int, ...], dtype[float64]]], ndarray[tuple[int, ...], dtype[float64]]] | None = None, normalize_g: bool = False, normalization: str = 'rms') ndarray[tuple[int, ...], dtype[float64]][source]

Compute \(\rho_s(T)\) for a quasi-2D gap function g(phi).

Parameters:
  • t_values – Temperature values in K.

  • tc – Critical temperature in K.

  • gap_ratio – Dimensionless gap ratio \(\Delta_0/(k_B T_c)\).

  • gap_function – Callable returning angular form factor g(phi, **gap_params).

  • reduced_gap_function – Optional helper returning the reduced temperature dependence \(\delta(T/T_c)\). Defaults to asymmetry.core.fitting.sc.bcs.delta_bcs().

  • normalize_g – If True, normalize |g| before integration.

  • normalization"rms" or "mean_abs" normalization mode.

asymmetry.core.fitting.sc.kernel.superfluid_density_3d(t_values: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, tc: float, gap_ratio: float, gap_function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], gap_params: dict[str, float] | None = None, n_theta: int = 32, n_phi: int = 64, n_energy: int = 96, reduced_gap_function: Callable[[ndarray[tuple[int, ...], dtype[float64]]], ndarray[tuple[int, ...], dtype[float64]]] | None = None, normalize_g: bool = False, normalization: str = 'rms') ndarray[tuple[int, ...], dtype[float64]][source]

Compute \(\rho_s(T)\) for a 3D gap function g(theta, phi).

Parameters:
  • t_values – Temperature values in K.

  • tc – Critical temperature in K.

  • gap_ratio – Dimensionless gap ratio \(\Delta_0/(k_B T_c)\).

  • gap_function – Callable returning angular form factor g(theta, phi, **gap_params).

  • gap_params – Optional keyword arguments forwarded to gap_function.

  • n_theta – Number of Gauss-Legendre nodes in polar angle.

  • n_phi – Number of Gauss-Legendre nodes in azimuthal angle.

  • n_energy – Number of Gauss-Legendre nodes used in the thermal energy integral.

  • reduced_gap_function – Optional helper returning the reduced temperature dependence \(\delta(T/T_c)\). Defaults to asymmetry.core.fitting.sc.bcs.delta_bcs().

  • normalize_g – If True, normalize \(|g|\) before integration.

  • normalization – Either "rms" or "mean_abs".

Returns:

Normalized superfluid density clipped to [0, 1] with limiting values rho_s(0)=1 and rho_s(T>=Tc)=0 imposed for numerical stability.

Return type:

numpy.ndarray

Gap-amplitude approximations and convention helpers:

asymmetry.core.fitting.sc.bcs.delta_bcs(t_reduced: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float) ndarray[tuple[int, ...], dtype[float64]][source]

Return reduced BCS gap from the Carrington-Manzano interpolation.

This uses the Carrington-Manzano approximation:

delta(T) = tanh(1.82 * [1.018 * (1/t - 1)]^0.51), for 0 < t < 1

with t = T / Tc.

The function is pinned to: - delta = 1 at t <= 0 - delta = 0 at t >= 1

Notes

A frequently cited alternative is a Gross-type form [1],

\[\Delta_0(T)=\Delta_0(0)\tanh\left[\frac{\pi T_c}{\Delta_0(0)} \sqrt{a\left(\frac{T_c}{T}-1\right)}\right],\]

where a is symmetry-dependent. Use delta_generalized() when a symmetry-specific weak-coupling shape factor is available.

asymmetry.core.fitting.sc.bcs.delta_generalized(t_reduced: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, gap_ratio: float, shape_factor: float) ndarray[tuple[int, ...], dtype[float64]][source]

Return reduced gap from the generalized Gross-style expression.

In reduced units with \(t = T/T_c\) and \(\Delta_0(0)/(k_B T_c)=\text{gap_ratio}\) this becomes

\[\delta(t)=\tanh\left[\frac{\pi}{\text{gap_ratio}} \sqrt{a\left(\frac{1}{t}-1\right)}\right],\]

where shape_factor corresponds to the literature parameter \(a\). The function is pinned to 1 for t <= 0 and 0 for t >= 1.

asymmetry.core.fitting.sc.bcs.gap_ratio_from_mev(gap_mev: float, tc: float) float[source]

Convert Delta0 in meV to Delta0/(k_B Tc).

Parameters:
  • gap_mev – Zero-temperature gap magnitude in meV.

  • tc – Critical temperature in K.

asymmetry.core.fitting.sc.bcs.resolve_gap_ratio(*, tc: float, gap_ratio: float | None = None, gap_mev: float | None = None) float[source]

Resolve gap ratio from either explicit ratio or meV input.

If both are provided, gap_mev takes precedence.

Returns:

Dimensionless ratio \(\Delta_0/(k_B T_c)\).

Return type:

float

Angular gap form factors:

asymmetry.core.fitting.sc.gaps.isotropic_s(phi: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float) ndarray[tuple[int, ...], dtype[float64]][source]

Return isotropic s-wave form factor.

\[g(\phi) = 1.\]
asymmetry.core.fitting.sc.gaps.d_wave(phi: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float) ndarray[tuple[int, ...], dtype[float64]][source]

Return 2D d-wave form factor with line nodes.

\[g(\phi) = \cos(2\phi).\]
asymmetry.core.fitting.sc.gaps.anisotropic_s_cos4(phi: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, a_anis: float) ndarray[tuple[int, ...], dtype[float64]][source]

Return fourfold-anisotropic s-wave form factor.

\[g(\phi) = 1 + a\cos(4\phi).\]

For abs(a) < 1 this form is nodeless. For abs(a) >= 1 accidental nodes can occur.

asymmetry.core.fitting.sc.gaps.nonmonotonic_d_wave(phi: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, beta_nm: float) ndarray[tuple[int, ...], dtype[float64]][source]

Return nonmonotonic d-wave form factor.

\[g(\phi) = \beta\cos(2\phi) + (1-\beta)\cos(6\phi).\]
asymmetry.core.fitting.sc.gaps.s_plus_g(theta: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, phi: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float) ndarray[tuple[int, ...], dtype[float64]][source]

Return s+g form factor used in anisotropic singlet models.

\[g(\theta,\phi) = \frac{1 - \sin^4\theta\cos(4\phi)}{2}.\]

This representation follows the weak-coupling form tabulated in Ref. [1] for s+g-wave phenomenology.

Single-gap and anisotropic models

Isotropic and nodal baselines:

asymmetry.core.fitting.sc.models.rho_s_wave(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, Tc: float, gap_ratio: float = 1.764, gap_mev: float | None = None, n_phi: int = 64, n_energy: int = 48) ndarray[tuple[int, ...], dtype[float64]][source]

Return \(\rho_s(T)\) for isotropic s-wave gap, \(g(\phi)=1\).

Parameters:
  • T – Temperature in K.

  • Tc – Critical temperature in K.

  • gap_ratio – Dimensionless ratio \(\Delta_0/(k_B T_c)\).

  • gap_mev – Optional \(\Delta_0\) in meV. Overrides gap_ratio when given.

  • n_phi – Number of angular quadrature points for Fermi-surface averaging.

  • n_energy – Number of Gauss-Legendre nodes for the energy integral.

Returns:

Normalized superfluid density \(\rho_s(T)\).

Return type:

numpy.ndarray

asymmetry.core.fitting.sc.models.sc_s_wave(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio: float = 1.764, sigma_bg: float = 0.0, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Additive isotropic s-wave model for measured \(\sigma(T)\).

\[\sigma(T) = \sigma_0\,\rho_s(T) + \sigma_{bg}\]
asymmetry.core.fitting.sc.models.rho_d_wave(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, Tc: float, gap_ratio: float = 2.14, gap_mev: float | None = None, n_phi: int = 64, n_energy: int = 48) ndarray[tuple[int, ...], dtype[float64]][source]

Return \(\rho_s(T)\) for d-wave \(g(\phi)=\cos(2\phi)\).

This model has line nodes and therefore stronger low-temperature variation than isotropic s-wave, typically close to linear-in-T in clean limits [1]. The reduced gap amplitude uses the generalized weak-coupling form with the d-wave shape factor \(a=4/3\).

asymmetry.core.fitting.sc.models.sc_d_wave(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio: float = 2.14, sigma_bg: float = 0.0, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Additive d-wave model for measured \(\sigma(T)\).

Anisotropic variants:

asymmetry.core.fitting.sc.models.rho_anisotropic_s_cos4(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, Tc: float, gap_ratio: float = 1.764, a_anis: float = 0.2, shape_factor_a: float = 0.0, gap_mev: float | None = None, n_phi: int = 64, n_energy: int = 48) ndarray[tuple[int, ...], dtype[float64]][source]

Return \(\rho_s(T)\) for anisotropic s-wave 1 + a*cos(4phi).

a_anis controls anisotropy. For abs(a_anis) < 1 the gap remains nodeless; accidental nodes may appear when abs(a_anis) >= 1. If shape_factor_a > 0, use the generalized weak-coupling reduced-gap law with that value; otherwise fall back to the Carrington-Manzano interpolation.

asymmetry.core.fitting.sc.models.sc_anisotropic_s_cos4(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio: float = 1.764, a_anis: float = 0.2, shape_factor_a: float = 0.0, sigma_bg: float = 0.0, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Additive anisotropic-s model for measured \(\sigma(T)\).

asymmetry.core.fitting.sc.models.rho_nonmonotonic_d(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, Tc: float, gap_ratio: float = 2.14, beta_nm: float = 0.8, gap_mev: float | None = None, n_phi: int = 64, n_energy: int = 48) ndarray[tuple[int, ...], dtype[float64]][source]

Return \(\rho_s(T)\) for nonmonotonic d-wave.

\[g(\phi)=\beta\cos(2\phi)+(1-\beta)\cos(6\phi).\]

This form is commonly used as a phenomenological extension when a monotonic \(\cos(2\phi)\) d-wave is insufficient [1]. The temperature-dependent gap amplitude uses the same d-wave weak-coupling shape factor \(a=4/3\) as rho_d_wave().

asymmetry.core.fitting.sc.models.sc_nonmonotonic_d(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio: float = 2.14, beta_nm: float = 0.8, sigma_bg: float = 0.0, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Additive nonmonotonic-d model for measured \(\sigma(T)\).

asymmetry.core.fitting.sc.models.rho_s_plus_g(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, Tc: float, gap_ratio: float = 2.77, gap_mev: float | None = None, n_theta: int = 24, n_phi: int = 48, n_energy: int = 48) ndarray[tuple[int, ...], dtype[float64]][source]

Return \(\rho_s(T)\) for s+g-wave anisotropic singlet gap.

\[g(\theta,\phi)=\frac{1-\sin^4\theta\cos(4\phi)}{2}.\]

The default gap_ratio=2.77 follows the weak-coupling tabulation used in Ref. [1]. The reduced gap amplitude uses the generalized weak-coupling form with the s+g shape factor \(a=2\).

Parameters:
  • T – Temperature in K.

  • Tc – Critical temperature in K.

  • gap_ratio – Dimensionless ratio \(\Delta_0/(k_B T_c)\).

  • gap_mev – Optional \(\Delta_0\) in meV. Overrides gap_ratio when given.

  • n_theta – Number of polar-angle quadrature points.

  • n_phi – Number of azimuthal-angle quadrature points.

  • n_energy – Number of Gauss-Legendre nodes for the energy integral.

asymmetry.core.fitting.sc.models.sc_s_plus_g(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio: float = 2.77, sigma_bg: float = 0.0, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Additive s+g model for measured \(\sigma(T)\).

\[\sigma(T) = \sigma_0\,\rho_{s+g}(T) + \sigma_{bg}.\]

Additional unconventional examples:

asymmetry.core.fitting.sc.models.rho_extended_s(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, Tc: float, gap_ratio: float = 2.14, signed: bool = False, gap_mev: float | None = None, n_phi: int = 64, n_energy: int = 48) ndarray[tuple[int, ...], dtype[float64]][source]

Return \(\rho_s(T)\) for extended s-wave based on cos(2phi).

Set signed=True to preserve sign of \(\cos(2\phi)\). The default uses magnitude because the quasiparticle excitation energy depends on \(|\Delta|\). The reduced gap amplitude uses the generalized weak-coupling form with \(a=4/3\), consistent with the \(\cos(2\phi)\) basis tabulated in Ref. [1].

asymmetry.core.fitting.sc.models.sc_extended_s(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio: float = 2.14, signed_gap: float = 0.0, sigma_bg: float = 0.0, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Additive extended-s model for measured \(\sigma(T)\).

asymmetry.core.fitting.sc.models.rho_p_wave_axial(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, Tc: float, gap_ratio: float = 2.0, shape_factor_a: float = 0.0, gap_mev: float | None = None, n_phi: int = 64, n_energy: int = 48) ndarray[tuple[int, ...], dtype[float64]][source]

Return \(\rho_s(T)\) for 2D axial p-wave, \(g(\phi)=\cos\phi\).

If shape_factor_a > 0, use the generalized weak-coupling reduced-gap law with that value; otherwise fall back to the Carrington-Manzano interpolation.

asymmetry.core.fitting.sc.models.sc_p_wave_axial(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio: float = 2.0, shape_factor_a: float = 0.0, sigma_bg: float = 0.0, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Additive 2D axial p-wave model for measured \(\sigma(T)\).

asymmetry.core.fitting.sc.models.rho_p_wave_polar_3d(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, Tc: float, gap_ratio: float = 2.0, shape_factor_a: float = 0.0, gap_mev: float | None = None, n_theta: int = 24, n_phi: int = 48, n_energy: int = 48) ndarray[tuple[int, ...], dtype[float64]][source]

Return \(\rho_s(T)\) for 3D polar p-wave line-node example.

Uses \(g(\theta)=\sin\theta\) with spherical angular averaging. If shape_factor_a > 0, use the generalized weak-coupling reduced-gap law with that value; otherwise fall back to the Carrington-Manzano interpolation.

Note

rho_extended_s uses the generalized weak-coupling reduced-gap law with a = 4/3 by default. For rho_anisotropic_s_cos4, sc_anisotropic_s_cos4, rho_p_wave_axial, sc_p_wave_axial, and rho_p_wave_polar_3d, the optional shape_factor_a parameter can be supplied when a symmetry-specific weak-coupling shape factor is known or is to be fitted. If shape_factor_a is omitted or left at 0, these models fall back to the Carrington-Manzano interpolation.

Two-gap and alpha models

Weighted-sum two-gap models (including MgB2-style s+s decomposition):

asymmetry.core.fitting.sc.models.sc_two_gap_ss(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio_1: float = 1.2, gap_ratio_2: float = 2.2, weight: float = 0.5, sigma_bg: float = 0.0) ndarray[tuple[int, ...], dtype[float64]][source]

Two-gap isotropic model, weighted superfluid density sum.

\[ \begin{align}\begin{aligned} \rho_s(T) = w\rho_1(T) + (1-w)\rho_2(T),\quad 0\le w\le 1.\\This is the standard MgB2-style alpha-model decomposition for multiband superconductors [2].\end{aligned}\end{align} \]
asymmetry.core.fitting.sc.models.sc_two_gap_sd(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, gap_ratio_s: float = 1.764, gap_ratio_d: float = 2.14, weight: float = 0.5, sigma_bg: float = 0.0) ndarray[tuple[int, ...], dtype[float64]][source]

Two-gap mixed-symmetry model (s + d weighted sum).

Alpha-model scaling:

asymmetry.core.fitting.sc.models.sc_alpha_model(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_0: float, Tc: float, alpha_sc: float = 1.0, sigma_bg: float = 0.0) ndarray[tuple[int, ...], dtype[float64]][source]

Single-gap alpha model using isotropic BCS kernel.

alpha_sc rescales the weak-coupling s-wave ratio 1.764.

Quadrature sigma models

These variants are intended for workflows where superconducting and non-superconducting linewidth channels are modelled as independent Gaussian contributions added in quadrature.

asymmetry.core.fitting.sc.models.sc_s_wave_q(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_sc: float, sigma_nm: float, Tc: float, gap_ratio: float = 1.764, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Quadrature s-wave model for measured \(\sigma(T)\).

Use this convention when independent Gaussian broadening channels combine at the second-moment level, motivating \(\sigma^2=(\sigma_{sc}\rho_s)^2+\sigma_{nm}^2\).

asymmetry.core.fitting.sc.models.sc_d_wave_q(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_sc: float, sigma_nm: float, Tc: float, gap_ratio: float = 2.14, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Quadrature d-wave model for measured \(\sigma(T)\).

asymmetry.core.fitting.sc.models.sc_s_plus_g_q(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, sigma_sc: float, sigma_nm: float, Tc: float, gap_ratio: float = 2.77, gap_mev: float | None = None) ndarray[tuple[int, ...], dtype[float64]][source]

Quadrature s+g model for measured \(\sigma(T)\).

This is useful when superconducting and non-superconducting linewidth channels are treated as independent Gaussian contributions that add in quadrature.

lambda and sigma conversion helpers:

asymmetry.core.fitting.sc.constants.sigma_to_lambda_nm(sigma_us: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, brandt_coefficient: float = 0.0609) ndarray[tuple[int, ...], dtype[float64]][source]

Convert superconducting sigma (us^-1) to penetration depth lambda (nm).

For sigma <= 0, the returned lambda is +inf.

asymmetry.core.fitting.sc.constants.lambda_nm_to_sigma_us(lambda_nm: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, brandt_coefficient: float = 0.0609) ndarray[tuple[int, ...], dtype[float64]][source]

Convert penetration depth lambda (nm) to superconducting sigma (us^-1).

asymmetry.core.fitting.sc.models.rho_to_lambda_inv_sq(rho: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, lambda_0_nm: float) ndarray[tuple[int, ...], dtype[float64]][source]

Convert normalized \(\rho_s(T)\) to \(\lambda^{-2}(T)\) in nm^-2.

asymmetry.core.fitting.sc.models.rho_to_lambda(rho: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, lambda_0_nm: float) ndarray[tuple[int, ...], dtype[float64]][source]

Convert normalized \(\rho_s(T)\) to \(\lambda(T)\) in nm.

asymmetry.core.fitting.sc.models.lambda_inv_sq_from_model(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, rho_function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], lambda_0_nm: float, **kwargs: float) ndarray[tuple[int, ...], dtype[float64]][source]

Evaluate \(\lambda^{-2}(T)\) from any rho_function callable.

asymmetry.core.fitting.sc.models.lambda_from_model(T: ndarray[tuple[int, ...], dtype[float64]] | list[float] | float, *, rho_function: Callable[[...], ndarray[tuple[int, ...], dtype[float64]]], lambda_0_nm: float, **kwargs: float) ndarray[tuple[int, ...], dtype[float64]][source]

Evaluate \(\lambda(T)\) from any rho_function callable.

Parameters

class asymmetry.core.fitting.parameters.Parameter(name: str, value: float = 0.0, min: float = -inf, max: float = inf, fixed: bool = False, expr: str | None = None, link_group: int | None = None, tie: AffineTie | None = None)[source]

Bases: object

A single fit parameter.

name: str
value: float = 0.0
min: float = -inf
max: float = inf
fixed: bool = False
expr: str | None = None
tie: AffineTie | None = None
property is_constrained: bool
__init__(name: str, value: float = 0.0, min: float = -inf, max: float = inf, fixed: bool = False, expr: str | None = None, link_group: int | None = None, tie: AffineTie | None = None) None
class asymmetry.core.fitting.parameters.ParameterSet(params: list[Parameter] | None = None)[source]

Bases: object

Ordered collection of Parameter objects.

__init__(params: list[Parameter] | None = None) None[source]
add(param: Parameter) None[source]
property free_parameters: list[Parameter]
property names: list[str]

Return members of each equality link group, keyed by group id.

Singletons (a group with one member) are dropped — linking one parameter to nothing is a no-op.

Return the “main” parameter of a link group.

Mirrors WiMDA: the first member, unless a later member is free (non-fixed) — then the first free member is the main, so the free-fit set always contains the group main.

Map each non-main linked parameter name to its group main’s name.

tie_followers() dict[str, AffineTie][source]

Map each affinely-tied parameter name to its AffineTie.

These followers are derived from other parameters at fit time and so drop out of the free set (via Parameter.is_constrained).

values_array() list[float][source]
update_values(values: dict[str, float]) None[source]

Fit results

class asymmetry.core.fitting.engine.FitResult(success: bool, chi_squared: float = 0.0, reduced_chi_squared: float = 0.0, parameters: ~asymmetry.core.fitting.parameters.ParameterSet = <factory>, uncertainties: dict[str, float] = <factory>, covariance: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]] | None = None, covariance_parameters: list[str] = <factory>, residuals: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]] | None = None, message: str = '', function_calls: int = 0, gradient_calls: int = 0, hessian_calls: int = 0, edm: float | None = None, covariance_accurate: bool = False, dof: int = 0, minos_errors: dict[str, tuple[float, float]] | None = None, warnings: list[str] = <factory>)[source]

Container for the outcome of a fit.

success: bool
chi_squared: float = 0.0
reduced_chi_squared: float = 0.0
parameters: ParameterSet
uncertainties: dict[str, float]
covariance: ndarray[tuple[int, ...], dtype[float64]] | None = None
covariance_parameters: list[str]
residuals: ndarray[tuple[int, ...], dtype[float64]] | None = None
message: str = ''
function_calls: int = 0
gradient_calls: int = 0
hessian_calls: int = 0
edm: float | None = None
covariance_accurate: bool = False
dof: int = 0

Degrees of freedom ν = N_data − N_free for this (sub)fit. Used by the χ² quality verdict; 0 means “unknown” and callers fall back to inference.

minos_errors: dict[str, tuple[float, float]] | None = None

Opt-in MINOS asymmetric 1σ intervals, {param: (lower, upper)} with lower < 0 < upper (iminuit’s signed offsets). None when MINOS was not requested or every scan failed. A display-only overlay — the symmetric HESSE uncertainties are unchanged and remain the value every downstream surface (trends, export, propagation, promote) consumes.

warnings: list[str]

Advisory warning messages emitted while fitting (the percent/fraction scale trap and the fixed-frequency σ-inflation trap — see AsymmetryScaleWarning / FixedFrequencyFieldMismatchWarning). Captured off the Python warnings system so the GUI can surface them in the fit panel; they are also re-emitted, so stderr/logging and any outer catch_warnings still observe them exactly as before.

__init__(success: bool, chi_squared: float = 0.0, reduced_chi_squared: float = 0.0, parameters: ~asymmetry.core.fitting.parameters.ParameterSet = <factory>, uncertainties: dict[str, float] = <factory>, covariance: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]] | None = None, covariance_parameters: list[str] = <factory>, residuals: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]] | None = None, message: str = '', function_calls: int = 0, gradient_calls: int = 0, hessian_calls: int = 0, edm: float | None = None, covariance_accurate: bool = False, dof: int = 0, minos_errors: dict[str, tuple[float, float]] | None = None, warnings: list[str] = <factory>) None

Fit wizards

Single-spectrum fit-wizard helpers:

class asymmetry.core.fitting.CandidateTemplate(key: str, title: str, category: str, rationale: str, model: CompositeModel, is_current_model_baseline: bool = False)[source]

One curated candidate model considered by the fit wizard.

key: str
title: str
category: str
rationale: str
model: CompositeModel
is_current_model_baseline: bool = False
property parameter_count: int
property additive_terms: int
__init__(key: str, title: str, category: str, rationale: str, model: CompositeModel, is_current_model_baseline: bool = False) None
class asymmetry.core.fitting.CandidateAssessment(template: CandidateTemplate, fit_result: FitResult, aic: float, aicc: float | None, bic: float, selected_score: float, residual_rms: float, runs_z_score: float, max_abs_autocorrelation: float, residual_fft_peak_snr: float, residual_gate_passed: bool, residual_gate_reasons: tuple[str, ...], bound_hits: tuple[str, ...], fitted_time: ndarray[tuple[int, ...], dtype[float64]], fitted_curve: ndarray[tuple[int, ...], dtype[float64]], component_curves: tuple[tuple[str, ndarray[tuple[int, ...], dtype[float64]]], ...], repair_attempted: bool = False, stage: int = 2, disqualification_reasons: tuple[str, ...] = (), is_null_baseline: bool = False)[source]

Fit and comparison data for one candidate model.

template: CandidateTemplate
fit_result: FitResult
aic: float
aicc: float | None
bic: float
selected_score: float
residual_rms: float
runs_z_score: float
max_abs_autocorrelation: float
residual_fft_peak_snr: float
residual_gate_passed: bool
residual_gate_reasons: tuple[str, ...]
bound_hits: tuple[str, ...]
fitted_time: ndarray[tuple[int, ...], dtype[float64]]
fitted_curve: ndarray[tuple[int, ...], dtype[float64]]
component_curves: tuple[tuple[str, ndarray[tuple[int, ...], dtype[float64]]], ...]
repair_attempted: bool = False
stage: int = 2

1 = Stage-1 family representative (cheap first-pass fit), 2 = full assessment.

Type:

Screening stage that produced this assessment

disqualification_reasons: tuple[str, ...] = ()

Targeted-disqualifier reasons that make this candidate unfit to recommend even when it wins by metric (frequency at the resolution floor / pinned at a bound, oscillation amplitude consistent with zero). Empty when the candidate is eligible. Additive — old payloads deserialize to ().

is_null_baseline: bool = False

True for the flat/plain-exponential null baselines fitted unconditionally as a “no significant structure” reference; these never win a normal recommendation and are excluded from the ranked candidate pool.

property is_disqualified: bool
property parameter_count: int
property additive_terms: int
metric_value(metric: SelectionMetric) float[source]
property is_successful: bool
__init__(template: CandidateTemplate, fit_result: FitResult, aic: float, aicc: float | None, bic: float, selected_score: float, residual_rms: float, runs_z_score: float, max_abs_autocorrelation: float, residual_fft_peak_snr: float, residual_gate_passed: bool, residual_gate_reasons: tuple[str, ...], bound_hits: tuple[str, ...], fitted_time: ndarray[tuple[int, ...], dtype[float64]], fitted_curve: ndarray[tuple[int, ...], dtype[float64]], component_curves: tuple[tuple[str, ndarray[tuple[int, ...], dtype[float64]]], ...], repair_attempted: bool = False, stage: int = 2, disqualification_reasons: tuple[str, ...] = (), is_null_baseline: bool = False) None
class asymmetry.core.fitting.FitWizardRecommendation(fingerprint: SpectrumFingerprint, templates: tuple[CandidateTemplate, ...], assessments: tuple[CandidateAssessment, ...], metric: SelectionMetric, recommended_key: str | None, comparable_keys: tuple[str, ...], summary: str, peak_analysis: PeakAnalysis | None = None, multiplet_matches: tuple[MultipletMatch, ...] = (), family_reports: tuple[FamilyScreeningReport, ...] = (), confidence: ConfidenceTier = ConfidenceTier.NONE, verdict: RecommendationVerdict = RecommendationVerdict.NONE, caveat: str = '', scope_note: str = '')[source]

Full fit-wizard analysis payload plus the current recommendation.

fingerprint: SpectrumFingerprint
templates: tuple[CandidateTemplate, ...]
assessments: tuple[CandidateAssessment, ...]
metric: SelectionMetric
recommended_key: str | None
comparable_keys: tuple[str, ...]
summary: str
peak_analysis: PeakAnalysis | None = None
multiplet_matches: tuple[MultipletMatch, ...] = ()
family_reports: tuple[FamilyScreeningReport, ...] = ()
confidence: ConfidenceTier = 'none'

Confidence tier of recommended_key (High = gates passed, Medium = metric winner with structured residuals, None = no recommendation). Additive — old payloads default to NONE.

verdict: RecommendationVerdict = 'none'

Whether the recommendation describes real structure or the data are consistent with a null baseline. Additive — old payloads default to NONE (they predate the null-baseline test).

caveat: str = ''

Human-readable caveat carried when confidence is Medium (the residual gate reasons for the recommended template) or when the null-baseline verdict fires. Empty for a clean High recommendation. GUI-facing.

scope_note: str = ''

Human-readable note on how the candidate scope was resolved (run geometry, near-zero-field widening, a fluorine sample-name prior — see wizard_scope.ScopeResolution.inference_note). Empty when no scope was resolved (scope=None in build_fit_wizard_recommendation()). Additive — old payloads default to "". GUI-facing.

property recommended_assessment: CandidateAssessment | None
assessment_for_key(key: str | None) CandidateAssessment | None[source]
sorted_assessments(metric: SelectionMetric | None = None) list[CandidateAssessment][source]
__init__(fingerprint: SpectrumFingerprint, templates: tuple[CandidateTemplate, ...], assessments: tuple[CandidateAssessment, ...], metric: SelectionMetric, recommended_key: str | None, comparable_keys: tuple[str, ...], summary: str, peak_analysis: PeakAnalysis | None = None, multiplet_matches: tuple[MultipletMatch, ...] = (), family_reports: tuple[FamilyScreeningReport, ...] = (), confidence: ConfidenceTier = ConfidenceTier.NONE, verdict: RecommendationVerdict = RecommendationVerdict.NONE, caveat: str = '', scope_note: str = '') None
asymmetry.core.fitting.build_fit_wizard_recommendation(dataset: MuonDataset, current_model: CompositeModel | None = None, *, metric: SelectionMetric = SelectionMetric.AICC, scope: WizardScope | None = None, user_frequencies_mhz: Sequence[float] | None = None, max_workers: int | None = None, progress_callback: Callable[[str], None] | None = None, cancel_callback: Callable[[], bool] | None = None) FitWizardRecommendation[source]

Analyze one asymmetry spectrum and recommend a fit candidate.

Tiered screening: every in-scope candidate family gets its cheap Stage-1 representative fitted (fingerprint hints only prioritise — they no longer exclude); families that pass the residual gates, score within _STAGE1_PROMOTE_DELTA of the best, or are named by a multiplet pattern match expand to their full Stage-2 portfolios. scope restricts the families physically (None screens the default superset); user_frequencies_mhz adds trusted peak seeds; max_workers=1 gives a deterministic serial path. cancel_callback is polled between and inside fits when running serially or on the thread-pool fallback (engine in-fit abort); when the resolved worker count allows a shared process pool, cancellation instead takes effect only between fits (a cancel_callback cannot cross a process boundary) — either way, cancellation raises FitCancelledError.

asymmetry.core.fitting.rerank_fit_wizard_recommendation(recommendation: FitWizardRecommendation, metric: SelectionMetric) FitWizardRecommendation[source]

Apply the recommendation policy against the (already fitted) assessments.

Policy (gates classify, they no longer veto):

  • Recommend the best-by-metric successful, non-null, non-disqualified candidate. Targeted disqualifiers (frequency at the resolution floor / pinned at a bound, zero-consistent oscillation amplitude) drop a candidate to the next survivor.

  • Confidence tier: High when the winner’s residuals pass every gate, Medium when it is the clear metric winner but leaves structured residuals (its gate reasons ride along as a caveat).

  • Null-baseline verdict: if the winner does not beat the better strictly-simpler null baseline by _NULL_BASELINE_MIN_DELTA_AICC, the verdict becomes NO_SIGNIFICANT_STRUCTURE and the recommendation points at the winning null (fixes pure-noise over-confidence). Tolerates missing nulls (old payloads, the explicit-template path): the test is skipped.

Global fit-wizard helpers:

class asymmetry.core.fitting.GlobalParameterRecommendation(name: str, recommended_role: str, global_score: float, local_score: float, score_delta: float, total_variation: float, roughness: float, rationale: str)[source]

Recommended role for one parameter in a global fit.

name: str
recommended_role: str
global_score: float
local_score: float
score_delta: float
total_variation: float
roughness: float
rationale: str
__init__(name: str, recommended_role: str, global_score: float, local_score: float, score_delta: float, total_variation: float, roughness: float, rationale: str) None
class asymmetry.core.fitting.GlobalCandidateAssessment(template: CandidateTemplate, fit_results_by_run: dict[int, FitResult], global_parameters: ParameterSet, global_param_names: tuple[str, ...], local_param_names: tuple[str, ...], fixed_param_names: tuple[str, ...], parameter_recommendations: tuple[GlobalParameterRecommendation, ...], run_diagnostics: tuple[RunResidualDiagnostic, ...], series_warnings: tuple[str, ...], aic: float, aicc: float | None, bic: float, selected_score: float, fitted_curves_by_run: dict[int, tuple[ndarray[tuple[int, ...], dtype[float64]], ndarray[tuple[int, ...], dtype[float64]]]], component_curves_by_run: dict[int, tuple[tuple[str, ndarray[tuple[int, ...], dtype[float64]]], ...]], prescreen_only: bool = False, assessment_key: str | None = None)[source]

Fit and comparison data for one global-fit candidate.

template: CandidateTemplate
fit_results_by_run: dict[int, FitResult]
global_parameters: ParameterSet
global_param_names: tuple[str, ...]
local_param_names: tuple[str, ...]
fixed_param_names: tuple[str, ...]
parameter_recommendations: tuple[GlobalParameterRecommendation, ...]
run_diagnostics: tuple[RunResidualDiagnostic, ...]
series_warnings: tuple[str, ...]
aic: float
aicc: float | None
bic: float
selected_score: float
fitted_curves_by_run: dict[int, tuple[ndarray[tuple[int, ...], dtype[float64]], ndarray[tuple[int, ...], dtype[float64]]]]
component_curves_by_run: dict[int, tuple[tuple[str, ndarray[tuple[int, ...], dtype[float64]]], ...]]
prescreen_only: bool = False
assessment_key: str | None = None
property selection_key: str
property parameter_count: int
property additive_terms: int
metric_value(metric: SelectionMetric) float[source]
property is_successful: bool
property residual_gate_passed: bool
__init__(template: CandidateTemplate, fit_results_by_run: dict[int, FitResult], global_parameters: ParameterSet, global_param_names: tuple[str, ...], local_param_names: tuple[str, ...], fixed_param_names: tuple[str, ...], parameter_recommendations: tuple[GlobalParameterRecommendation, ...], run_diagnostics: tuple[RunResidualDiagnostic, ...], series_warnings: tuple[str, ...], aic: float, aicc: float | None, bic: float, selected_score: float, fitted_curves_by_run: dict[int, tuple[ndarray[tuple[int, ...], dtype[float64]], ndarray[tuple[int, ...], dtype[float64]]]], component_curves_by_run: dict[int, tuple[tuple[str, ndarray[tuple[int, ...], dtype[float64]]], ...]], prescreen_only: bool = False, assessment_key: str | None = None) None
class asymmetry.core.fitting.GlobalFitWizardRecommendation(series_axis_key: str, series_axis_label: str, mixed_axes_warning: str | None, fingerprints_by_run: dict[int, SpectrumFingerprint], dataset_order: tuple[int, ...], templates: tuple[CandidateTemplate, ...], assessments: tuple[GlobalCandidateAssessment, ...], metric: SelectionMetric, recommended_key: str | None, comparable_keys: tuple[str, ...], summary: str)[source]

Global-fit analysis payload plus the current recommendation.

series_axis_key: str
series_axis_label: str
mixed_axes_warning: str | None
fingerprints_by_run: dict[int, SpectrumFingerprint]
dataset_order: tuple[int, ...]
templates: tuple[CandidateTemplate, ...]
assessments: tuple[GlobalCandidateAssessment, ...]
metric: SelectionMetric
recommended_key: str | None
comparable_keys: tuple[str, ...]
summary: str
property recommended_assessment: GlobalCandidateAssessment | None
assessment_for_key(key: str | None) GlobalCandidateAssessment | None[source]
assessments_for_template_key(template_key: str) tuple[GlobalCandidateAssessment, ...][source]
sorted_assessments(metric: SelectionMetric | None = None) list[GlobalCandidateAssessment][source]
sorted_prescreen_assessments(metric: SelectionMetric | None = None) list[GlobalCandidateAssessment][source]
optimized_assessments() tuple[GlobalCandidateAssessment, ...][source]
sorted_optimized_assessments(metric: SelectionMetric | None = None) list[GlobalCandidateAssessment][source]
optimization_status_for_key(key: str | None) str[source]
__init__(series_axis_key: str, series_axis_label: str, mixed_axes_warning: str | None, fingerprints_by_run: dict[int, SpectrumFingerprint], dataset_order: tuple[int, ...], templates: tuple[CandidateTemplate, ...], assessments: tuple[GlobalCandidateAssessment, ...], metric: SelectionMetric, recommended_key: str | None, comparable_keys: tuple[str, ...], summary: str) None
asymmetry.core.fitting.build_global_fit_wizard_recommendation(datasets: list[MuonDataset], current_model: CompositeModel | None = None, *, current_parameter_types: dict[str, str] | None = None, current_values: dict[str, float] | None = None, parameter_bounds: dict[str, tuple[float, float]] | None = None, single_fit_recommendations_by_run: dict[int, FitWizardRecommendation] | None = None, metric: SelectionMetric = SelectionMetric.AICC, progress_callback: Callable[[str], None] | None = None, instrumentation: dict[str, object] | None = None, selected_template_keys: tuple[str, ...] | None = None, scope: WizardScope | None = None, user_frequencies_mhz: Sequence[float] | None = None, search_engine: str | None = None, effort_tier: EffortTier = EffortTier.EXHAUSTIVE, cancel_callback: Callable[[], bool] | None = None) GlobalFitWizardRecommendation[source]

Analyze one ordered dataset series and recommend a global-fit candidate.

effort_tier is the user-facing effort slider (PR 5): LOW, BALANCED, THOROUGH, EXHAUSTIVE (default EXHAUSTIVE). As of the PR 5 rework, every tier resolves to the exact bounded-wavefront engine (see _EFFORT_TIER_SEARCH_ENGINE): PR 2’s exact bounds already made the exhaustive path near-minimal and 12-way parallel, so the heuristic Low/ Balanced engines were empirically slower with no fit-count headroom, and the user-facing slider now collapses to one honest mode. The enum and payload are retained so a future scope-based quick-look tier can be added without a schema/UI change.

search_engine remains available as a lower-level override for existing callers/tests — when given explicitly it takes precedence over effort_tier for engine selection. "exhaustive" and "thorough" run the exact bounded wavefront (byte-for-byte the current call); "low" and "balanced" run the retained non-exhaustive heuristic engines (techniques E/F/G/H and the I/J/K knobs), reachable only through this seam for future large-P use and regression coverage — never through effort_tier.

asymmetry.core.fitting.rerank_global_fit_wizard_recommendation(recommendation: GlobalFitWizardRecommendation, metric: SelectionMetric) GlobalFitWizardRecommendation[source]

Reuse existing global-fit assessments and recompute the recommendation.