Skip to content

niess.tof

tof

Set up a tof.Model from an instrument niess emitted.

tof (from the scipp developers) is a lightweight straight-line Monte Carlo for chopper cascade diagrams. It needs the same description of a chopper train that niess.chopcalc already extracts for chopper-lib, with one difference: chopcalc emits parameter names, so a band recomputes at run time, while tof configures one specific machine and needs numbers. So this evaluates them, and says afterwards which ones it used.

from mccode_antlr import Flavor
from mccode_antlr.assembler import Assembler
from niess.teaching import Primary
import niess.tof

assembler = Assembler('teaching', flavor=Flavor.MCSTAS)
Primary.from_calibration().to_mccode(assembler)

setup = niess.tof.to_tof_model(assembler)
setup                      # in a notebook: what it used, and what you may override
setup.model.run().plot()

Note that import tof inside this package is an absolute import and reaches the scipp package, not niess.tof; every use goes through components._tof so it is never in doubt.

Modules:

  • components

    Walk an emitted instrument and build the pieces of a tof.Model.

  • mapping

    The numbers a tof.Chopper wants, from the way niess describes a disc.

  • parameters

    Instrument parameters as numbers, and a record of which ones were used.

  • registry

    Builder lookup for the tof target.

Classes:

  • TofSetup

    A ready-to-run tof.Model, and what went into it.

  • ChopperSpec

    One tof.Chopper, as plain numbers, before any tof object exists.

  • ParameterValues

    The values to evaluate expressions against, and a note of what got used.

  • Use

    One instrument parameter the model depended on.

  • NiessTofRegistry

    Three-tier builder lookup: niess source type, niess role, McCode type.

Functions:

  • to_tof_model

    Build a ready-to-run tof.Model from an assembled instrument.

  • delay_to_phase

    A disc's delay in seconds, as the phase angle in degrees tof wants.

  • spec_from_windows

    A spec from the windows niess.chopcalc extracts.

TofSetup dataclass

TofSetup(model: Any, source: Any, choppers: tuple[ChopperSpec, ...], detectors: tuple[str, ...], parameters: tuple[Use, ...], excluded: tuple[Any, ...] = (), notes: tuple[str, ...] = (), _rebuild: Any = None)

A ready-to-run tof.Model, and what went into it.

Displaying this in a notebook answers "what do I need to provide?" -- which, for an instrument niess built, is usually nothing: every chopper knob is declared with the calibration's own value as its default. The knobs are listed anyway, because knowing which ones exist is the point of asking.

Methods:

  • with_values

    The same instrument again, with these instrument parameters replaced.

with_values

with_values(**overrides) -> 'TofSetup'

The same instrument again, with these instrument parameters replaced.

Source code in src/niess/tof/components.py
def with_values(self, **overrides) -> 'TofSetup':
    """The same instrument again, with these instrument parameters replaced."""
    if self._rebuild is None:
        raise RuntimeError('this setup was not built from an instrument')
    return self._rebuild(overrides)

ChopperSpec dataclass

ChopperSpec(name: str, frequency: float, anticlockwise: bool, open: tuple[float, ...], close: tuple[float, ...], phase: float, distance: float)

One tof.Chopper, as plain numbers, before any tof object exists.

Methods:

  • to_tof

    The tof.Chopper itself.

Attributes:

  • frequency (float) –

    Hz, never negative -- tof carries the direction separately.

  • open (tuple[float, ...]) –

    Degrees from the beam, one per opening, in tof's sense.

  • phase (float) –

    Degrees.

  • distance (float) –

    Metres along the beam. tof measures from the same zero as its source, so this

frequency instance-attribute

frequency: float

Hz, never negative -- tof carries the direction separately.

open instance-attribute

open: tuple[float, ...]

Degrees from the beam, one per opening, in tof's sense.

phase instance-attribute

phase: float

Degrees.

distance instance-attribute

distance: float

Metres along the beam. tof measures from the same zero as its source, so this is the source's own distance plus the path walked to the disc.

to_tof

to_tof()

The tof.Chopper itself.

Source code in src/niess/tof/mapping.py
def to_tof(self):
    """The ``tof.Chopper`` itself."""
    import scipp as sc

    from .components import _tof

    tof = _tof()
    return tof.Chopper(
        frequency=sc.scalar(self.frequency, unit='Hz'),
        open=sc.array(dims=['cutout'], values=list(self.open), unit='deg'),
        close=sc.array(dims=['cutout'], values=list(self.close), unit='deg'),
        phase=sc.scalar(self.phase, unit='deg'),
        distance=sc.scalar(self.distance, unit='m'),
        name=self.name,
        direction=tof.AntiClockwise if self.anticlockwise else tof.Clockwise,
    )

ParameterValues

ParameterValues(instrument, overrides: dict[str, float] | None = None)

The values to evaluate expressions against, and a note of what got used.

Methods:

  • evaluate

    An expression as a number, or None when it does not fold to one.

  • evaluate_text

    A chopcalc field -- C text naming instrument parameters -- as a number.

  • number

    A component parameter as a number, recording what it depended on.

  • uses

    What the model read, in the order the instrument declares it.

Source code in src/niess/tof/parameters.py
def __init__(self, instrument, overrides: dict[str, float] | None = None):
    self.instrument = instrument
    self.defaults = instrument_defaults(instrument)
    self.units = instrument_units(instrument)
    overrides = {} if overrides is None else dict(overrides)
    unknown = set(overrides) - {p.name for p in instrument.parameters}
    if unknown:
        raise ValueError(
            f'{sorted(unknown)} are not instrument parameters of '
            f'{instrument.name!r}; it has {sorted(p.name for p in instrument.parameters)}'
        )
    self.overrides = {name: self._as_declared(name, value)
                      for name, value in overrides.items()}
    self.values = {**self.defaults, **self.overrides}
    self._uses: dict[str, set[str]] = {}

evaluate

evaluate(expression, *, used_by: str | None = None) -> float | None

An expression as a number, or None when it does not fold to one.

Source code in src/niess/tof/parameters.py
def evaluate(self, expression, *, used_by: str | None = None) -> float | None:
    """An expression as a number, or ``None`` when it does not fold to one."""
    if expression is None:
        return None
    try:
        return float(expression)
    except (TypeError, ValueError):
        pass

    for name in self.values:
        if _depends_on(expression, name):
            self._uses.setdefault(name, set()).add(used_by or '?')

    folded = _fold(expression, self.values)
    if folded is None:
        return None
    try:
        return expr_float(folded)
    except (TypeError, ValueError):
        return None

evaluate_text

evaluate_text(text, *, used_by: str | None = None) -> float | None

A chopcalc field -- C text naming instrument parameters -- as a number.

chopcalc writes C so a band recomputes at run time, and everything it writes happens to parse as a McCode expression, the conditional it emits for a run-time phase included. So the train it extracts can be reused whole rather than the instrument walked a second time.

Source code in src/niess/tof/parameters.py
def evaluate_text(self, text, *, used_by: str | None = None) -> float | None:
    """A `chopcalc` field -- C text naming instrument parameters -- as a number.

    chopcalc writes C so a band recomputes at run time, and everything it writes
    happens to parse as a McCode expression, the conditional it emits for a run-time
    phase included. So the train it extracts can be reused whole rather than the
    instrument walked a second time.
    """
    from mccode_antlr.common.expression import Expr

    if text is None:
        return None
    try:
        return float(text)
    except (TypeError, ValueError):
        pass
    try:
        expression = Expr.parse(str(text))
    except Exception:
        return None
    return self.evaluate(expression, used_by=used_by)

number

number(instance, name: str, *, default: float | None = None)

A component parameter as a number, recording what it depended on.

Source code in src/niess/tof/parameters.py
def number(self, instance, name: str, *, default: float | None = None):
    """A component parameter as a number, recording what it depended on."""
    parameter = instance.get_parameter(name)
    if parameter is None:
        return default
    value = self.evaluate(parameter.value, used_by=f'{instance.name}.{name}')
    return default if value is None else value

uses

uses() -> tuple[Use, ...]

What the model read, in the order the instrument declares it.

Source code in src/niess/tof/parameters.py
def uses(self) -> tuple[Use, ...]:
    """What the model read, in the order the instrument declares it."""
    found = []
    for parameter in self.instrument.parameters:
        if parameter.name not in self._uses:
            continue
        value = self.values.get(parameter.name)
        found.append(Use(
            name=parameter.name,
            value=_as_float(value),
            default=_as_float(self.defaults.get(parameter.name)),
            unit=self.units.get(parameter.name),
            overridden=parameter.name in self.overrides,
            used_by=tuple(sorted(self._uses[parameter.name])),
        ))
    return tuple(found)

Use dataclass

Use(name: str, value: float, default: float | None, unit: str | None, overridden: bool, used_by: tuple[str, ...])

One instrument parameter the model depended on.

Attributes:

  • overridden (bool) –

    Whether the caller supplied it, rather than the instrument's own default.

  • used_by (tuple[str, ...]) –

    Where it was read, as component.parameter, for the report.

overridden instance-attribute

overridden: bool

Whether the caller supplied it, rather than the instrument's own default.

used_by instance-attribute

used_by: tuple[str, ...]

Where it was read, as component.parameter, for the report.

NiessTofRegistry

NiessTofRegistry(parent: 'NiessRegistry[B] | None' = None)

Bases: NiessRegistry[TofBuilder]

Three-tier builder lookup: niess source type, niess role, McCode type.

As everywhere else, resolve_builder returning None means nothing is registered, while a builder returning None means it ran and declined -- which is how an opening folded into its disc says so.

Source code in src/niess/dispatch.py
def __init__(self, parent: 'NiessRegistry[B] | None' = None) -> None:
    self.parent = parent
    self._source_type_builders: dict[str, B] = {}
    self._role_builders: dict[str, B] = {}
    self._component_type_builders: dict[str, B] = {}

to_tof_model

to_tof_model(obj, *, source=None, values=None, neutrons: int = 1000000, pulses: int | None = None, seed: int | None = None, sample: str | None = None, source_name: str | None = None, skip=(), path_lengths=None, graph=None, registry=None) -> TofSetup

Build a ready-to-run tof.Model from an assembled instrument.

Parameters:

  • obj

    An Assembler or an Instr. A top-level assembler: a child from assembler.included(...) merges into its parent only when the block exits, so its components -- and every later section's -- are not visible yet.

  • source

    A tof.Source to use instead of building one. Building one downloads the facility's pulse profile on first use, so pass your own to stay offline.

  • values

    Instrument parameter values to use instead of the instrument's own defaults. A scipp scalar is converted to whatever unit the instrument declares, so a speed worked out in kHz or a delay in ms can be handed over as it comes.

  • neutrons (int, default: 1000000 ) –

    How many to sample from each pulse.

  • pulses (int | None, default: None ) –

    How many source pulses to simulate. More than one is what shows a chopper turning at a fraction of the source frequency doing its job: a disc at half of 14 Hz opens for every other pulse and absorbs the rest, which a single pulse cannot show. Taken from the source when omitted.

  • seed (int | None, default: None ) –

    Fixes the sampling, so two runs can be compared rather than merely resembling each other.

  • sample (str | None, default: None ) –

    The component to put a detector on at the end of the beam. Found from the beam path when omitted.

  • graph

    The particle flow through the instrument, as a networkx DiGraph. Every distance here is walked along it, and McCode has no way to say that a beam branches -- so an instrument whose flow is not the order its components are declared in, BIFROST after the sample among them, has to be handed the real one. Built from the instrument when omitted.

Source code in src/niess/tof/components.py
def to_tof_model(obj, *, source=None, values=None, neutrons: int = 1_000_000,
                 pulses: int | None = None, seed: int | None = None,
                 sample: str | None = None, source_name: str | None = None,
                 skip=(), path_lengths=None, graph=None, registry=None) -> TofSetup:
    """Build a ready-to-run ``tof.Model`` from an assembled instrument.

    Parameters
    ----------
    obj:
        An ``Assembler`` or an ``Instr``. A **top-level** assembler: a child from
        ``assembler.included(...)`` merges into its parent only when the block exits, so
        its components -- and every later section's -- are not visible yet.
    source:
        A ``tof.Source`` to use instead of building one. Building one downloads the
        facility's pulse profile on first use, so pass your own to stay offline.
    values:
        Instrument parameter values to use instead of the instrument's own defaults.
        A scipp scalar is converted to whatever unit the instrument declares, so a speed
        worked out in kHz or a delay in ms can be handed over as it comes.
    neutrons:
        How many to sample from each pulse.
    pulses:
        How many source pulses to simulate. More than one is what shows a chopper turning
        at a fraction of the source frequency doing its job: a disc at half of 14 Hz opens
        for every other pulse and absorbs the rest, which a single pulse cannot show.
        Taken from the source when omitted.
    seed:
        Fixes the sampling, so two runs can be compared rather than merely resembling
        each other.
    sample:
        The component to put a detector on at the end of the beam. Found from the beam
        path when omitted.
    graph:
        The particle flow through the instrument, as a ``networkx`` DiGraph. Every
        distance here is walked along it, and McCode has no way to say that a beam
        branches -- so an instrument whose flow is not the order its components are
        declared in, BIFROST after the sample among them, has to be handed the real one.
        Built from the instrument when omitted.
    """
    from ..chopcalc.discovery import build_train

    tof = _tof()
    import scipp as sc

    instrument = obj.instrument if hasattr(obj, 'instrument') else obj
    if getattr(obj, 'parent', None) is not None:
        raise ValueError(
            'to_tof_model needs the top-level Assembler, after every section has been '
            'added. A section\'s child Assembler is merged into its parent only on '
            'leaving the included() block, so its components are not visible yet.'
        )

    parameters = ParameterValues(instrument, values)
    graph = instrument.build_flow_graph() if graph is None else graph
    places = positions(instrument)
    source_instance = find_source(instrument, graph, source_name)

    notes = []
    if source is None:
        source, facility = _build_source(instrument, source_instance, parameters, tof,
                                         neutrons=neutrons, pulses=pulses, seed=seed)
        notes.append(f'source pulse from the {facility!r} profile')
    else:
        notes.append('source supplied by the caller')
    origin = float(source.distance.to(unit='m').value)

    excluded = ()
    specs: dict[str, ChopperSpec] = {}
    try:
        train = build_train(instrument, source=source_name, skip=skip,
                            path_lengths=path_lengths, graph=graph)
    except ChopcalcError as error:
        notes.append(f'no chopper train: {error}')
    else:
        excluded = train.excluded
        for entry in train.choppers:
            speed = parameters.evaluate_text(entry.speed, used_by=f'{entry.name}.speed')
            delay = parameters.evaluate_text(entry.delay, used_by=f'{entry.name}.delay')
            windows = [(parameters.evaluate_text(low), parameters.evaluate_text(high))
                       for low, high in entry.windows]
            path = parameters.evaluate_text(entry.path)
            if None in (speed, delay, path) or any(v is None for w in windows for v in w):
                notes.append(f'{entry.name}: left out, its description did not reduce to '
                             f'numbers')
                continue
            specs[entry.name] = spec_from_windows(
                name=entry.name, windows=windows, delay=delay, speed=speed,
                distance=origin + path)

    registry = DEFAULT_TOF_REGISTRY if registry is None else registry
    components, detector_names = [], []
    for instance in instrument.components:
        if instance.name == source_instance.name:
            continue
        builder = registry.resolve_builder(instance)
        if builder is None:
            continue
        provenance = NiessProvenance.from_instance(instance)
        spec = specs.get(instance.name)
        if spec is None and provenance is not None:
            spec = specs.get(provenance.extra.get('nexus_group_id'))
        try:
            distance = origin + beam_path_length(graph, places, source_instance.name,
                                                 instance.name)
        except ChopcalcError as error:
            notes.append(f'{instance.name}: left out, {error}')
            continue
        built = builder(Conversion(instrument=instrument, instance=instance,
                                   values=parameters, distance=distance,
                                   provenance=provenance, spec=spec))
        if built is None:
            continue
        components.append(built)
        if getattr(built, 'kind', None) == 'detector':
            detector_names.append(built.name)

    sample_at = _furthest_measurable(graph, places, source_instance.name,
                                     instrument, sample)
    if sample_at is not None and sample_at not in detector_names:
        try:
            distance = origin + beam_path_length(graph, places, source_instance.name,
                                                 sample_at)
        except ChopcalcError as error:
            notes.append(f'{sample_at}: no sample detector, {error}')
        else:
            components.append(tof.Detector(distance=sc.scalar(distance, unit='m'),
                                           name=sample_at))
            detector_names.append(sample_at)

    model = tof.Model(source=source, components=components)

    def rebuild(overrides):
        merged = {**(values or {}), **overrides}
        return to_tof_model(obj, source=None, values=merged, neutrons=neutrons,
                            pulses=pulses, seed=seed, sample=sample,
                            source_name=source_name, skip=skip,
                            path_lengths=path_lengths, graph=graph, registry=registry)

    return TofSetup(
        model=model, source=source,
        choppers=tuple(specs[name] for name in sorted(specs, key=lambda n: specs[n].distance)),
        detectors=tuple(detector_names),
        parameters=parameters.uses(),
        excluded=tuple(excluded),
        notes=tuple(notes),
        _rebuild=rebuild,
    )

delay_to_phase

delay_to_phase(delay: float, speed: float) -> float

A disc's delay in seconds, as the phase angle in degrees tof wants.

tof has no notion of a delay. An opening is placed by its angle and the whole disc is shifted by phase, which open_close_times adds to every angle before dividing by the angular speed -- so a delay of d seconds is 360 * |speed| * d degrees.

The sign does not flip with the direction of rotation, which is the one thing here worth being suspicious of. A NeXus phase is an angle in the disc's own rotating frame, so its sign does flip: tof.Chopper.from_nexus writes phase = -phase for a negative rotation speed for exactly that reason. A niess delay is a time, and a later time is later whichever way the disc turns.

Checked against Chopper.open_close_times for both directions on an asymmetric three-opening disc: negating this puts every opening somewhere else.

Source code in src/niess/tof/mapping.py
def delay_to_phase(delay: float, speed: float) -> float:
    """A disc's delay in seconds, as the phase angle in degrees ``tof`` wants.

    ``tof`` has no notion of a delay. An opening is placed by its angle and the whole disc
    is shifted by ``phase``, which ``open_close_times`` adds to every angle before dividing
    by the angular speed -- so a delay of ``d`` seconds is ``360 * |speed| * d`` degrees.

    **The sign does not flip with the direction of rotation**, which is the one thing here
    worth being suspicious of. A NeXus phase is an angle in the disc's own rotating frame,
    so its sign *does* flip: ``tof.Chopper.from_nexus`` writes ``phase = -phase`` for a
    negative rotation speed for exactly that reason. A niess delay is a *time*, and a later
    time is later whichever way the disc turns.

    Checked against ``Chopper.open_close_times`` for both directions on an asymmetric
    three-opening disc: negating this puts every opening somewhere else.
    """
    return 360.0 * abs(speed) * delay

spec_from_windows

spec_from_windows(*, name: str, windows, delay: float, speed: float, distance: float) -> ChopperSpec

A spec from the windows niess.chopcalc extracts.

Those windows are already in the frame where an edge at angle a is on the beam at delay + a / (360 * speed) -- that is, a is measured from the beam towards the mark, which is the opposite sense to tof, where an opening at angle a is reached after turning through it. So the two swap sign, and a (minimum, maximum) pair becomes (-maximum, -minimum): still increasing, which tof requires.

Source code in src/niess/tof/mapping.py
def spec_from_windows(*, name: str, windows, delay: float, speed: float,
                      distance: float) -> ChopperSpec:
    """A spec from the windows ``niess.chopcalc`` extracts.

    Those windows are already in the frame where an edge at angle ``a`` is on the beam at
    ``delay + a / (360 * speed)`` -- that is, ``a`` is measured *from the beam towards the
    mark*, which is the opposite sense to ``tof``, where an opening at angle ``a`` is
    reached after turning through it. So the two swap sign, and a ``(minimum, maximum)``
    pair becomes ``(-maximum, -minimum)``: still increasing, which ``tof`` requires.
    """
    if speed == 0:
        raise ValueError(f'{name}: a chopper that is not turning has no phase')
    pairs = [(-float(high), -float(low)) for low, high in windows]
    return ChopperSpec(
        name=name,
        frequency=abs(float(speed)),
        anticlockwise=float(speed) > 0,
        open=tuple(low for low, _ in pairs),
        close=tuple(high for _, high in pairs),
        phase=delay_to_phase(float(delay), float(speed)),
        distance=float(distance),
    )

mapping

The numbers a tof.Chopper wants, from the way niess describes a disc.

Deliberately free of tof itself, so the arithmetic can be tested -- and printed -- without the optional dependency installed, and so the one place the conversion is derived is one place to read.

Classes:

  • ChopperSpec

    One tof.Chopper, as plain numbers, before any tof object exists.

Functions:

  • delay_to_phase

    A disc's delay in seconds, as the phase angle in degrees tof wants.

  • spec_from_windows

    A spec from the windows niess.chopcalc extracts.

ChopperSpec dataclass

ChopperSpec(name: str, frequency: float, anticlockwise: bool, open: tuple[float, ...], close: tuple[float, ...], phase: float, distance: float)

One tof.Chopper, as plain numbers, before any tof object exists.

Methods:

  • to_tof

    The tof.Chopper itself.

Attributes:

  • frequency (float) –

    Hz, never negative -- tof carries the direction separately.

  • open (tuple[float, ...]) –

    Degrees from the beam, one per opening, in tof's sense.

  • phase (float) –

    Degrees.

  • distance (float) –

    Metres along the beam. tof measures from the same zero as its source, so this

frequency instance-attribute

frequency: float

Hz, never negative -- tof carries the direction separately.

open instance-attribute

open: tuple[float, ...]

Degrees from the beam, one per opening, in tof's sense.

phase instance-attribute

phase: float

Degrees.

distance instance-attribute

distance: float

Metres along the beam. tof measures from the same zero as its source, so this is the source's own distance plus the path walked to the disc.

to_tof

to_tof()

The tof.Chopper itself.

Source code in src/niess/tof/mapping.py
def to_tof(self):
    """The ``tof.Chopper`` itself."""
    import scipp as sc

    from .components import _tof

    tof = _tof()
    return tof.Chopper(
        frequency=sc.scalar(self.frequency, unit='Hz'),
        open=sc.array(dims=['cutout'], values=list(self.open), unit='deg'),
        close=sc.array(dims=['cutout'], values=list(self.close), unit='deg'),
        phase=sc.scalar(self.phase, unit='deg'),
        distance=sc.scalar(self.distance, unit='m'),
        name=self.name,
        direction=tof.AntiClockwise if self.anticlockwise else tof.Clockwise,
    )

delay_to_phase

delay_to_phase(delay: float, speed: float) -> float

A disc's delay in seconds, as the phase angle in degrees tof wants.

tof has no notion of a delay. An opening is placed by its angle and the whole disc is shifted by phase, which open_close_times adds to every angle before dividing by the angular speed -- so a delay of d seconds is 360 * |speed| * d degrees.

The sign does not flip with the direction of rotation, which is the one thing here worth being suspicious of. A NeXus phase is an angle in the disc's own rotating frame, so its sign does flip: tof.Chopper.from_nexus writes phase = -phase for a negative rotation speed for exactly that reason. A niess delay is a time, and a later time is later whichever way the disc turns.

Checked against Chopper.open_close_times for both directions on an asymmetric three-opening disc: negating this puts every opening somewhere else.

Source code in src/niess/tof/mapping.py
def delay_to_phase(delay: float, speed: float) -> float:
    """A disc's delay in seconds, as the phase angle in degrees ``tof`` wants.

    ``tof`` has no notion of a delay. An opening is placed by its angle and the whole disc
    is shifted by ``phase``, which ``open_close_times`` adds to every angle before dividing
    by the angular speed -- so a delay of ``d`` seconds is ``360 * |speed| * d`` degrees.

    **The sign does not flip with the direction of rotation**, which is the one thing here
    worth being suspicious of. A NeXus phase is an angle in the disc's own rotating frame,
    so its sign *does* flip: ``tof.Chopper.from_nexus`` writes ``phase = -phase`` for a
    negative rotation speed for exactly that reason. A niess delay is a *time*, and a later
    time is later whichever way the disc turns.

    Checked against ``Chopper.open_close_times`` for both directions on an asymmetric
    three-opening disc: negating this puts every opening somewhere else.
    """
    return 360.0 * abs(speed) * delay

spec_from_windows

spec_from_windows(*, name: str, windows, delay: float, speed: float, distance: float) -> ChopperSpec

A spec from the windows niess.chopcalc extracts.

Those windows are already in the frame where an edge at angle a is on the beam at delay + a / (360 * speed) -- that is, a is measured from the beam towards the mark, which is the opposite sense to tof, where an opening at angle a is reached after turning through it. So the two swap sign, and a (minimum, maximum) pair becomes (-maximum, -minimum): still increasing, which tof requires.

Source code in src/niess/tof/mapping.py
def spec_from_windows(*, name: str, windows, delay: float, speed: float,
                      distance: float) -> ChopperSpec:
    """A spec from the windows ``niess.chopcalc`` extracts.

    Those windows are already in the frame where an edge at angle ``a`` is on the beam at
    ``delay + a / (360 * speed)`` -- that is, ``a`` is measured *from the beam towards the
    mark*, which is the opposite sense to ``tof``, where an opening at angle ``a`` is
    reached after turning through it. So the two swap sign, and a ``(minimum, maximum)``
    pair becomes ``(-maximum, -minimum)``: still increasing, which ``tof`` requires.
    """
    if speed == 0:
        raise ValueError(f'{name}: a chopper that is not turning has no phase')
    pairs = [(-float(high), -float(low)) for low, high in windows]
    return ChopperSpec(
        name=name,
        frequency=abs(float(speed)),
        anticlockwise=float(speed) > 0,
        open=tuple(low for low, _ in pairs),
        close=tuple(high for _, high in pairs),
        phase=delay_to_phase(float(delay), float(speed)),
        distance=float(distance),
    )

parameters

Instrument parameters as numbers, and a record of which ones were used.

niess.chopcalc emits parameter names on purpose, so a band recomputes at run time. tof configures one specific machine, so it needs values. Everything here is about getting from one to the other, and about being able to tell a notebook user afterwards which knobs the model actually turned on.

Classes:

  • Use

    One instrument parameter the model depended on.

  • ParameterValues

    The values to evaluate expressions against, and a note of what got used.

Functions:

  • instrument_defaults

    Every DEFINE INSTRUMENT parameter that has a value, by name.

  • instrument_units

    Parameter units, unquoted -- a DEFINE line carries them as name/"Hz" = 14.

Use dataclass

Use(name: str, value: float, default: float | None, unit: str | None, overridden: bool, used_by: tuple[str, ...])

One instrument parameter the model depended on.

Attributes:

  • overridden (bool) –

    Whether the caller supplied it, rather than the instrument's own default.

  • used_by (tuple[str, ...]) –

    Where it was read, as component.parameter, for the report.

overridden instance-attribute

overridden: bool

Whether the caller supplied it, rather than the instrument's own default.

used_by instance-attribute

used_by: tuple[str, ...]

Where it was read, as component.parameter, for the report.

ParameterValues

ParameterValues(instrument, overrides: dict[str, float] | None = None)

The values to evaluate expressions against, and a note of what got used.

Methods:

  • evaluate

    An expression as a number, or None when it does not fold to one.

  • evaluate_text

    A chopcalc field -- C text naming instrument parameters -- as a number.

  • number

    A component parameter as a number, recording what it depended on.

  • uses

    What the model read, in the order the instrument declares it.

Source code in src/niess/tof/parameters.py
def __init__(self, instrument, overrides: dict[str, float] | None = None):
    self.instrument = instrument
    self.defaults = instrument_defaults(instrument)
    self.units = instrument_units(instrument)
    overrides = {} if overrides is None else dict(overrides)
    unknown = set(overrides) - {p.name for p in instrument.parameters}
    if unknown:
        raise ValueError(
            f'{sorted(unknown)} are not instrument parameters of '
            f'{instrument.name!r}; it has {sorted(p.name for p in instrument.parameters)}'
        )
    self.overrides = {name: self._as_declared(name, value)
                      for name, value in overrides.items()}
    self.values = {**self.defaults, **self.overrides}
    self._uses: dict[str, set[str]] = {}

evaluate

evaluate(expression, *, used_by: str | None = None) -> float | None

An expression as a number, or None when it does not fold to one.

Source code in src/niess/tof/parameters.py
def evaluate(self, expression, *, used_by: str | None = None) -> float | None:
    """An expression as a number, or ``None`` when it does not fold to one."""
    if expression is None:
        return None
    try:
        return float(expression)
    except (TypeError, ValueError):
        pass

    for name in self.values:
        if _depends_on(expression, name):
            self._uses.setdefault(name, set()).add(used_by or '?')

    folded = _fold(expression, self.values)
    if folded is None:
        return None
    try:
        return expr_float(folded)
    except (TypeError, ValueError):
        return None

evaluate_text

evaluate_text(text, *, used_by: str | None = None) -> float | None

A chopcalc field -- C text naming instrument parameters -- as a number.

chopcalc writes C so a band recomputes at run time, and everything it writes happens to parse as a McCode expression, the conditional it emits for a run-time phase included. So the train it extracts can be reused whole rather than the instrument walked a second time.

Source code in src/niess/tof/parameters.py
def evaluate_text(self, text, *, used_by: str | None = None) -> float | None:
    """A `chopcalc` field -- C text naming instrument parameters -- as a number.

    chopcalc writes C so a band recomputes at run time, and everything it writes
    happens to parse as a McCode expression, the conditional it emits for a run-time
    phase included. So the train it extracts can be reused whole rather than the
    instrument walked a second time.
    """
    from mccode_antlr.common.expression import Expr

    if text is None:
        return None
    try:
        return float(text)
    except (TypeError, ValueError):
        pass
    try:
        expression = Expr.parse(str(text))
    except Exception:
        return None
    return self.evaluate(expression, used_by=used_by)

number

number(instance, name: str, *, default: float | None = None)

A component parameter as a number, recording what it depended on.

Source code in src/niess/tof/parameters.py
def number(self, instance, name: str, *, default: float | None = None):
    """A component parameter as a number, recording what it depended on."""
    parameter = instance.get_parameter(name)
    if parameter is None:
        return default
    value = self.evaluate(parameter.value, used_by=f'{instance.name}.{name}')
    return default if value is None else value

uses

uses() -> tuple[Use, ...]

What the model read, in the order the instrument declares it.

Source code in src/niess/tof/parameters.py
def uses(self) -> tuple[Use, ...]:
    """What the model read, in the order the instrument declares it."""
    found = []
    for parameter in self.instrument.parameters:
        if parameter.name not in self._uses:
            continue
        value = self.values.get(parameter.name)
        found.append(Use(
            name=parameter.name,
            value=_as_float(value),
            default=_as_float(self.defaults.get(parameter.name)),
            unit=self.units.get(parameter.name),
            overridden=parameter.name in self.overrides,
            used_by=tuple(sorted(self._uses[parameter.name])),
        ))
    return tuple(found)

instrument_defaults

instrument_defaults(instrument) -> dict[str, object]

Every DEFINE INSTRUMENT parameter that has a value, by name.

niess writes a chopper's speed and delay with the calibration's own numbers as the defaults, so an instrument it built is usually complete on its own -- which is why the report below can normally say that nothing needs supplying.

Source code in src/niess/tof/parameters.py
def instrument_defaults(instrument) -> dict[str, object]:
    """Every DEFINE INSTRUMENT parameter that has a value, by name.

    niess writes a chopper's speed and delay with the calibration's own numbers as the
    defaults, so an instrument it built is usually complete on its own -- which is why the
    report below can normally say that nothing needs supplying.
    """
    known = {}
    for parameter in instrument.parameters:
        value = parameter.value
        if value is None or not getattr(value, 'has_value', False):
            continue
        known[parameter.name] = value
    return known

instrument_units

instrument_units(instrument) -> dict[str, str | None]

Parameter units, unquoted -- a DEFINE line carries them as name/"Hz" = 14.

Source code in src/niess/tof/parameters.py
def instrument_units(instrument) -> dict[str, str | None]:
    """Parameter units, unquoted -- a DEFINE line carries them as `name/"Hz" = 14`."""
    return {p.name: (p.unit or '').strip().strip('"') or None
            for p in instrument.parameters}