Skip to content

Example Data

Getting started with a fitting package usually means finding a data file first. sans_fitter.examples removes that step in two ways: a curated set of bundled datasets, and a simulator that generates data.

from sans_fitter import examples

examples.describe()                       # what's available
data = examples.load('silica_spheres')    # fit-ready Data1D
fitter = examples.load_fitter('silica_spheres')   # data + model + parameters
result = fitter.fit()

Bundled datasets

These are the same example datasets SasView ships. They are not vendored into this repository — they live inside the installed sasdata package, which is already a hard dependency. That keeps the wheel small and the collection in sync with sasdata.

examples.describe() prints the whole collection:

Name Model What it is good for
cylinder cylinder Noise-free calculated cylinder, R=20 Å, L=400 Å. A verification reference, not a fitting exercise (see the note below).
sphere sphere 100 nm spheres, dI but no dQ.
sphere_smeared sphere The same spheres with dQ — pair the two to see what resolution smearing does.
polydisperse_spheres sphere Fit with polydispersity off, then on, and watch the residuals collapse.
silica_spheres sphere Measured Ludox colloidal silica, with both dI and dQ.
sds_micelles ellipsoid Measured charged SDS micelles — needs the hayter_msa structure factor.
sds_micelles_salt ellipsoid The same micelles with 0.2 M NaCl; the salt screens the charge, so hardsphere suffices.
core_shell core_shell_sphere Partly degenerate core radius and shell thickness — a lesson in correlated parameters.
polymer_micelles ellipsoid Measured 10% Pluronic P123, with a clear structure-factor peak.
protein sphere Measured apoferritin: ~400 points, flat incoherent background, noisy high-Q tail.
canSAS_xml sphere Measured SANS2D data as canSAS-1D XML.
nxcanSAS_h5 sphere The identical measurement as NXcanSAS HDF5.

Filter by tag to find one of a given kind:

examples.list_examples(tag='measured')          # real instrument data
examples.list_examples(tag='structure-factor')  # concentrated samples
examples.list_examples(tag='resolution')        # datasets carrying dQ

examples.describe('name') prints the full detail for one entry, including live facts read from the file — point count, Q range, and whether dI and dQ are present:

silica_spheres
==============
Measured Ludox colloidal silica. Carries both dI and dQ, so it exercises
weighted fitting and resolution smearing on real data.

  file              Ludox_silica.xml
  model             sphere
  polydispersity    radius
  tags              measured, colloid, resolution
  points            92
  Q range           0.0071429 to 0.25169 1/A
  dI column         yes
  dQ column         yes

Presets

load_fitter() returns a SANSFitter with the data, model, structure factor, polydispersity and starting parameters already set:

fitter = examples.load_fitter('sds_micelles')
result = fitter.fit()
fitter.plot_results()

The starting values are coarse — chosen to put the optimiser in the right basin, not published results. Check examples.get_example(name).truth to see whether ground truth is known at all; for measured data it is None, because there is none.

cylinder is a verification dataset, not a fitting exercise

cyl_400_20.txt is a noise-free calculation: evaluating the model at its truth reproduces all 20 points exactly, which makes it the right thing to check the model pipeline against. It cannot be fitted, though — it has no dI column, so the bumps engine refuses it, and its intensity spans five decades unweighted, which the scipy engine cannot descend. For a cylinder you can actually fit, use simulate('cylinder', radius=20, length=400).

Simulated data

examples.simulate() computes a dataset from any sasmodels model and attaches the generating parameters as data.truth:

data = examples.simulate('sphere', radius=50, noise=0.02, seed=0)
data.truth['radius']    # 50.0

This is often the better teaching tool: you can state the answer up front and have the reader check that the fit recovers it. It works for any of the ~100 sasmodels models, needs no files, and lets you dial noise, Q range and resolution independently.

# wider Q range, more points, 5% noise
examples.simulate('cylinder', radius=20, length=400,
                  qmin=0.001, qmax=0.7, npoints=200, noise=0.05)

# with instrument resolution — the intensity is smeared, not just labelled
examples.simulate('sphere', radius=50, dq=0.05)

# polydisperse; the _pd_n/_pd_type companions are filled in for you
examples.simulate('sphere', radius=50, radius_pd=0.15)

# onto the Q grid of a real dataset
real = examples.load('silica_spheres')
examples.simulate('sphere', radius=100, q=real.x)

How the noise is generated

Uncertainties follow counting statistics, scaled so a point at the median intensity gets exactly the requested relative error:

dI = noise * sqrt(|I| * median(|I|))

The scatter is drawn from that same width, so the error bars honestly describe the noise and reduced χ² lands near 1 for a correct model.

A purely relative dI = noise * I would be simpler but is wrong here: it drives the uncertainty to zero inside the form-factor minima, where the intensity itself goes to zero. Those points then carry runaway weight and pull the fit away from the truth — measurably so, a simulated 50 Å sphere recovers as 88 Å. Counting statistics keep the absolute uncertainty from collapsing while letting the relative uncertainty grow in the dim minima and the high-Q tail, which is what real SANS data does.

Sample and background pairs

simulate_pair() returns a matched sample and background on an identical Q grid, which is what data_ops requires:

from sans_fitter import SANSFitter, data_ops, examples

sample, background = examples.simulate_pair('sphere', radius=50,
                                            background_level=0.5)
subtracted = data_ops.subtract(sample, background)

fitter = SANSFitter()
fitter.set_data(subtracted)
fitter.set_model('sphere')
fitter.set_param('radius', value=30, min=5, max=300)
result = fitter.fit()      # recovers radius ≈ 50

If the bundled files are missing

load() raises FileNotFoundError naming the expected location if your sasdata build excludes its example data or has moved it. simulate() needs no files at all and is unaffected.

API

sans_fitter.examples

Example datasets and simulated data (issue #53).

Two complementary ways to get data without hunting for a file:

Bundled example datasets — the same collection SasView ships, curated here with the model that fits each one and sensible starting parameters::

>>> from sans_fitter import examples
>>> examples.describe()                        # what is available
>>> data = examples.load('silica_spheres')     # fit-ready Data1D
>>> fitter = examples.load_fitter('silica_spheres')  # data + model + parameters
>>> result = fitter.fit()

The files themselves are not vendored into this package. They live inside the installed sasdata distribution (sasdata/example_data/1d_data), which is already a hard dependency, so the set stays in sync with sasdata and costs nothing to ship. :func:load resolves them through :mod:importlib.resources and raises an actionable error if the layout ever changes.

Simulated data — computed on demand from any sasmodels model, with known ground truth::

>>> data = examples.simulate('sphere', radius=50, noise=0.03, seed=0)
>>> data.truth
{'radius': 50.0, 'sld': 1.0, ...}

Simulated data is the better teaching tool when you want a self-checking exercise ("fit this, you should recover radius = 50"), works for any of the ~100 sasmodels models, and needs no files on disk. Real bundled data is the better tool for everything a simulation will not show you: instrument resolution, sloping backgrounds, noisy high-Q tails and negative intensities after subtraction.

Both routes return a fit-ready Data1Dqmin/qmax/mask set — that can be handed straight to :meth:SANSFitter.set_data or to :mod:sans_fitter.data.ops.

Example dataclass

A curated bundled dataset and how to fit it.

Attributes:

Name Type Description
name str

Short key used by :func:load and :func:load_fitter.

filename str

File name within the sasdata 1D example directory.

model str

sasmodels model name that describes this sample.

description str

What the sample is and what it is useful for teaching.

params dict[str, dict[str, Any]]

Suggested starting configuration, as {param_name: {'value': ..., 'min': ..., 'max': ..., 'vary': ...}}. Passed verbatim to :meth:SANSFitter.set_param.

structure_factor str | None

Structure factor to apply, if the sample is concentrated enough to need one.

polydispersity dict[str, dict[str, Any]]

{param_name: {'pd_width': ..., 'vary': ...}}, passed to :meth:SANSFitter.set_pd_param.

truth dict[str, float] | None

Generating parameters, for simulated files only. None for measured data, where no ground truth exists.

notes str

Caveats worth knowing before fitting — engine restrictions, known difficulties. Empty when there are none.

tags tuple[str, ...]

Free-form labels for filtering with :func:list_examples.

source str

Provenance note.

Source code in src/sans_fitter/examples.py
@dataclass(frozen=True)
class Example:
    """A curated bundled dataset and how to fit it.

    Attributes:
        name: Short key used by :func:`load` and :func:`load_fitter`.
        filename: File name within the sasdata 1D example directory.
        model: sasmodels model name that describes this sample.
        description: What the sample is and what it is useful for teaching.
        params: Suggested starting configuration, as
            ``{param_name: {'value': ..., 'min': ..., 'max': ..., 'vary': ...}}``.
            Passed verbatim to :meth:`SANSFitter.set_param`.
        structure_factor: Structure factor to apply, if the sample is
            concentrated enough to need one.
        polydispersity: ``{param_name: {'pd_width': ..., 'vary': ...}}``,
            passed to :meth:`SANSFitter.set_pd_param`.
        truth: Generating parameters, for simulated files only. ``None`` for
            measured data, where no ground truth exists.
        notes: Caveats worth knowing before fitting — engine restrictions,
            known difficulties. Empty when there are none.
        tags: Free-form labels for filtering with :func:`list_examples`.
        source: Provenance note.
    """

    name: str
    filename: str
    model: str
    description: str
    params: dict[str, dict[str, Any]] = field(default_factory=dict)
    structure_factor: str | None = None
    polydispersity: dict[str, dict[str, Any]] = field(default_factory=dict)
    truth: dict[str, float] | None = None
    notes: str = ''
    tags: tuple[str, ...] = ()
    source: str = 'sasdata example_data'

list_examples(tag=None)

Return the names of the bundled examples, optionally filtered by tag.

Parameters:

Name Type Description Default
tag str | None

Only return examples carrying this tag (e.g. 'measured', 'simulated', 'structure-factor', 'polydispersity', 'resolution').

None

Returns:

Type Description
list[str]

Sorted example names.

Source code in src/sans_fitter/examples.py
def list_examples(tag: str | None = None) -> list[str]:
    """Return the names of the bundled examples, optionally filtered by *tag*.

    Args:
        tag: Only return examples carrying this tag (e.g. ``'measured'``,
            ``'simulated'``, ``'structure-factor'``, ``'polydispersity'``,
            ``'resolution'``).

    Returns:
        Sorted example names.
    """
    names = sorted(_REGISTRY)
    if tag is None:
        return names
    return [name for name in names if tag in _REGISTRY[name].tags]

describe(name=None)

Print a human-readable summary of the bundled examples.

Parameters:

Name Type Description Default
name str | None

Print the full detail for a single example. When omitted, print a one-line-per-example overview of the whole collection.

None
Source code in src/sans_fitter/examples.py
def describe(name: str | None = None) -> None:
    """Print a human-readable summary of the bundled examples.

    Args:
        name: Print the full detail for a single example. When omitted, print a
            one-line-per-example overview of the whole collection.
    """
    if name is not None:
        _describe_one(get_example(name))
        return

    print(f'{len(_REGISTRY)} bundled example datasets (from the installed sasdata package)')
    print()
    width = max(len(key) for key in _REGISTRY)
    for key in sorted(_REGISTRY):
        example = _REGISTRY[key]
        print(f'  {key:<{width}}  {example.model:<18}  {_summarize(example.description)}')
    print()
    print("Load one with examples.load('name') or examples.load_fitter('name');")
    print("see full detail with examples.describe('name').")

get_example(name)

Return the :class:Example record for name.

Raises:

Type Description
KeyError

If name is not a known example.

Source code in src/sans_fitter/examples.py
def get_example(name: str) -> Example:
    """Return the :class:`Example` record for *name*.

    Raises:
        KeyError: If *name* is not a known example.
    """
    try:
        return _REGISTRY[name]
    except KeyError:
        available = ', '.join(sorted(_REGISTRY))
        raise KeyError(f"Unknown example '{name}'. Available: {available}") from None

example_path(name)

Return the filesystem path of a bundled example file.

Useful when you want to pass the file to :func:sans_fitter.data.ops.load or to any other reader yourself.

Raises:

Type Description
KeyError

If name is not a known example.

FileNotFoundError

If the file is missing from the sasdata install.

Source code in src/sans_fitter/examples.py
def example_path(name: str) -> str:
    """Return the filesystem path of a bundled example file.

    Useful when you want to pass the file to :func:`sans_fitter.data.ops.load`
    or to any other reader yourself.

    Raises:
        KeyError: If *name* is not a known example.
        FileNotFoundError: If the file is missing from the sasdata install.
    """
    example = get_example(name)
    path = _example_dir() / example.filename
    if not path.is_file():
        raise FileNotFoundError(
            f"Example '{name}' expects the file '{example.filename}' in the "
            f'sasdata example directory, but it is not there. The installed '
            f'sasdata version may have renamed or removed it.'
        )
    return str(path)

load(name)

Load a bundled example dataset and return a fit-ready Data1D.

Goes through the same loader as :meth:SANSFitter.load_data, so the result behaves identically to any dataset you load yourself.

Parameters:

Name Type Description Default
name str

Example name — see :func:list_examples.

required

Returns:

Type Description
Data1D

A Data1D with qmin/qmax/mask set.

Raises:

Type Description
KeyError

If name is not a known example.

FileNotFoundError

If the file is missing from the sasdata install.

ValueError

If the file cannot be parsed.

Source code in src/sans_fitter/examples.py
def load(name: str) -> Data1D:
    """Load a bundled example dataset and return a fit-ready ``Data1D``.

    Goes through the same loader as :meth:`SANSFitter.load_data`, so the result
    behaves identically to any dataset you load yourself.

    Args:
        name: Example name — see :func:`list_examples`.

    Returns:
        A ``Data1D`` with ``qmin``/``qmax``/``mask`` set.

    Raises:
        KeyError: If *name* is not a known example.
        FileNotFoundError: If the file is missing from the sasdata install.
        ValueError: If the file cannot be parsed.
    """
    return load_sans_data(example_path(name))

load_fitter(name, quiet=True)

Return a :class:SANSFitter preloaded with an example and ready to fit.

Sets the data, the model, any structure factor and polydispersity, and the suggested starting parameters — so a tutorial reaches fitter.fit() in one line::

>>> fitter = examples.load_fitter('silica_spheres')
>>> result = fitter.fit()

The starting parameters are coarse values chosen to put the model in the right basin, not published results. Check get_example(name).truth to see whether ground truth is known.

Parameters:

Name Type Description Default
name str

Example name — see :func:list_examples.

required
quiet bool

Suppress the progress messages that set_data/set_model normally print, so the preset is a single quiet step. Pass False to see them.

True

Returns:

Type Description
SANSFitter

A configured SANSFitter.

Source code in src/sans_fitter/examples.py
def load_fitter(name: str, quiet: bool = True) -> SANSFitter:
    """Return a :class:`SANSFitter` preloaded with an example and ready to fit.

    Sets the data, the model, any structure factor and polydispersity, and the
    suggested starting parameters — so a tutorial reaches ``fitter.fit()`` in
    one line::

        >>> fitter = examples.load_fitter('silica_spheres')
        >>> result = fitter.fit()

    The starting parameters are coarse values chosen to put the model in the
    right basin, not published results. Check ``get_example(name).truth`` to see
    whether ground truth is known.

    Args:
        name: Example name — see :func:`list_examples`.
        quiet: Suppress the progress messages that ``set_data``/``set_model``
            normally print, so the preset is a single quiet step. Pass ``False``
            to see them.

    Returns:
        A configured ``SANSFitter``.
    """
    example = get_example(name)
    data = load(name)

    with _maybe_silenced(quiet):
        fitter = SANSFitter()
        fitter.set_data(data)
        fitter.set_model(example.model)

        if example.structure_factor is not None:
            fitter.set_structure_factor(example.structure_factor)

        for param_name, settings in example.params.items():
            fitter.set_param(param_name, **settings)

        if example.polydispersity:
            fitter.enable_polydispersity(True)
            for param_name, settings in example.polydispersity.items():
                fitter.set_pd_param(param_name, **settings)

    return fitter

simulate(model='sphere', qmin=0.005, qmax=0.5, npoints=100, noise=0.02, seed=0, dq=None, q=None, **params)

Simulate a SANS dataset from any sasmodels model, with known truth.

The generating parameters are attached to the result as data.truth, so a tutorial can state the answer up front and the reader can check whether the fit recovers it.

Parameters:

Name Type Description Default
model str

sasmodels model name, e.g. 'sphere', 'cylinder', 'core_shell_sphere'. Product models such as 'sphere@hardsphere' work too.

'sphere'
qmin float

Lowest Q, in 1/A. Ignored when q is given.

0.005
qmax float

Highest Q, in 1/A. Ignored when q is given.

0.5
npoints int

Number of log-spaced Q points. Ignored when q is given.

100
noise float

Relative noise level. 0.02 gives a point at the median intensity a 2% error bar; uncertainties follow counting statistics (dI = noise * sqrt(|I| * median|I|)), so the relative error grows in the dim form-factor minima and the high-Q tail as it does in real data. Pass 0 for noise-free data — note that the bumps engine refuses data without uncertainties.

0.02
seed int | None

Seed for the noise, so results are reproducible. Pass None for fresh noise on every call.

0
dq float | None

Relative resolution width. When given, dx = dq * q is attached and the simulated intensity is smeared accordingly, matching what an instrument would measure.

None
q ndarray | None

Explicit Q array, overriding qmin/qmax/npoints. Use this to simulate onto the grid of a real dataset.

None
**params Any

Model parameters, e.g. radius=50, sld=4.0. Anything unspecified keeps its sasmodels default. A polydispersity width such as radius_pd=0.15 is enough on its own — the companion _pd_n/_pd_type/_pd_nsigma settings are filled from :data:~sans_fitter.polydispersity.PD_DEFAULTS, because sasmodels silently ignores a width with no _pd_n.

{}

Returns:

Type Description
Data1D

A fit-ready Data1D with an extra truth attribute holding the

Data1D

full parameter set used to generate it.

Raises:

Type Description
ValueError

If the model name is unknown, a parameter is not valid for the model, or the Q range is not positive and increasing.

Example

data = simulate('sphere', radius=50, noise=0.03, seed=1) data.truth['radius'] 50.0

Source code in src/sans_fitter/examples.py
def simulate(
    model: str = 'sphere',
    qmin: float = 0.005,
    qmax: float = 0.5,
    npoints: int = 100,
    noise: float = 0.02,
    seed: int | None = 0,
    dq: float | None = None,
    q: np.ndarray | None = None,
    **params: Any,
) -> Data1D:
    """Simulate a SANS dataset from any sasmodels model, with known truth.

    The generating parameters are attached to the result as ``data.truth``, so
    a tutorial can state the answer up front and the reader can check whether
    the fit recovers it.

    Args:
        model: sasmodels model name, e.g. ``'sphere'``, ``'cylinder'``,
            ``'core_shell_sphere'``. Product models such as
            ``'sphere@hardsphere'`` work too.
        qmin: Lowest Q, in 1/A. Ignored when *q* is given.
        qmax: Highest Q, in 1/A. Ignored when *q* is given.
        npoints: Number of log-spaced Q points. Ignored when *q* is given.
        noise: Relative noise level. ``0.02`` gives a point at the median
            intensity a 2% error bar; uncertainties follow counting statistics
            (``dI = noise * sqrt(|I| * median|I|)``), so the relative error
            grows in the dim form-factor minima and the high-Q tail as it does
            in real data. Pass ``0`` for noise-free data — note that the bumps
            engine refuses data without uncertainties.
        seed: Seed for the noise, so results are reproducible. Pass ``None``
            for fresh noise on every call.
        dq: Relative resolution width. When given, ``dx = dq * q`` is attached
            and the *simulated intensity is smeared accordingly*, matching what
            an instrument would measure.
        q: Explicit Q array, overriding *qmin*/*qmax*/*npoints*. Use this to
            simulate onto the grid of a real dataset.
        **params: Model parameters, e.g. ``radius=50``, ``sld=4.0``. Anything
            unspecified keeps its sasmodels default. A polydispersity width
            such as ``radius_pd=0.15`` is enough on its own — the companion
            ``_pd_n``/``_pd_type``/``_pd_nsigma`` settings are filled from
            :data:`~sans_fitter.polydispersity.PD_DEFAULTS`, because sasmodels
            silently ignores a width with no ``_pd_n``.

    Returns:
        A fit-ready ``Data1D`` with an extra ``truth`` attribute holding the
        full parameter set used to generate it.

    Raises:
        ValueError: If the model name is unknown, a parameter is not valid for
            the model, or the Q range is not positive and increasing.

    Example:
        >>> data = simulate('sphere', radius=50, noise=0.03, seed=1)
        >>> data.truth['radius']
        50.0
    """
    q_values = _build_q(q, qmin, qmax, npoints)

    if not np.isfinite(noise) or noise < 0:
        raise ValueError(f'noise must be non-negative and finite, got {noise}.')
    if dq is not None and (not np.isfinite(dq) or dq < 0):
        raise ValueError(f'dq must be non-negative and finite, got {dq}.')

    try:
        kernel = load_model(model, dtype='single', platform='dll')
    except Exception as exc:
        raise ValueError(f"Failed to load model '{model}': {exc}") from exc

    defaults = _model_defaults(kernel)
    unknown = [
        key for key in params if key not in defaults and not _is_polydispersity_key(key, defaults)
    ]
    if unknown:
        raise ValueError(
            f'Parameter(s) {", ".join(sorted(unknown))} are not valid for model '
            f"'{model}'. Valid parameters: {', '.join(sorted(defaults))}."
        )

    params = _complete_polydispersity(params)

    resolution = None if dq is None else np.asarray(q_values) * float(dq)
    template = normalize_sans_data(
        Data1D(
            x=q_values,
            y=np.zeros_like(q_values),
            dy=np.zeros_like(q_values),
            dx=resolution,
        )
    )

    calculator = DirectModel(template, kernel)
    intensity = np.asarray(calculator(**params), dtype=float)

    intensity, uncertainty = _apply_noise(intensity, noise, seed)

    data = normalize_sans_data(Data1D(x=q_values, y=intensity, dy=uncertainty, dx=resolution))
    # Record what generated this dataset. `truth` merges the explicit arguments
    # over the model defaults, so it is the complete parameter set, not just
    # what the caller happened to pass.
    data.truth = {**defaults, **params}
    data.filename = f'simulated_{model}'
    return data

simulate_pair(model='sphere', background_level=0.5, noise=0.02, seed=0, **kwargs)

Simulate a matched sample and background pair for dataset arithmetic.

Both datasets land on an identical Q grid, which is what :mod:sans_fitter.data.ops requires — the sample is model + flat background, and the background dataset is that flat level alone::

>>> sample, background = simulate_pair('sphere', radius=50)
>>> subtracted = data_ops.subtract(sample, background)
>>> fitter = SANSFitter()
>>> fitter.set_data(subtracted)

Parameters:

Name Type Description Default
model str

sasmodels model name for the sample.

'sphere'
background_level float

Flat intensity added to the sample and carried by the background dataset. An explicit background= model parameter in **kwargs is treated as part of the sample's signal, not the flat level: it is added on top of background_level in the sample only, so it survives subtract(sample, background).

0.5
noise float

Relative Gaussian noise, applied independently to each dataset.

0.02
seed int | None

Seed for reproducibility. The background uses seed + 1 so the two datasets do not share identical noise.

0
**kwargs Any

Forwarded to :func:simulate — Q range, dq, and model parameters.

{}

Returns:

Type Description
tuple[Data1D, Data1D]

(sample, background), both fit-ready and on the same Q grid.

Source code in src/sans_fitter/examples.py
def simulate_pair(
    model: str = 'sphere',
    background_level: float = 0.5,
    noise: float = 0.02,
    seed: int | None = 0,
    **kwargs: Any,
) -> tuple[Data1D, Data1D]:
    """Simulate a matched sample and background pair for dataset arithmetic.

    Both datasets land on an identical Q grid, which is what
    :mod:`sans_fitter.data.ops` requires — the sample is *model + flat
    background*, and the background dataset is that flat level alone::

        >>> sample, background = simulate_pair('sphere', radius=50)
        >>> subtracted = data_ops.subtract(sample, background)
        >>> fitter = SANSFitter()
        >>> fitter.set_data(subtracted)

    Args:
        model: sasmodels model name for the sample.
        background_level: Flat intensity added to the sample and carried by the
            background dataset. An explicit ``background=`` model parameter in
            ``**kwargs`` is treated as part of the sample's signal, not the
            flat level: it is added on top of ``background_level`` in the
            sample only, so it survives ``subtract(sample, background)``.
        noise: Relative Gaussian noise, applied independently to each dataset.
        seed: Seed for reproducibility. The background uses ``seed + 1`` so the
            two datasets do not share identical noise.
        **kwargs: Forwarded to :func:`simulate` — Q range, ``dq``, and model
            parameters.

    Returns:
        ``(sample, background)``, both fit-ready and on the same Q grid.
    """
    params = dict(kwargs)
    params['background'] = params.get('background', 0.0) + background_level

    sample = simulate(model, noise=noise, seed=seed, **params)

    # 'empty' reproduces the flat level alone: same Q grid, scale zeroed so only
    # `background` survives.
    empty_params = {key: value for key, value in params.items() if _is_grid_kwarg(key)}
    background = simulate(
        model,
        noise=noise,
        seed=None if seed is None else seed + 1,
        scale=0.0,
        background=background_level,
        **empty_params,
    )

    # data_ops names results after their operands, so distinct labels keep the
    # provenance trail readable ('sample - background', not 'x - x').
    sample.filename = f'simulated_{model}_sample'
    sample.title = sample.filename
    background.filename = f'simulated_{model}_background'
    background.title = background.filename
    return sample, background