Core data structures

The core data layer carries μSR measurements through the fit and analysis pipeline. MuonDataset is the working representation — a time axis, an asymmetry, and an uncertainty, plus a metadata dictionary and a reference back to the Run it was computed from. The Run carries the raw per-detector Histogram records and the grouping payload (see Detector Grouping and Layout) and is what asymmetry.core.io.load() returns for raw-histogram files. The Logbook collects multiple runs into a searchable, tag-filterable run table (see Logbook workflows), and the instrument-geometry classes below define the detector banks and preset groupings for the supported spectrometers.

Dataset

class asymmetry.core.data.dataset.MuonDataset(time: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]], asymmetry: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]], error: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]], metadata: dict[str, ~typing.Any] = <factory>, run: ~asymmetry.core.data.dataset.Run | None = None)[source]

Bases: object

Processed asymmetry dataset ready for plotting / fitting.

This is the primary object that users interact with after loading and reducing a run.

Scale convention

asymmetry and error are stored on the percent scale (the WiMDA-style convention the loaders and the time-domain fit models share — a 16 % asymmetry is stored as 16.0, not 0.16, and the built-in models default A0 to 25). The low-level asymmetry.core.transform.compute_asymmetry() primitive instead returns the dimensionless fraction \(A \in [-1, 1]\); the loaders multiply by 100 to populate these arrays. When a specific scale is needed, prefer the explicit asymmetry_percent / asymmetry_fraction (and error_percent / error_fraction) accessors so the two can never be silently confused — seeding a fit with fraction-scale amplitudes against this percent-scale data converges to the wrong minimum.

time: ndarray[tuple[int, ...], dtype[float64]]
asymmetry: ndarray[tuple[int, ...], dtype[float64]]
error: ndarray[tuple[int, ...], dtype[float64]]
metadata: dict[str, Any]
run: Run | None = None
property n_points: int
property asymmetry_percent: ndarray[tuple[int, ...], dtype[float64]]

Asymmetry on the percent scale (the stored convention).

property asymmetry_fraction: ndarray[tuple[int, ...], dtype[float64]]

Asymmetry as the dimensionless fraction \(A \in [-1, 1]\) (percent / 100).

property error_percent: ndarray[tuple[int, ...], dtype[float64]]

Asymmetry error on the percent scale (the stored convention).

property error_fraction: ndarray[tuple[int, ...], dtype[float64]]

Asymmetry error on the fractional scale (percent / 100).

property run_number: int
property temperature: float | None

Sample temperature setpoint (metadata["temperature"]), or None.

Unlike Run.temperature (which floors a missing value to 0.0), this returns None when no temperature was recorded so a trend point with genuinely-missing metadata is marked off-axis rather than planted at 0 K.

property field: float | None

Applied field in gauss (metadata["field"]), or None when absent.

property sample_temperature_logged: float | None

Representative logged sample temperature, if recorded.

Sourced from the Temp_Sample NXlog (the actual measured sample temperature), as distinct from the metadata['temperature'] setpoint. None when no logged series is present.

property run_label: str

Return a user-facing run label.

For co-added datasets this prefers metadata['run_label'] (for example "3039 + 3040") so UI text does not display internal negative combined IDs.

rebin(factor: int) MuonDataset[source]

Return a copy with every factor consecutive bins merged.

Thin convenience wrapper over asymmetry.core.transform.rebin.rebin(): the time/asymmetry/error arrays are rebinned by that primitive and a new dataset is returned with the same metadata and run (this dataset is left unchanged).

Combining factor adjacent bins trades time resolution for statistics — the per-point error shrinks as \(1/\sqrt{factor}\) on flat data. factor = 1 is a no-op copy; a length that is not a multiple of factor drops the trailing remainder bins; factor < 1 raises ValueError.

Useful on high-rate continuous-source data (e.g. PSI GPS 1.25 ns bins) where the raw sampling is far finer than the physics requires.

time_range(t_min: float | None = None, t_max: float | None = None) MuonDataset[source]

Return a copy restricted to [t_min, t_max].

summary() str[source]
__init__(time: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]], asymmetry: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]], error: ~numpy.ndarray[tuple[int, ...], ~numpy.dtype[~numpy.float64]], metadata: dict[str, ~typing.Any] = <factory>, run: ~asymmetry.core.data.dataset.Run | None = None) None
class asymmetry.core.data.dataset.Run(run_number: int = 0, histograms: list[Histogram] = <factory>, metadata: dict[str, ~typing.Any]=<factory>, grouping: dict[str, ~typing.Any]=<factory>, source_file: str = '')[source]

Bases: object

A single μSR measurement run.

Holds raw histograms, grouping definitions, and run metadata.

run_number: int = 0
histograms: list[Histogram]
metadata: dict[str, Any]
grouping: dict[str, Any]
source_file: str = ''
property title: str
property temperature: float

Sample temperature setpoint (sample/temperature in NeXus).

property sample_temperature_logged: float | None

Representative logged sample temperature, if recorded.

Sourced from the Temp_Sample NXlog (the actual measured sample temperature), as distinct from temperature (the setpoint). None when no logged series is present.

property field: float
summary() str[source]
__init__(run_number: int = 0, histograms: list[Histogram] = <factory>, metadata: dict[str, ~typing.Any]=<factory>, grouping: dict[str, ~typing.Any]=<factory>, source_file: str = '') None
class asymmetry.core.data.dataset.Histogram(counts: ndarray[tuple[int, ...], dtype[float64]], bin_width: float, t0_bin: int = 0, good_bin_start: int = 0, good_bin_end: int = -1)[source]

Bases: object

Raw detector histogram.

Parameters:
  • counts (NDArray) – Positron counts per time bin.

  • bin_width (float) – Bin width in microseconds.

  • t0_bin (int) – Bin index of t = 0 (muon implantation).

  • good_bin_start (int) – First usable bin (offset from t0).

  • good_bin_end (int) – Last usable bin.

counts: ndarray[tuple[int, ...], dtype[float64]]
bin_width: float
t0_bin: int = 0
good_bin_start: int = 0
good_bin_end: int = -1
property n_bins: int
property time_axis: ndarray[tuple[int, ...], dtype[float64]]

Time axis in microseconds, centred on t0.

__init__(counts: ndarray[tuple[int, ...], dtype[float64]], bin_width: float, t0_bin: int = 0, good_bin_start: int = 0, good_bin_end: int = -1) None

Logbook

class asymmetry.core.data.logbook.LogbookEntry(run_number: int, title: str = '', temperature: float = 0.0, field: float = 0.0, comment: str = '', tags: list[str] = <factory>, source_file: str = '', extra: dict[str, ~typing.Any]=<factory>)[source]

Bases: object

One row in the logbook.

run_number: int
title: str = ''
temperature: float = 0.0
field: float = 0.0
comment: str = ''
tags: list[str]
source_file: str = ''
extra: dict[str, Any]
__init__(run_number: int, title: str = '', temperature: float = 0.0, field: float = 0.0, comment: str = '', tags: list[str] = <factory>, source_file: str = '', extra: dict[str, ~typing.Any]=<factory>) None
class asymmetry.core.data.logbook.Logbook[source]

Bases: object

A searchable, sortable table of μSR runs.

__init__() None[source]
add(dataset: MuonDataset, tags: list[str] | None = None) LogbookEntry[source]

Register a dataset in the logbook.

remove(run_number: int) None[source]
get_entry(run_number: int) LogbookEntry | None[source]
get_dataset(run_number: int) MuonDataset | None[source]
property run_numbers: list[int]
filter(**criteria: Any) list[LogbookEntry][source]

Return entries matching all criteria (field=value pairs).

search(text: str) list[LogbookEntry][source]

Free-text search across title, comment and tags.

create_collection(name: str, run_numbers: list[int]) None[source]
get_collection(name: str) list[int][source]
property collections: list[str]
save(path: str | Path) None[source]

Save logbook metadata to a JSON file.

load_metadata(path: str | Path) None[source]

Restore logbook entries from a JSON file (data files must be reloaded separately).

Instrument geometry

class asymmetry.core.instrument.DetectorSegment(detector_id: int, sector_index: int, ring_index: int, angle_center_deg: float, angle_half_width_deg: float, r_inner: float, r_outer: float, shape: str = 'wedge', label: str | None = None, x_center: float = 0.0, y_center: float = 0.0, width: float = 0.0, height: float = 0.0, rotation_deg: float = 0.0, read_only: bool = False)[source]

Bases: object

Geometric description of a single detector element.

Parameters:
  • detector_id (int) – 1-based detector number matching instrument-manual convention.

  • sector_index (int) – Azimuthal sector index within the bank (0-based, 0 = first sector).

  • ring_index (int) – Radial ring index within the sector (0-based, 0 = innermost). For single-ring instruments this is always 0.

  • angle_center_deg (float) – Central azimuthal angle of the segment in degrees. points right and positive rotation is counter-clockwise.

  • angle_half_width_deg (float) – Half the angular width of the segment in degrees.

  • r_inner (float) – Inner radius normalised to the outer disc radius (0–1).

  • r_outer (float) – Outer radius normalised to the outer disc radius (0–1).

  • shape (str) – Rendering primitive. "wedge" is used for circular ISIS detector banks; "rectangle" is used for top-view detector plates such as PSI FLAME. "endon_out" / "endon_in" draw a detector seen end-on — pointing toward () or away from () the viewer — used in multi-panel plan layouts to show a detector that is perpendicular to the current view (and therefore editable in the other panel).

  • label (str | None) – Optional short detector label displayed alongside the detector ID.

  • x_center – Rectangle geometry in an arbitrary plan-view coordinate system.

  • y_center – Rectangle geometry in an arbitrary plan-view coordinate system.

  • width – Rectangle geometry in an arbitrary plan-view coordinate system.

  • height – Rectangle geometry in an arbitrary plan-view coordinate system.

  • rotation_deg – Rectangle geometry in an arbitrary plan-view coordinate system.

  • read_only (bool) – When True the segment is drawn for spatial context only — it is not clickable and never takes a group colour. A detector that appears in more than one plan panel is active (clickable) in exactly one panel and read_only in the others.

detector_id: int
sector_index: int
ring_index: int
angle_center_deg: float
angle_half_width_deg: float
r_inner: float
r_outer: float
shape: str = 'wedge'
label: str | None = None
x_center: float = 0.0
y_center: float = 0.0
width: float = 0.0
height: float = 0.0
rotation_deg: float = 0.0
read_only: bool = False
property angle_start_deg: float

Lower bound of the segment arc in degrees (counter-clockwise measured).

property angle_end_deg: float

Upper bound of the segment arc in degrees (counter-clockwise measured).

__init__(detector_id: int, sector_index: int, ring_index: int, angle_center_deg: float, angle_half_width_deg: float, r_inner: float, r_outer: float, shape: str = 'wedge', label: str | None = None, x_center: float = 0.0, y_center: float = 0.0, width: float = 0.0, height: float = 0.0, rotation_deg: float = 0.0, read_only: bool = False) None
class asymmetry.core.instrument.BankLayout(name: str, segments: tuple[DetectorSegment, ...])[source]

Bases: object

Collection of detector segments forming one detector bank.

Parameters:
name: str
segments: tuple[DetectorSegment, ...]
__init__(name: str, segments: tuple[DetectorSegment, ...]) None
class asymmetry.core.instrument.GroupDefinition(name: str, detector_ids: tuple[int, ...])[source]

Bases: object

A named set of detectors forming one group within a preset.

Parameters:
  • name (str) – Display name for the group (e.g. "Forward", "Pz Backward").

  • detector_ids (tuple[int, ...]) – Tuple of 1-based detector IDs belonging to this group.

name: str
detector_ids: tuple[int, ...]
__init__(name: str, detector_ids: tuple[int, ...]) None
class asymmetry.core.instrument.PresetGrouping(name: str, groups: dict[int, GroupDefinition], forward_group: int, backward_group: int, projections: tuple[AsymmetryProjection, ...] = ())[source]

Bases: object

A named, complete grouping assignment for an instrument.

Parameters:
  • name (str) – Human-readable preset name (e.g. "Longitudinal").

  • groups (dict[int, asymmetry.core.instrument.GroupDefinition]) – Mapping from group ID (1-based, matching the Group 1–8 buttons) to GroupDefinition. Unused group IDs should not appear.

  • forward_group (int) – Group ID of the default forward group.

  • backward_group (int) – Group ID of the default backward group.

  • projections (tuple[asymmetry.core.instrument.AsymmetryProjection, ...]) – Ordered projections exposed by this preset, when it is a multi-projection (vector / dual-grouping) preset. Empty for ordinary single-pair presets.

name: str
groups: dict[int, GroupDefinition]
forward_group: int
backward_group: int
projections: tuple[AsymmetryProjection, ...] = ()
__init__(name: str, groups: dict[int, GroupDefinition], forward_group: int, backward_group: int, projections: tuple[AsymmetryProjection, ...] = ()) None
class asymmetry.core.instrument.ReferenceArrow(label: str, start: tuple[float, float], end: tuple[float, float], color: str = '#333333')[source]

Bases: object

A labelled direction marker rendered on plan-view schematics.

label: str
start: tuple[float, float]
end: tuple[float, float]
color: str = '#333333'
__init__(label: str, start: tuple[float, float], end: tuple[float, float], color: str = '#333333') None
class asymmetry.core.instrument.InstrumentLayout(name: str, n_detectors: int, banks: tuple[BankLayout, ...], presets: dict[str, PresetGrouping], view: str = 'radial', reference_arrows: tuple[ReferenceArrow, ...] = (), display_name: str | None = None, apply_default_preset_on_load: bool = False)[source]

Bases: object

Complete detector layout description for one muon instrument.

Parameters:
  • name (str) – Canonical instrument name ("HiFi", "MuSR", or "EMU").

  • n_detectors (int) – Total number of detectors / histograms.

  • banks (tuple[asymmetry.core.instrument.BankLayout, ...]) – Ordered tuple of BankLayout objects.

  • presets (dict[str, asymmetry.core.instrument.PresetGrouping]) – Ordered dict of preset names to PresetGrouping. The first entry is the default preset.

  • view (str) – Schematic rendering mode, either "radial" or "plan".

  • reference_arrows (tuple[asymmetry.core.instrument.ReferenceArrow, ...]) – Optional labelled arrows used by plan-view layouts.

  • display_name (str | None) – Optional user-facing name shown in the instrument dropdown. Defaults to name. Layout variants of one physical instrument (e.g. the 6-detector PSI GPS BIN layout "GPS" and the 11-detector ROOT sub-detector layout "GPS-RD") share a display name so the user only ever sees the variant matching their loaded data, while name stays a distinct registry key for detection and persistence.

  • apply_default_preset_on_load (bool) – When True, a loader applies this instrument’s default preset (the first entry in presets) to a freshly loaded run automatically, instead of leaving the loader’s generic label-based grouping in place. Used for instruments whose useful grouping is not the plain forward/backward split the labels imply — HAL-9500, whose per-octant angle-resolved grouping should be live the moment the data loads, without the user having to open the grouping window and click Apply. Off by default so every other instrument keeps its loader grouping untouched.

name: str
n_detectors: int
banks: tuple[BankLayout, ...]
presets: dict[str, PresetGrouping]
view: str = 'radial'
reference_arrows: tuple[ReferenceArrow, ...] = ()
display_name: str | None = None
apply_default_preset_on_load: bool = False
property display: str

User-facing name (display_name if set, else name).

property all_segments: list[DetectorSegment]

All segments from all banks, in bank order.

In a multi-panel plan layout a detector may appear in more than one bank (active in its home panel, read_only elsewhere), so this can contain several segments per detector_id. Use active_segments for one segment per physical detector.

property active_segments: list[DetectorSegment]

One clickable segment per detector (the non-read_only segments).

property default_preset_name: str

Name of the first (default) preset.

__init__(name: str, n_detectors: int, banks: tuple[BankLayout, ...], presets: dict[str, PresetGrouping], view: str = 'radial', reference_arrows: tuple[ReferenceArrow, ...] = (), display_name: str | None = None, apply_default_preset_on_load: bool = False) None
asymmetry.core.instrument.get_instrument_layout(name: str) InstrumentLayout[source]

Return the InstrumentLayout for name.

Parameters:

name – One of the names in INSTRUMENT_NAMES (case-sensitive).

Raises:

KeyError – If name is not a known instrument.

asymmetry.core.instrument.detect_instrument(n_histograms: int, *, metadata: dict | None = None, source_file: str | None = None) str | None[source]

Guess the instrument name from the number of histograms.

This is a heuristic for the automatic instrument selection in the detector layout editor. The caller should always allow the user to override the returned value.

Parameters:
  • n_histograms – Total number of histograms / raw-detector channels in the run.

  • metadata – Optional run metadata. If an instrument-like field is present, it takes priority over the histogram-count heuristic.

  • source_file – Optional source filename/path used as a final lightweight hint.

Returns:

Canonical instrument name when detection succeeds, else None.

Return type:

str or None

Simulation

Synthetic-run generation and statistics degradation (see Synthetic runs and degraded statistics): Poisson draws of expected per-detector counts from an instrument template — a loaded run or a built-in idealised instrument — exact binomial thinning of measured runs, per-group amplitude and phase simulation, and the promoted screenshot-archetype builders.

Synthetic μSR run generation and statistics degradation.

Counts in each detector time bin are Poisson distributed; the expected counts for detector d follow the muon lifetime envelope modulated by the asymmetry signal seen by that detector:

N_d(t) = N0_d · exp(−t/τ_μ) · [1 + a_d(t)] + b_d (t ≥ 0) N_d(t) = b_d (t < 0)

Simulation draws Poisson variates of these expected counts — never Gaussian noise added to an asymmetry curve — so per-bin errors propagate correctly through the real reduction chain (grouping → α-balanced asymmetry → error formula) by construction. The α calibration enters as the relative efficiency of the forward and backward detector groups: forward-group detectors receive the weight 2α/(1+α) and backward-group detectors 2/(1+α), so the reduction (F − αB)/(F + αB) recovers a(t) with α restored.

The functional behaviour follows WiMDA’s Simulate.pas/DegradeStats.pas (study: docs/porting/simulate-mode/), with the divergences documented in the study’s comparison.md: an optional flat background, background-only bins before t0, deterministic seeding, and exact binomial thinning for degrade factors below one.

This module is Qt-free and must stay importable without the GUI.

asymmetry.core.simulate.GroupSignal = collections.abc.Callable[[numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.float64]]], numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.float64]]] | numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.float64]]

a callable evaluated on the time axis in microseconds (returning the fractional asymmetry), or an array on the detector’s post-t0 bin grid (short arrays are zero-padded).

Type:

A per-group asymmetry signal

class asymmetry.core.simulate.InstrumentTemplate(key: str, label: str, description: str, n_detectors: int, n_bins: int, bin_width_us: float, t0_bin: int, forward_detectors: tuple[int, ...], backward_detectors: tuple[int, ...], alpha: float = 1.0, good_frames: float = 1.0, good_bin_start: int | None = None, good_bin_end: int | None = None, default_total_events: float = 10000000.0, default_background_per_bin: float = 0.0, instrument_name: str = '', field_state: str = 'ZF', detector_orientation: str = 'Longitudinal')[source]

A built-in idealised instrument that can stand in for a loaded run.

simulate_run() only reads structure from its template — detector count, bin width, per-detector t0, the good-bin window and the forward/backward grouping — never the (zero) counts, so a built-in template carries empty histograms and a complete grouping. The default_total_events and default_background_per_bin are teaching-sensible starting points the dialog seeds its spinners with; the continuous instrument carries a non-zero flat background (the time-independent uncorrelated background characteristic of continuous sources — textbook Ch. 14), the pulsed one does not.

key: str
label: str
description: str
n_detectors: int
n_bins: int
bin_width_us: float
t0_bin: int
forward_detectors: tuple[int, ...]
backward_detectors: tuple[int, ...]
alpha: float = 1.0
good_frames: float = 1.0
good_bin_start: int | None = None
good_bin_end: int | None = None
default_total_events: float = 10000000.0
default_background_per_bin: float = 0.0
instrument_name: str = ''
field_state: str = 'ZF'
detector_orientation: str = 'Longitudinal'
build() Run[source]

Materialise an empty-histogram Run with this geometry.

__init__(key: str, label: str, description: str, n_detectors: int, n_bins: int, bin_width_us: float, t0_bin: int, forward_detectors: tuple[int, ...], backward_detectors: tuple[int, ...], alpha: float = 1.0, good_frames: float = 1.0, good_bin_start: int | None = None, good_bin_end: int | None = None, default_total_events: float = 10000000.0, default_background_per_bin: float = 0.0, instrument_name: str = '', field_state: str = 'ZF', detector_orientation: str = 'Longitudinal') None
asymmetry.core.simulate.BUILTIN_TEMPLATES: dict[str, InstrumentTemplate] = {'ideal_continuous_fb': InstrumentTemplate(key='ideal_continuous_fb', label='Ideal continuous F/B (PSI-style)', description='Continuous-source F/B pair: 1 ns bins over a 10 µs window with a flat uncorrelated background (10 counts/bin/detector).', n_detectors=2, n_bins=10000, bin_width_us=0.001, t0_bin=1000, forward_detectors=(1,), backward_detectors=(2,), alpha=1.0, good_frames=1.0, good_bin_start=None, good_bin_end=None, default_total_events=20000000.0, default_background_per_bin=10.0, instrument_name='IDEAL-CONTINUOUS', field_state='ZF', detector_orientation='Longitudinal'), 'ideal_pulsed_fb': InstrumentTemplate(key='ideal_pulsed_fb', label='Ideal pulsed F/B (ISIS-style)', description='Pulsed-source spectrometer: 32 forward + 32 backward detectors, 16 ns bins over a 32 µs window, no uncorrelated background.', n_detectors=64, n_bins=2000, bin_width_us=0.016, t0_bin=100, forward_detectors=(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32), backward_detectors=(33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64), alpha=1.0, good_frames=1.0, good_bin_start=None, good_bin_end=None, default_total_events=40000000.0, default_background_per_bin=0.0, instrument_name='IDEAL-PULSED', field_state='ZF', detector_orientation='Longitudinal')}

Built-in idealised instruments, keyed for the dialog template combo. The pulsed F/B template mirrors an ISIS-style spectrometer (32 + 32 detectors, 16 ns bins, a 32 µs window); the continuous template a PSI-style F/B pair with fine 1 ns binning, a short 10 µs window and a flat background. The two are the source archetypes the textbook contrasts (Ch. 14).

asymmetry.core.simulate.build_builtin_template(key: str) Run[source]

Build an empty-histogram Run for a named built-in instrument.

Raises KeyError for an unknown key. See BUILTIN_TEMPLATES for the available instruments.

asymmetry.core.simulate.poisson_asymmetry_errors(asymmetry: ndarray[tuple[int, ...], dtype[float64]], counts_per_bin: float = 50000.0) ndarray[tuple[int, ...], dtype[float64]][source]

Per-bin uncertainty (percent) derived from a target counts-per-bin.

The asymmetry σ for a two-detector experiment scales as sqrt((1 - A^2) / N). Picking a counts-per-bin gives a realistic noise envelope without doing a histogram round-trip; use the full simulate_run() pipeline when the errors must come from actual Poisson histograms.

asymmetry.core.simulate.build_run_from_detector_asymmetries(*, run_number: int, detector_asymmetries: list[dict], title: str, temperature_k: float, field_g: float, bin_width_us: float = 0.005, n_bins: int = 2400, t0_bin: int = 100, n0_per_detector: float = 1000000.0, lifetime_us: float = 2.1969811, rng: Generator | None = None) tuple[Run, ndarray[tuple[int, ...], dtype[float64]], ndarray[tuple[int, ...], dtype[float64]], ndarray[tuple[int, ...], dtype[float64]]][source]

Synthesise a full Run from per-detector asymmetry signals.

Each detector histogram is built as

N_d(t) = N_{0,d} · exp(-t/τ) · (1 + A_d(t)) for t > 0

= N_{0,d} for t ≤ 0

with Poisson counting noise applied. The grouping payload puts one detector per group so the GUI’s Individual Groups domain view shows a trace per detector. The function also returns the F–B asymmetry trace (using the first two groups) and a per-bin error estimate so a wrapper MuonDataset carries a defensible time-domain view.

This is the screenshot-archetype builder promoted into core: the flat pre-t0 plateau at N₀ is its historical convention (kept for byte-stable documentation data). simulate_run() is the template-driven instrument-faithful path, with background-only bins before t0.

Parameters:
  • detector_asymmetries – One dict per detector. Each dict must define "asymmetry" (a (n_bins - t0_bin,) array giving the fractional A_d(t)) plus optional "label" and "n0".

  • lifetime_us – Decay constant of the envelope. Defaults to the canonical muon lifetime; the screenshot archetypes pass their legacy rounded value to keep documentation data byte-stable.

asymmetry.core.simulate.expected_counts(template: Run, group_signals: Mapping[int, Callable[[ndarray[tuple[int, ...], dtype[float64]]], ndarray[tuple[int, ...], dtype[float64]]] | ndarray[tuple[int, ...], dtype[float64]]], *, total_events: float, group_weights: Mapping[int, float] | None = None, background_per_bin: float = 0.0) list[ndarray[tuple[int, ...], dtype[float64]]][source]

Expected (noise-free) per-detector count histograms.

The deterministic core of simulate_run_from_group_signals(), exposed so tests and diagnostics can compare the sampled histograms (or a reduction of them) against the exact expectation.

total_events is the expected number of detected decay events summed over all detectors and the post-t0 histogram window — the per-bin rate at t = 0 uses the exact telescoping normalisation n0 = N_d · (1 exp(−Δt/τ_μ)), so the window sum equals total_events · (1 exp(−T/τ_μ)) up to the (simulated) truncation of the lifetime envelope at the histogram end. Background counts are in addition to this budget.

group_weights rescales each group’s per-detector rate; weights are normalised over the assigned detectors so the total event budget is independent of the weighting (this is how the α split is applied without distorting the run-level rate).

asymmetry.core.simulate.simulate_run_from_group_signals(template: Run, group_signals: Mapping[int, Callable[[ndarray[tuple[int, ...], dtype[float64]]], ndarray[tuple[int, ...], dtype[float64]]] | ndarray[tuple[int, ...], dtype[float64]]], *, total_events: float, seed: int = 0, group_weights: Mapping[int, float] | None = None, background_per_bin: float = 0.0, run_number: int | None = None, title: str | None = None, simulation_metadata: Mapping[str, Any] | None = None) Run[source]

Simulate a run from per-group fractional asymmetry signals.

The scriptable multi-group seam: each entry of group_signals assigns a signal a_g(t) to one detector group of the template’s grouping; detectors in unlisted groups receive the bare lifetime envelope. Bin structure, per-detector t0, the good-bin window and the grouping are all taken from template. Sampling is a single seeded numpy.random.Generator drawing poisson(expected) per detector, in detector order — a fixed seed reproduces the run bit-for-bit.

The returned Run carries metadata["synthetic"] = True and a metadata["simulation"] provenance dict; deadtimes are zeroed in the grouping (the synthetic counts contain no deadtime distortion, so zero is the true instrument description).

asymmetry.core.simulate.simulate_run(template: Run, model: Any, parameters: Mapping[str, float] | None = None, *, total_events: float, seed: int = 0, alpha: float | None = None, background_per_bin: float = 0.0, run_number: int | None = None, title: str | None = None) Run[source]

Simulate a forward/backward run from a fit model (WiMDA Simulate).

model is either a CompositeModel (its function is bound with parameters) or a plain callable returning the asymmetry in percent on a time axis in microseconds — the same convention the fit panel uses. Forward-group detectors see +a(t), backward-group detectors −a(t) (every non-backward group counts as forward, as in WiMDA). The α split fixes the forward/backward group totals at 2α/(1+α) : 2/(1+α) of the event budget, divided equally among each side’s detectors, so the reduction recovers a(t) with α restored regardless of group sizes.

α defaults to the template grouping’s balance factor. See simulate_run_from_group_signals() for the sampling and provenance contract; the generating model expression, parameter values and α are recorded in metadata["simulation"].

asymmetry.core.simulate.simulate_count_run(template: Run, model: Any, parameters: Mapping[str, float] | None = None, *, total_events: float, seed: int = 0, background_per_bin: float = 0.0, run_number: int | None = None, title: str | None = None) Run[source]

Simulate per-group single-histogram count data (WiMDA evfactor path).

The count-domain counterpart of simulate_run(). Where simulate_run imprints the antisymmetric ±a(t) of a forward/backward asymmetry experiment, this imprints the same +a(t) on every detector group, so each group is an independent single-histogram measurement

N_g(t) = N₀_g · exp(−t/τ_μ) · [1 + a(t)] + b (t ≥ 0)

with no balancing backward detector — the non-FB convention WiMDA’s Simulate.pas evfactor branch (nn = a(t)/ghists·evfactor·e^{−t/τ}) produces. The event budget is divided equally over the detectors (no α split), so each group’s N₀ follows its detector count.

The result is fittable by asymmetry.core.fitting.count_domain.fit_single_histogram() (side="forward" on any group), recovering the generating N₀, a(t) amplitude and flat background. Provenance records count_mode = True. Use simulate_run() when the data must instead exercise the α-free forward/backward count fit (fit_fb_alpha), which needs the antisymmetric pair.

asymmetry.core.simulate.simulate_double_pulse_run(template: Run, model: Any, parameters: Mapping[str, float] | None = None, *, total_events: float, dpsep_us: float, alpha: float | None = None, background_per_bin: float = 0.0, seed: int = 0, run_number: int | None = None, title: str | None = None) Run[source]

Simulate an ISIS double-pulse run (WiMDA double-pulse count model).

Two muon pulses separated by dpsep_us each carry the polarization, evaluated at t ± dpsep/2 and weighted by exp(∓dpsep/2τ_μ); the envelope stays at t and the single-pulse limit is dpsep_us 0. Used to round-trip the double-pulse single-histogram fit (the generating model matches asymmetry.core.fitting.count_domain.fit_single_histogram()’s dpsep model).

class asymmetry.core.simulate.PeriodSpec(model: Any, parameters: Mapping[str, float] | None = None, total_events: float | None = None, alpha: float | None = None, scale: float = 1.0, label: str = '')[source]

One period’s forward/backward signal for a two-period synthetic run.

Each period is an independent measurement: its own model (or the same model with different parameters), event budget and α balance, all imprinted with the standard forward/backward antisymmetry so the period reduces exactly like a real single-period run. total_events and alpha fall back to the run-level budget and the template grouping’s balance when None. scale multiplies the whole signal amplitude (0 makes the period a flat reference — the usual light-off / RF-off green — so G−R recovers the other period); it keeps the model’s own provenance intact rather than wrapping it in an opaque closure.

model: Any
parameters: Mapping[str, float] | None = None
total_events: float | None = None
alpha: float | None = None
scale: float = 1.0
label: str = ''
__init__(model: Any, parameters: Mapping[str, float] | None = None, total_events: float | None = None, alpha: float | None = None, scale: float = 1.0, label: str = '') None
asymmetry.core.simulate.simulate_two_period_run(template: Run, periods: list[PeriodSpec], *, total_events: float, seed: int = 0, background_per_bin: float = 0.0, period_mode: PeriodMode | str = PeriodMode.RED, run_number: int | None = None, title: str | None = None) Run[source]

Simulate a two-period (red/green) run from per-period models.

Pulsed-source runs can be recorded in period mode (light-on/off, RF-on/off, ALC steps): one file holds several period histograms. This synthesises the two-period case — periods[0] is red (period 1), periods[1] green (period 2) — through the same Poisson forward model as simulate_run(), one independent draw per period from a single seeded generator (a fixed seed reproduces both periods bit-for-bit).

The returned Run carries the loader’s two-period payload (period_histograms, period_reduced, period_good_frames, period_dead_time_us, period_mode) with run.histograms cloned from the red period, so asymmetry.core.io.periods.select_period(), the green∓red combinations in reduce_run_to_dataset(), and degrade_run() all operate on it exactly as on a loaded period-mode file. The NeXus writer still emits a single period (study decision); a two-period synthetic run lives in-memory and through the project.

class asymmetry.core.simulate.GroupSignalSpec(group_id: int, amplitude: float, relative_phase: float = 0.0, n0_weight: float = 1.0, label: str = '')[source]

Per-group amplitude, relative phase and count-rate weight for a TF ring.

The simulation analogue of a grouped time-domain fit’s per-group nuisance block (amplitude, relative_phase, N0 in asymmetry.core.fitting.grouped_time_domain): each detector group sees the shared normalised polarisation scaled by its own amplitude and phase-shifted by its own relative_phase, with n0_weight setting its relative count rate (detector efficiency).

group_id: int
amplitude: float
relative_phase: float = 0.0
n0_weight: float = 1.0
label: str = ''
__init__(group_id: int, amplitude: float, relative_phase: float = 0.0, n0_weight: float = 1.0, label: str = '') None
asymmetry.core.simulate.build_group_signals(model: Any, specs: Mapping[int, GroupSignalSpec] | list[GroupSignalSpec], *, base_parameters: Mapping[str, float] | None = None) tuple[dict[int, Callable[[ndarray[tuple[int, ...], dtype[float64]]], ndarray[tuple[int, ...], dtype[float64]]] | ndarray[tuple[int, ...], dtype[float64]]], dict[int, float]][source]

Build (group_signals, group_weights) for a multi-group simulation.

model is a normalised polarisation model (amplitude 1, background 0 — the grouped-fit contract), so its output is the dimensionless polarisation P(t) ≈ [−1, 1] directly (no percent conversion: the per-group amplitude owns the scale, exactly as in build_grouped_count_model()). Each spec forms a_g(t) = amplitude · P(t) and adds relative_phase (radians) to the model’s phase parameter(s); base_parameters supplies the shared model values. The returned weights are the per-group n0_weight values, fed to simulate_run_from_group_signals() which normalises them over the assigned detectors.

Raises ValueError if a non-zero relative_phase is requested for a model with no phase parameter.

asymmetry.core.simulate.simulate_multi_group_run(template: Run, model: Any, specs: Mapping[int, GroupSignalSpec] | list[GroupSignalSpec], *, total_events: float, seed: int = 0, base_parameters: Mapping[str, float] | None = None, background_per_bin: float = 0.0, run_number: int | None = None, title: str | None = None) Run[source]

Simulate a run with a distinct amplitude/phase per detector group.

The multi-group counterpart of simulate_run(): instead of the F/B α split it drives simulate_run_from_group_signals() with per-group signals built by build_group_signals() from specs and the shared normalised model. Use it to synthesise a TF ring whose groups differ in phase, seeded from a grouped time-domain fit’s nuisance parameters.

asymmetry.core.simulate.group_specs_from_grouped_fit(grouped_result: Any) list[GroupSignalSpec][source]

Extract per-group GroupSignalSpecs from a grouped fit result.

Reads the amplitude/relative_phase/N0 nuisance values from each group’s FitResult in a GroupedTimeDomainFitResult (duck-typed: any object exposing group_results). Groups with no fitted amplitude fall back to a 0.2 default. The shared model values are not captured here — pass them as base_parameters to build_group_signals() / simulate_multi_group_run().

asymmetry.core.simulate.degrade_run(run: Run, factor: float, *, seed: int = 0, run_number: int | None = None) Run[source]

Resample a run’s histograms to a different statistics level.

For factor < 1 each recorded count survives independently with probability factor (binomial thinning) — thinning a Poisson process is exactly Poisson, so the result is statistically indistinguishable from a run measured for factor times the beam time. For factor > 1 no exact construction from recorded data exists; the WiMDA convention Poisson(counts · factor) is used, which is over-dispersed relative to a genuinely longer measurement (variance λf(1+f) rather than λf).

Returns a new Run (the source run is untouched) with metadata["degraded"] provenance. A fixed seed reproduces the resampling bit-for-bit. grouping["good_frames"] (and the per-period frame counts) are scaled by factor — thinning by f is a measurement f times shorter, and an inherited deadtime correction only stays exact when counts and frames scale together. Combined two-period runs keep their period payload: each period’s histograms are thinned with the same generator and the stored per-period reductions are recomputed, so period selection keeps working on the derived run.

asymmetry.core.simulate.reduce_run_to_dataset(run: Run) MuonDataset[source]

Reduce a run to its F-B asymmetry MuonDataset (percent).

Mirrors the loader reduction (NexusLoader): align and sum the forward/backward groups, form the α-balanced asymmetry in percent, slice to the good-bin window and build the time axis from the common t0. Runs carrying a two-period payload are reduced according to their period_mode (red, green, or the green∓red combinations), and a bunching_factor above one is applied, so a derived run surfaces in the Data Browser looking exactly like its source. Used for synthetic and degraded runs.

asymmetry.core.simulate.total_events_of(run: Run) float[source]

Realised event total of a run: the sum of all histogram counts.

The gross count (signal + background). Use it as the event-budget default for a re-simulation or a dialog spinner; for a signal-only budget that excludes a run’s flat background, subtract estimate_background_per_bin(run) * total bins (see matched_statistics()).

asymmetry.core.simulate.estimate_background_per_bin(run: Run) float[source]

Estimate a run’s flat background from its pre-t0 bins (counts/bin).

The bins before t0 see no implanted-muon decay signal, so their mean count is the time-independent background level (zero for an ideal pulsed source). Returns the median across detectors for robustness, or 0.0 when no detector has a pre-t0 region.

asymmetry.core.simulate.matched_statistics(run: Run) tuple[float, float][source]

Signal event budget and flat background for re-simulating a run.

Returns (total_signal_events, background_per_bin) such that feeding them to simulate_run() reproduces the run’s statistics: the flat background (estimated from the pre-t0 bins) is split off the gross count so it is not counted as decay signal. For an ideal pulsed run (no background) this is just the gross total and zero.

asymmetry.core.simulate.simulate_capture_run(template: Run, components: Sequence[CaptureComponent], weights: Mapping[str, float], *, total_events: float, group_id: int | None = None, seed: int = 0, background_per_bin: float = 0.0, run_number: int | None = None, title: str | None = None) Run[source]

Synthesise a μ⁻ capture-lifetime run.

Per detector in the signal group (or all detectors if group_id is None):

N_d(t) = Σ_i N_{i,d}·exp(−(t−t0)/τ_i) + b (t ≥ t0) N_d(t) = b (t < t0)

with the component populations split by weights (relative, normalised over the components) and the per-bin envelope using the same exact telescoping normalisation as expected_counts():

n0_i = N_{i,d}·(1−exp(−Δt/τ_i))

so the post-t0 window sum ≈ total_events. Detectors in group_id (or all detectors if None) carry the signal; background_per_bin is added in addition to the event budget. Poisson-sampled with seed and assembled (provenance, deadtime-zeroing) via _sample_and_build_run(). Provenance records capture_mode=True, the components/τ and weights, and the seed.

Parameters:
  • template – Geometry template — bin structure, t0, grouping. Counts are ignored.

  • components – Ordered sequence of CaptureComponent objects (duck-typed: need .label and .tau_us).

  • weights – Relative weight per component label. Normalised over the components present; missing labels default to zero weight. The provenance block records the raw (pre-normalisation) weights as passed.

  • total_events – Expected number of detected capture events in the post-t0 window, summed over all signal detectors.

  • group_id – Only detectors in this group carry the signal. If None, all detectors are signal detectors.

  • seed – RNG seed for bit-for-bit reproducibility.

  • background_per_bin – Flat background counts per bin added to every detector (signal and non-signal alike).

  • run_number – Provenance metadata for the returned Run.

  • title – Provenance metadata for the returned Run.

Archetype presets

One-click textbook archetypes (Ag Kubo–Toyabe, EuO T-scan, F-μ-F, YBCO) that generate badged synthetic runs through the simulate pipeline.

Archetype gallery: one-click textbook synthetic runs.

Each preset pairs a built-in idealised instrument (asymmetry.core.simulate) with a fit model and a set of physically grounded parameters, and generates a first-class synthetic run — Poisson count histograms drawn through the same reduction chain as beamline data, badged with provenance — rather than a curve with Gaussian noise bolted on. The parameter values are the canonical textbook/literature numbers also used by the documentation screenshots (docs.screenshots.data.archetypes); here they drive the full simulate pipeline so the runs are fittable and recover their generating physics.

Reference for all archetypes: S. J. Blundell, R. De Renzi, T. Lancaster, and F. L. Pratt (eds.), Muon Spectroscopy: An Introduction (Oxford University Press, 2022). Per-preset chapters are named in each SimulatePreset.

This module is Qt-free and must stay importable without the GUI.

class asymmetry.core.simulate_presets.PresetRunSpec(model_name: str, model: Any, parameters: dict[str, float], title: str, temperature: float, field: float, field_state: str = 'ZF', total_events: float = 40000000.0)[source]

One synthetic run within a preset (a scan member or a single run).

model_name: str
model: Any
parameters: dict[str, float]
title: str
temperature: float
field: float
field_state: str = 'ZF'
total_events: float = 40000000.0
__init__(model_name: str, model: Any, parameters: dict[str, float], title: str, temperature: float, field: float, field_state: str = 'ZF', total_events: float = 40000000.0) None
class asymmetry.core.simulate_presets.SimulatePreset(key: str, label: str, chapter: str, description: str, template_key: str, seed: int, specs: tuple[~asymmetry.core.simulate_presets.PresetRunSpec, ...]=<factory>)[source]

A named textbook archetype that generates one or more synthetic runs.

key: str
label: str
chapter: str
description: str
template_key: str
seed: int
specs: tuple[PresetRunSpec, ...]
__init__(key: str, label: str, chapter: str, description: str, template_key: str, seed: int, specs: tuple[~asymmetry.core.simulate_presets.PresetRunSpec, ...]=<factory>) None
asymmetry.core.simulate_presets.ARCHETYPE_PRESETS: dict[str, SimulatePreset] = {'ag_lf_decoupling': SimulatePreset(key='ag_lf_decoupling', label='Ag LF decoupling series', chapter='Ch. 5', description='Longitudinal-field decoupling of the same Ag Kubo–Toyabe: as B_L grows the relaxation is progressively decoupled, the textbook test of static nuclear dipolar fields.', template_key='ideal_pulsed_fb', seed=4100, specs=(PresetRunSpec(model_name='LFKuboToyabe', model=<function longitudinal_field_kubo_toyabe>, parameters={'A0': 24.0, 'Delta': 0.39, 'B_L': 0.0, 'baseline': 0.0}, title='Ag LF 0 G', temperature=20.0, field=0.0, field_state='ZF', total_events=40000000.0), PresetRunSpec(model_name='LFKuboToyabe', model=<function longitudinal_field_kubo_toyabe>, parameters={'A0': 24.0, 'Delta': 0.39, 'B_L': 10.0, 'baseline': 0.0}, title='Ag LF 10 G', temperature=20.0, field=10.0, field_state='LF', total_events=40000000.0), PresetRunSpec(model_name='LFKuboToyabe', model=<function longitudinal_field_kubo_toyabe>, parameters={'A0': 24.0, 'Delta': 0.39, 'B_L': 25.0, 'baseline': 0.0}, title='Ag LF 25 G', temperature=20.0, field=25.0, field_state='LF', total_events=40000000.0), PresetRunSpec(model_name='LFKuboToyabe', model=<function longitudinal_field_kubo_toyabe>, parameters={'A0': 24.0, 'Delta': 0.39, 'B_L': 50.0, 'baseline': 0.0}, title='Ag LF 50 G', temperature=20.0, field=50.0, field_state='LF', total_events=40000000.0))), 'ag_zf_kt': SimulatePreset(key='ag_zf_kt', label='Ag ZF Gaussian Kubo–Toyabe', chapter='Ch. 5', description='Zero-field silver polycrystal: the canonical static nuclear-dipolar reference, a Gaussian Kubo–Toyabe with Δ = 0.39 µs⁻¹.', template_key='ideal_pulsed_fb', seed=2301, specs=(PresetRunSpec(model_name='StaticGKT_ZF', model=<function static_gkt_zf>, parameters={'A0': 24.0, 'Delta': 0.39, 'baseline': 0.0}, title='Ag ZF 20 K (Kubo–Toyabe)', temperature=20.0, field=0.0, field_state='ZF', total_events=40000000.0),)), 'euo_tscan': SimulatePreset(key='euo_tscan', label='EuO ferromagnet through Tc', chapter='Ch. 6', description='Zero-field EuO temperature scan across the Curie point Tc = 69 K: spontaneous precession below Tc tracking the order parameter (1 T/Tc)^β, paramagnetic relaxation above.', template_key='ideal_pulsed_fb', seed=1700, specs=(PresetRunSpec(model_name='Oscillatory', model=<function oscillatory>, parameters={'A0': 22.0, 'frequency': 22.286621524273773, 'phase': 0.0, 'Lambda': 0.1, 'baseline': 0.0}, title='EuO ZF 30 K (ordered)', temperature=30.0, field=0.0, field_state='ZF', total_events=40000000.0), PresetRunSpec(model_name='Oscillatory', model=<function oscillatory>, parameters={'A0': 22.0, 'frequency': 16.71551784090791, 'phase': 0.0, 'Lambda': 0.10017662469979105, 'baseline': 0.0}, title='EuO ZF 50 K (ordered)', temperature=50.0, field=0.0, field_state='ZF', total_events=40000000.0), PresetRunSpec(model_name='Oscillatory', model=<function oscillatory>, parameters={'A0': 22.0, 'frequency': 8.962772884123677, 'phase': 0.0, 'Lambda': 2.6647215537198186, 'baseline': 0.0}, title='EuO ZF 65 K (ordered)', temperature=65.0, field=0.0, field_state='ZF', total_events=40000000.0), PresetRunSpec(model_name='ExponentialRelaxation', model=<function exponential_relaxation>, parameters={'A0': 22.0, 'Lambda': 2.6647215537198186, 'baseline': 0.0}, title='EuO ZF 73 K (paramagnetic)', temperature=73.0, field=0.0, field_state='ZF', total_events=40000000.0), PresetRunSpec(model_name='ExponentialRelaxation', model=<function exponential_relaxation>, parameters={'A0': 22.0, 'Lambda': 0.10001914046956852, 'baseline': 0.0}, title='EuO ZF 90 K (paramagnetic)', temperature=90.0, field=0.0, field_state='ZF', total_events=40000000.0))), 'fmuf_pbf2': SimulatePreset(key='fmuf_pbf2', label='PbF₂ F-μ-F entanglement', chapter='Ch. 4', description='Zero-field PbF₂: the muon binds two fluorine neighbours into an F-μ-F state whose characteristic dipolar beat pattern (r 1.17 Å) is the textbook fingerprint of muon-fluorine entanglement.', template_key='ideal_pulsed_fb', seed=8900, specs=(PresetRunSpec(model_name='FmuF_Linear+Constant', model=<asymmetry.core.fitting.composite.CompositeModel object>, parameters={'A_1': 22.0, 'r_muF': 1.17, 'A_bg': 0.2}, title='PbF₂ ZF 5 K (F-μ-F)', temperature=5.0, field=0.0, field_state='ZF', total_events=80000000.0),)), 'ybco_tf': SimulatePreset(key='ybco_tf', label='YBCO transverse-field precession', chapter='Ch. 8', description='Normal-state YBa₂Cu₃O₇₋δ in a 200 G transverse field: Knight-shifted Larmor precession with a relaxation that broadens on entering the vortex state below Tc = 90 K.', template_key='ideal_pulsed_fb', seed=10100, specs=(PresetRunSpec(model_name='Oscillatory', model=<function oscillatory>, parameters={'A0': 20.0, 'frequency': 2.7235499999999995, 'phase': 0.0, 'Lambda': 0.08, 'baseline': 0.0}, title='YBCO TF 200 G 100 K (Knight shift)', temperature=100.0, field=200.0, field_state='TF', total_events=40000000.0),))}

Archetype presets keyed for the gallery menu.

asymmetry.core.simulate_presets.build_preset_runs(key: str, *, seed: int | None = None, run_number_allocator: Callable[[], int] | None = None) list[Run][source]

Generate the synthetic run(s) of a named archetype preset.

Each run is produced by asymmetry.core.simulate.simulate_run() on the preset’s built-in instrument template, badged synthetic, and carries the preset key and textbook chapter in its metadata["simulation"] provenance. A scan preset (EuO, Ag LF) returns several runs; seeds are the preset’s fixed seed offset by the member index, so the family is reproducible. seed overrides the preset’s fixed base seed when given.

Raises KeyError for an unknown preset key.

Pull-distribution diagnostic

Re-simulate-and-refit a completed fit over many seeds to test that the analysis chain’s error bars are calibrated (pulls ~ \(N(0, 1)\)).

Pull-distribution diagnostic: is the analysis chain’s error bar honest?

Given a completed fit — a model, its generating parameter values and the run it was fit on — re-simulate many synthetic runs at matched statistics, refit each, and collect the pulls

pull = (θ̂ − θ_true) / σ_θ̂

for every free parameter. For a correctly calibrated analysis the pulls are standard normal: a mean consistent with zero says the fit is unbiased, and a width consistent with one says the reported errors are neither over- nor under-estimated. A width persistently below one flags over-estimated errors, above one under-estimated errors — the single most informative check that the whole reduction → fit → covariance chain is trustworthy.

This module is Qt-free and engine-agnostic: the caller injects a refit callable so the diagnostic never imports the fitting engine (or iminuit) itself. It builds on asymmetry.core.simulate.simulate_run().

asymmetry.core.pull_diagnostic.RefitResult = tuple[collections.abc.Mapping[str, float], collections.abc.Mapping[str, float]] | None

A refit maps a reduced dataset to (values, errors) dicts keyed by parameter name, or None when the fit did not converge.

class asymmetry.core.pull_diagnostic.ParameterPull(name: str, truth: float, pulls: ndarray[tuple[int, ...], dtype[float64]])[source]

Collected pulls for one fitted parameter.

name: str
truth: float
pulls: ndarray[tuple[int, ...], dtype[float64]]
property n: int
property mean: float

Sample mean of the pulls (consistent with 0 for an unbiased fit).

property mean_uncertainty: float

Standard error of the mean under the N(0, 1) null (1/√N).

property width: float

Sample standard deviation (consistent with 1 for honest errors).

property width_uncertainty: float

Standard error of the width under the N(0, 1) null (1/√(2(N−1))).

verdict() str[source]

One-line calibration verdict for this parameter’s error bar.

__init__(name: str, truth: float, pulls: ndarray[tuple[int, ...], dtype[float64]]) None
class asymmetry.core.pull_diagnostic.PullDistribution(parameters: dict[str, ParameterPull], truth: dict[str, float], n_seeds: int, n_converged: int, total_events: float)[source]

Result of a pull-distribution run over many seeds.

parameters: dict[str, ParameterPull]
truth: dict[str, float]
n_seeds: int
n_converged: int
total_events: float
verdict() str[source]

Overall headline plus a per-parameter calibration line.

__init__(parameters: dict[str, ParameterPull], truth: dict[str, float], n_seeds: int, n_converged: int, total_events: float) None
asymmetry.core.pull_diagnostic.run_pull_distribution(template: Run, model: Any, parameters: Mapping[str, float], refit: Callable[[MuonDataset], tuple[Mapping[str, float], Mapping[str, float]] | None], *, total_events: float, n_seeds: int = 200, seed_start: int = 0, track: Sequence[str] | None = None, alpha: float | None = None, background_per_bin: float = 0.0, time_range: tuple[float | None, float | None] | None = None, progress: Callable[[int, int], None] | None = None, should_continue: Callable[[], bool] | None = None) PullDistribution[source]

Re-simulate, refit and histogram parameter pulls over n_seeds seeds.

parameters are the generating (true) values bound into model for every synthetic run; track selects which of them to collect pulls for (default: all of them — pass the free parameters to skip fixed ones). Each seed simulates a run from template at total_events matched statistics, reduces it, optionally restricts it to time_range (the fit window), and refits it via the injected refit. Seeds whose refit fails to converge — or returns a non-finite/non-positive error for a tracked parameter — are dropped and counted in n_converged.

progress(done, total) is called after each seed when supplied. should_continue() is polled before each seed; returning False stops the run early (a cancel button), and the reported n_seeds is then the number actually attempted, so the converged fraction stays meaningful.