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:
objectProcessed asymmetry dataset ready for plotting / fitting.
This is the primary object that users interact with after loading and reducing a run.
Scale convention
asymmetryanderrorare stored on the percent scale (the WiMDA-style convention the loaders and the time-domain fit models share — a 16 % asymmetry is stored as16.0, not0.16, and the built-in models defaultA0to25). The low-levelasymmetry.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 explicitasymmetry_percent/asymmetry_fraction(anderror_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.- 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 temperature: float | None
Sample temperature setpoint (
metadata["temperature"]), orNone.Unlike
Run.temperature(which floors a missing value to0.0), this returnsNonewhen no temperature was recorded so a trend point with genuinely-missing metadata is marked off-axis rather than planted at 0 K.
- property sample_temperature_logged: float | None
Representative logged sample temperature, if recorded.
Sourced from the
Temp_SampleNXlog (the actual measured sample temperature), as distinct from themetadata['temperature']setpoint.Nonewhen 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
factorconsecutive 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
factoradjacent bins trades time resolution for statistics — the per-point error shrinks as \(1/\sqrt{factor}\) on flat data.factor = 1is a no-op copy; a length that is not a multiple offactordrops the trailing remainder bins;factor < 1raisesValueError.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].
- __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:
objectA single μSR measurement run.
Holds raw histograms, grouping definitions, and run metadata.
- property sample_temperature_logged: float | None
Representative logged sample temperature, if recorded.
Sourced from the
Temp_SampleNXlog (the actual measured sample temperature), as distinct fromtemperature(the setpoint).Nonewhen no logged series is present.
- 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:
objectRaw detector histogram.
- Parameters:
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:
objectOne row in the logbook.
- class asymmetry.core.data.logbook.Logbook[source]
Bases:
objectA searchable, sortable table of μSR runs.
- add(dataset: MuonDataset, tags: list[str] | None = None) LogbookEntry[source]
Register a dataset in the logbook.
- get_entry(run_number: int) LogbookEntry | None[source]
- get_dataset(run_number: int) MuonDataset | None[source]
- 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.
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:
objectGeometric 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.
0°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
Truethe 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 andread_onlyin the others.
- 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:
objectCollection of detector segments forming one detector bank.
- Parameters:
name (str) – Human-readable bank name (e.g.
"Forward").segments (tuple[asymmetry.core.instrument.DetectorSegment, ...]) – All detector segments that belong to this bank.
- segments: tuple[DetectorSegment, ...]
- __init__(name: str, segments: tuple[DetectorSegment, ...]) None
- class asymmetry.core.instrument.GroupDefinition(name: str, detector_ids: tuple[int, ...])[source]
Bases:
objectA named set of detectors forming one group within a preset.
- Parameters:
- class asymmetry.core.instrument.PresetGrouping(name: str, groups: dict[int, GroupDefinition], forward_group: int, backward_group: int, projections: tuple[AsymmetryProjection, ...] = ())[source]
Bases:
objectA 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.
- groups: dict[int, GroupDefinition]
- class asymmetry.core.instrument.ReferenceArrow(label: str, start: tuple[float, float], end: tuple[float, float], color: str = '#333333')[source]
Bases:
objectA labelled direction marker rendered on plan-view schematics.
- 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:
objectComplete 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
BankLayoutobjects.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, whilenamestays 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 inpresets) 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.
- banks: tuple[BankLayout, ...]
- presets: dict[str, PresetGrouping]
- reference_arrows: tuple[ReferenceArrow, ...] = ()
- 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_onlyelsewhere), so this can contain several segments perdetector_id. Useactive_segmentsfor one segment per physical detector.
- property active_segments: list[DetectorSegment]
One clickable segment per detector (the non-
read_onlysegments).
- __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
InstrumentLayoutfor 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. Thedefault_total_eventsanddefault_background_per_binare 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.- __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
Runfor a named built-in instrument.Raises
KeyErrorfor an unknown key. SeeBUILTIN_TEMPLATESfor 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 fullsimulate_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
Runfrom 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
MuonDatasetcarries 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_eventsis 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 normalisationn0 = N_d · (1 − exp(−Δt/τ_μ)), so the window sum equalstotal_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_weightsrescales 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_signalsassigns 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 fromtemplate. Sampling is a single seedednumpy.random.Generatordrawingpoisson(expected)per detector, in detector order — a fixed seed reproduces the run bit-for-bit.The returned
Runcarriesmetadata["synthetic"] = Trueand ametadata["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).
modelis either aCompositeModel(itsfunctionis bound withparameters) 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 inmetadata["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(). Wheresimulate_runimprints 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 measurementN_g(t) = N₀_g · exp(−t/τ_μ) · [1 + a(t)] + b (t ≥ 0)
with no balancing backward detector — the non-FB convention WiMDA’s
Simulate.pasevfactor branch (nn = a(t)/ghists·evfactor·e^{−t/τ}) produces. The event budget is divided equally over the detectors (no α split), so each group’sN₀follows its detector count.The result is fittable by
asymmetry.core.fitting.count_domain.fit_single_histogram()(side="forward"on any group), recovering the generatingN₀,a(t)amplitude and flat background. Provenance recordscount_mode = True. Usesimulate_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_useach carry the polarization, evaluated att ± dpsep/2and weighted byexp(∓dpsep/2τ_μ); the envelope stays attand the single-pulse limit isdpsep_us → 0. Used to round-trip the double-pulse single-histogram fit (the generating model matchesasymmetry.core.fitting.count_domain.fit_single_histogram()’sdpsepmodel).
- 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_eventsandalphafall back to the run-level budget and the template grouping’s balance whenNone.scalemultiplies the whole signal amplitude (0makes 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.
- 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 assimulate_run(), one independent draw per period from a single seeded generator (a fixed seed reproduces both periods bit-for-bit).The returned
Runcarries the loader’s two-period payload (period_histograms,period_reduced,period_good_frames,period_dead_time_us,period_mode) withrun.histogramscloned from the red period, soasymmetry.core.io.periods.select_period(), the green∓red combinations inreduce_run_to_dataset(), anddegrade_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,N0inasymmetry.core.fitting.grouped_time_domain): each detector group sees the shared normalised polarisation scaled by its ownamplitudeand phase-shifted by its ownrelative_phase, withn0_weightsetting its relative count rate (detector efficiency).
- 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.modelis 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-groupamplitudeowns the scale, exactly as inbuild_grouped_count_model()). Each spec formsa_g(t) = amplitude · P(t)and addsrelative_phase(radians) to the model’s phase parameter(s);base_parameterssupplies the shared model values. The returned weights are the per-groupn0_weightvalues, fed tosimulate_run_from_group_signals()which normalises them over the assigned detectors.Raises
ValueErrorif a non-zerorelative_phaseis 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 drivessimulate_run_from_group_signals()with per-group signals built bybuild_group_signals()fromspecsand the shared normalisedmodel. 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/N0nuisance values from each group’sFitResultin aGroupedTimeDomainFitResult(duck-typed: any object exposinggroup_results). Groups with no fitted amplitude fall back to a 0.2 default. The shared model values are not captured here — pass them asbase_parameterstobuild_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 < 1each recorded count survives independently with probabilityfactor(binomial thinning) — thinning a Poisson process is exactly Poisson, so the result is statistically indistinguishable from a run measured forfactortimes the beam time. Forfactor > 1no exact construction from recorded data exists; the WiMDA conventionPoisson(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) withmetadata["degraded"]provenance. A fixed seed reproduces the resampling bit-for-bit.grouping["good_frames"](and the per-period frame counts) are scaled byfactor— 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 theirperiod_mode(red, green, or the green∓red combinations), and abunching_factorabove 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(seematched_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.0when 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 tosimulate_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_idisNone):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 asexpected_counts():n0_i = N_{i,d}·(1−exp(−Δt/τ_i))so the post-t0 window sum ≈
total_events. Detectors ingroup_id(or all detectors ifNone) carry the signal;background_per_binis added in addition to the event budget. Poisson-sampled withseedand assembled (provenance, deadtime-zeroing) via_sample_and_build_run(). Provenance recordscapture_mode=True, the components/τ and weights, and the seed.- Parameters:
template – Geometry template — bin structure, t0, grouping. Counts are ignored.
components – Ordered sequence of
CaptureComponentobjects (duck-typed: need.labeland.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).
- 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.
- specs: tuple[PresetRunSpec, ...]
- 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 itsmetadata["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.seedoverrides the preset’s fixed base seed when given.Raises
KeyErrorfor 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, orNonewhen 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.
- 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]
- 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_seedsseeds.parametersare the generating (true) values bound intomodelfor every synthetic run;trackselects which of them to collect pulls for (default: all of them — pass the free parameters to skip fixed ones). Each seed simulates a run fromtemplateattotal_eventsmatched statistics, reduces it, optionally restricts it totime_range(the fit window), and refits it via the injectedrefit. Seeds whose refit fails to converge — or returns a non-finite/non-positive error for a tracked parameter — are dropped and counted inn_converged.progress(done, total)is called after each seed when supplied.should_continue()is polled before each seed; returningFalsestops the run early (a cancel button), and the reportedn_seedsis then the number actually attempted, so the converged fraction stays meaningful.