Source code for pulserver.design._entries

"""The entries a scanner-sequence plugin declares its protocol with, and the values they hold."""

from __future__ import annotations

import math
import warnings
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any

import pypulseqpp as pp

from ..protocol import (
    PRESCRIPTION,
    InputMode,
    Kind,
    Parameter,
    ProtocolKey,
    TEPreset,
    TRPreset,
)
from ..protocol._keys import StrEnum

Preset = float | Callable[[pp.Opts], float] | None

# FLT_DIG: the significant decimal digits a float32 parameter holds through a round trip.
_SIGNIFICANT_DIGITS = 6
_MICROSECOND = Decimal("1e-6")


[docs] @dataclass(frozen=True) class FloatParam: """A float UI entry bound to an argument of the sequence function. The UI value is the argument divided by ``scale``, in ``unit``; a dropdown when it has options. ``default`` is the UI value the protocol starts at; ``None`` starts it at the sequence function's default. """ argument: str unit: str = "" scale: float = 1.0 range_min: float = 0.0 range_max: float = math.inf range_incr: float = 1.0 options: tuple[float, ...] = () default: float | None = None
[docs] @dataclass(frozen=True) class TimeParam: """A time UI entry bound to an argument of the sequence function, in seconds. Values, ranges and options are integer microseconds, the unit of the scanner's time parameters, so the value a parameter holds is the value the design reported. Each key of ``presets`` is a dropdown preset; its value is the time it requests: seconds, ``None`` for the sequence function's own shortest choice, or a function of the scanner limits. The entry is a dropdown when it has options or presets. ``default``, microseconds or a key of ``presets``, is the value the protocol starts at; ``None`` starts it at the sequence function's default. """ argument: str range_min: int = 0 range_max: int = 2**31 - 1 range_incr: int = 1 options: tuple[int, ...] = () presets: Mapping[int, Preset] = field(default_factory=dict) default: int | None = None
[docs] @dataclass(frozen=True) class IntParam: """An integer UI entry bound to an argument of the sequence function; a dropdown when it has options. ``default`` is the value the protocol starts at; ``None`` starts it at the sequence function's default. """ argument: str unit: str = "" range_min: int = 0 range_max: int = 2**31 - 1 range_incr: int = 1 options: tuple[int, ...] = () default: int | None = None
[docs] @dataclass(frozen=True) class BoolParam: """A checkbox UI entry bound to an argument of the sequence function. ``default`` is the value the protocol starts at; ``None`` starts it at the sequence function's default. """ argument: str default: bool | None = None
[docs] @dataclass(frozen=True) class ChoiceParam: """A choice among the members of a ``StrEnum``, bound to an argument of the sequence function. The argument receives the chosen member, a ``str`` equal to its option. The options are the members in definition order, and the wire carries the index of the chosen one. ``default`` is the member the protocol starts at; ``None`` starts it at the sequence function's default, which has to be a member or the value of one. """ argument: str choices: type[StrEnum] default: StrEnum | None = None
[docs] @dataclass(frozen=True) class ConfigParam: """A value the sequence declares to the interpreter; never shown or edited.""" value: int
[docs] @dataclass(frozen=True) class Description: """A read-only text row in the UI.""" text: str
Entry = ( FloatParam | TimeParam | IntParam | BoolParam | ChoiceParam | ConfigParam | Description )
[docs] def StringListParam( argument: str, options: tuple[str, ...], default: str | None = None ) -> ChoiceParam: """Return a :class:`ChoiceParam` over a ``StrEnum`` built from option strings. Deprecated: declare the enum and use :class:`ChoiceParam`. The argument receives the member of the chosen option, a ``str`` equal to it. ``default`` is the option the protocol starts at; ``None`` starts it at the sequence function's default. Warns ----- DeprecationWarning On every call. """ warnings.warn( "StringListParam is deprecated; use ChoiceParam with a StrEnum", DeprecationWarning, stacklevel=2, ) choices = StrEnum( "Options", {f"OPTION_{n}": option for n, option in enumerate(options)} ) return ChoiceParam(argument, choices, None if default is None else choices(default))
def _ui_float(value: float) -> float: return float(f"{value:.{_SIGNIFICANT_DIGITS}g}") def _to_ui(value: float, scale: float) -> float: return _ui_float(float(Decimal(repr(float(value))) / Decimal(repr(scale)))) def _to_si(value: float, scale: float) -> float: return float(Decimal(repr(_ui_float(value))) * Decimal(repr(scale))) def _to_microseconds(seconds: float) -> int: """Round to the nearest microsecond, ties to even.""" return int((Decimal(repr(float(seconds))) / _MICROSECOND).to_integral_value()) def _to_seconds(microseconds: int) -> float: return float(Decimal(int(microseconds)) * _MICROSECOND) def _member(key: ProtocolKey, entry: ChoiceParam, value: Any) -> StrEnum: """Return the member of the entry's choices that ``value`` is or names.""" try: return entry.choices(value) except ValueError: options = ", ".join(entry.choices) raise ValueError(f"{key}: {value!r} is not one of {options}") from None def _parameter( name: ProtocolKey, entry: Entry, defaults: Mapping[str, Any] ) -> Parameter: if isinstance(entry, ConfigParam): return Parameter(Kind.CONFIG, entry.value, InputMode.OFF) if isinstance(entry, Description): return Parameter(Kind.DESCRIPTION, entry.text) # An entry that states its default may bind a name the app does not take, # for the plugin's own hooks to read. if entry.argument not in defaults and getattr(entry, "default", None) is None: raise ValueError( f"{name} binds {entry.argument!r}, which the app does not take" ) default = defaults.get(entry.argument) if isinstance(entry, TimeParam): options = (*entry.presets, *entry.options) if entry.default is not None: value = int(entry.default) if value < 0 and value not in entry.presets: raise ValueError( f"{name} defaults to preset {value}, which it does not offer" ) elif default is None: shortest = [key for key, preset in entry.presets.items() if preset is None] if not shortest: raise ValueError( f"{name} defaults to None but offers no preset requesting it" ) value = shortest[0] else: value = _to_microseconds(default) mode = InputMode.DROPDOWN if options else InputMode.TYPEIN return Parameter( Kind.INT, value, mode, entry.range_min, entry.range_max, entry.range_incr, "us", options, ) if isinstance(entry, FloatParam): mode = InputMode.DROPDOWN if entry.options else InputMode.TYPEIN return Parameter( Kind.FLOAT, _to_ui(default, entry.scale) if entry.default is None else _ui_float(entry.default), mode, entry.range_min, entry.range_max, entry.range_incr, entry.unit, entry.options, ) if isinstance(entry, IntParam): mode = InputMode.DROPDOWN if entry.options else InputMode.TYPEIN return Parameter( Kind.INT, default if entry.default is None else int(entry.default), mode, entry.range_min, entry.range_max, entry.range_incr, entry.unit, entry.options, ) if isinstance(entry, BoolParam): return Parameter(Kind.BOOL, default if entry.default is None else entry.default) default = default if entry.default is None else entry.default return Parameter( Kind.STRINGLIST, _member(name, entry, default), InputMode.DROPDOWN, options=tuple(entry.choices), )
[docs] class Protocol(Mapping[ProtocolKey, Any]): """The values of a plugin's protocol, in the units of the sequence function's arguments. An immutable mapping from :data:`~pulserver.protocol.ProtocolKey` to value. A time is in seconds and a float in the unit of the argument it binds, metres for a length; an integer is an ``int``, a checkbox a ``bool`` and a choice a member of its enum. A time entry showing a preset holds what the preset requests, a number of seconds or ``None`` for the sequence function's own shortest choice, and :meth:`preset` names the preset. The prescription entries bind no argument and keep the units of the wire: mm for the offset, unitless for the rotation. The wire carries integer microseconds for a time and the entry's own unit for a float, to six significant digits. :meth:`from_wire` and :meth:`to_wire` are the only conversions between the two. Parameters ---------- entries The declared entries the values belong to, as :attr:`SequencePlugin.protocol` holds them. values The value of each key, in argument units. presets The preset each time key shows, by the key its entry declares it with. """ def __init__( self, entries: Mapping[ProtocolKey, Entry], values: Mapping[ProtocolKey, Any], presets: Mapping[ProtocolKey, TEPreset | TRPreset] | None = None, ) -> None: self._entries = entries self._values = dict(values) self._presets = dict(presets or {}) def __getitem__(self, key: ProtocolKey) -> Any: return self._values[key] def __iter__(self) -> Iterator[ProtocolKey]: return iter(self._values) def __len__(self) -> int: return len(self._values) def __repr__(self) -> str: items = ", ".join(f"{key}: {value!r}" for key, value in self._values.items()) return f"Protocol({{{items}}})"
[docs] @classmethod def from_wire( cls, entries: Mapping[ProtocolKey, Entry], values: Mapping[ProtocolKey, Any], system: pp.Opts, ) -> Protocol: """Return the protocol that wire values stand for. Parameters ---------- entries The declared entries: :attr:`SequencePlugin.protocol`. values Wire values by key: integer microseconds or a preset code for a time, the entry's unit for a float, an option for a choice. A key of a config or description entry, which holds no value, is left out. system The scanner limits a callable preset is a function of. Raises ------ ValueError If a key is neither a declared entry nor a prescription entry, a time is a preset its entry does not offer, or a choice is not one of its options. """ converted: dict[ProtocolKey, Any] = {} presets: dict[ProtocolKey, TEPreset | TRPreset] = {} for key, value in values.items(): entry = entries.get(key) if isinstance(entry, ConfigParam | Description): continue if entry is None: if key not in PRESCRIPTION: raise ValueError(f"{key} is not an entry of this protocol") converted[key] = float(value) elif isinstance(entry, TimeParam): if entry.presets and value < 0: code = next((c for c in entry.presets if c == value), None) if code is None: raise ValueError(f"{key} does not offer preset {value}") preset = entry.presets[code] presets[key] = code converted[key] = preset(system) if callable(preset) else preset else: converted[key] = _to_seconds(value) elif isinstance(entry, FloatParam): converted[key] = _to_si(value, entry.scale) elif isinstance(entry, IntParam): converted[key] = int(value) elif isinstance(entry, BoolParam): converted[key] = bool(value) else: converted[key] = _member(key, entry, value) return cls(entries, converted, presets)
[docs] def to_wire(self) -> dict[ProtocolKey, float | int | bool | str]: """Return the wire values of the protocol, the inverse of :meth:`from_wire`. A time showing a preset is its preset code. A float is rounded to six significant digits, the precision of a float32 parameter, and a time to the nearest microsecond, ties to even. """ wire: dict[ProtocolKey, Any] = {} for key, value in self._values.items(): entry = self._entries.get(key) if key in self._presets: wire[key] = self._presets[key] elif isinstance(entry, TimeParam): wire[key] = _to_microseconds(value) elif isinstance(entry, FloatParam): wire[key] = _to_ui(value, entry.scale) elif isinstance(entry, IntParam): wire[key] = int(value) elif isinstance(entry, BoolParam): wire[key] = bool(value) else: wire[key] = value return wire
@property def arguments(self) -> dict[str, Any]: """The values by the name of the sequence function argument each binds. The prescription entries bind no argument and are left out. """ arguments = {} for key, value in self._values.items(): argument = getattr(self._entries.get(key), "argument", None) if argument is not None: arguments[argument] = value return arguments
[docs] def preset(self, key: ProtocolKey) -> TEPreset | TRPreset | None: """Return the preset a time key shows, or ``None`` where it shows none. The preset is the key of the entry's ``presets`` the wire value selected: a :class:`~pulserver.protocol.TEPreset` or :class:`~pulserver.protocol.TRPreset` member. """ return self._presets.get(key)
[docs] def replace(self, changes: Mapping[ProtocolKey, Any]) -> Protocol: """Return a protocol holding ``changes`` in place of the values they name. The values are in argument units, and a time given in seconds no longer shows a preset. Raises ------ ValueError If a key is not in the protocol, or a choice is not one of its options. """ unknown = sorted(key for key in changes if key not in self._values) if unknown: raise ValueError(f"not entries of this protocol: {', '.join(unknown)}") values, presets = dict(self._values), dict(self._presets) for key, value in changes.items(): entry = self._entries.get(key) if isinstance(entry, ChoiceParam): value = _member(key, entry, value) values[key] = value presets.pop(key, None) return Protocol(self._entries, values, presets)