Source code for console.interfaces.acquisition_parameter

"""Interface class for acquisition parameters."""
import json
import logging
from collections.abc import Iterable
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Optional

from console.interfaces.dimensions import Dimensions
from console.interfaces.enums import DDCMethod


def _gradient_offset_factory() -> Dimensions:
    return Dimensions(x=0, y=0, z=0)


def _fov_scaling_factory() -> Dimensions:
    return Dimensions(x=1., y=1., z=1.)


def _channel_factory() -> Dimensions:
    return Dimensions(x=1, y=2, z=3)


def _dict_factory(items: Iterable[tuple[str, Any]]) -> dict[str, Any]:
    """Return a dictionary containing only fields whose names do not start with '_'."""
    return {key: value for key, value in items if not key.startswith("_")}


[docs] @dataclass class AcquisitionParameter: """ Parameters to define an acquisition. The acquisition parameters are defined in a dataclass which is hashable but still mutable. This allows to easily change parameters and detect updates by comparing the hash. New instances of acquisition parameters are not saved automatically. Once the autosave flag is set by calling `activate_autosave`, the parameter state is written to a pickle file acquisition-parameter.state in the default_state_file_path on any mutation of the acquisition parameter instance. The autosave option can be deactivated calling `deactivate_autosave`. Manually storing the acquisition parameters to a specific directory can be achieved using the `save` method and providing the desired path. """ larmor_frequency: float = 2.e6 """Larmor frequency in Hz.""" b1_scaling: float = 1.0 """Scaling of the B1 field (RF transmit power).""" gradient_offset: Dimensions = field(default_factory=_gradient_offset_factory) """Gradient offset values in mV.""" fov_scaling: Dimensions = field(default_factory=_fov_scaling_factory) """Field of view scaling for Gx, Gy and Gz.""" channel_assignment: Dimensions = field(default_factory=_channel_factory) """Assignment of console output channels to gradient channels x, y, z.""" ddc_method: DDCMethod = DDCMethod.FIR """Decimation filter method.""" num_averages: int = 1 """Number of acquisition averages.""" averaging_delay: float = 0.0 """Delay in seconds between acquisition averages.""" state_filepath: str = str(Path.home() / "nexus-console/acquisition-parameter.state") """Default file path for acquisition parameter state. Don't enforce a Path object here to prevent conflicts when sending acquisition parameter instances between different OS. """ _initialized: bool = field(default=False, init=True, repr=False, compare=False, hash=False) def __post_init__(self) -> None: """Post initialization method.""" # Ensure Dimensions type if isinstance(self.gradient_offset, dict): self.gradient_offset = Dimensions.from_dict(self.gradient_offset) if isinstance(self.fov_scaling, dict): self.fov_scaling = Dimensions.from_dict(self.fov_scaling) if isinstance(self.channel_assignment, dict): self.channel_assignment = Dimensions.from_dict(self.channel_assignment) # Register child change callback to trigger a save when child changed self.gradient_offset.register_on_change_callback(self._child_changed) self.fov_scaling.register_on_change_callback(self._child_changed) self.channel_assignment.register_on_change_callback(self._child_changed) _path = Path(self.state_filepath) if not _path.name.endswith(".state"): _path = _path / "acquisition-parameter.state" # Ensure state filepath is a string self.state_filepath = str(_path) self._initialized = True self.save() def __setattr__(self, name: str, value: Any) -> None: """Overwrite __setattr__ function to save object on each mutation.""" # Get type hints for the class hints = self.__class__.__annotations__ if self._initialized: # Skip validation for private attributes if not name.startswith("_") and name in hints: expected_type = hints[name] if not isinstance(value, expected_type): # Raise error on type mismatch msg = f"Attribute '{name}' must be of type {expected_type.__name__}, got {type(value).__name__}" raise TypeError(msg) prev = self.to_dict() super().__setattr__(name, value) if self.to_dict() != prev: self.save() else: super().__setattr__(name, value) def __eq__(self, other) -> bool: """Compare acquisition parameter to other instance.""" if not isinstance(other, AcquisitionParameter): return False # deep dict comparison, including nested dataclasses return self.to_dict() == other.to_dict() def __str__(self) -> str: """Get string representation of acquisition parameter.""" data = self.to_dict() output = "Acquisition parameter\n----------\n" for k, (key, value) in enumerate(data.items()): output += f"{key} = {value}" if k == len(data) - 1 else f"{key} = {value}\n" return output def __copy__(self) -> "AcquisitionParameter": """Copy acquisition parameter.""" cls = self.__class__ result = cls(**self.__dict__) result._initialized = True return result def _child_changed(self) -> None: """Save acquisition parameters if child changed.""" if self._initialized: self.save()
[docs] def to_dict(self, use_strings: bool = False) -> dict: """Return acquisition parameters as dictionary. Parameters ---------- use_strings, optional boolean flag indicating if values of dictionary should be represented as strings, by default False Returns ------- Acquisition parameter dictionary """ if use_strings: return {key: str(value) for key, value in asdict(self, dict_factory=_dict_factory).items()} return asdict(self, dict_factory=_dict_factory)
[docs] def save(self, filepath: str | Path | None = None) -> None: """Save current acquisition parameter state. Parameters ---------- file_path, optional Path to the pickle state file, by default None. If None, the default state file path is taken which is <home>/nexus-console/acquisition-parameter.state Default state file path can be changed using the set_default_path method. """ _path = Path(filepath or self.state_filepath) if not _path.name.endswith(".state"): _path = _path / "acquisition-parameter.state" _path.parent.mkdir(parents=True, exist_ok=True) _path.write_text(json.dumps(self.to_dict(), indent=2))
[docs] @classmethod def load(cls, filepath: Path | str | None = None) -> Optional["AcquisitionParameter"]: """ Load and return acquisition parameter state. Parameters ---------- file_path, optional Path to acquisition parameter state file. If file_path is not a pickle file, i.e. ends with .json, the default state file designation acquisition-parameter.state is added. Returns ------- Instance of acquisition parameters with state loaded from provided file_path. Raises ------ FileNotFoundError Provided file_path is not a pickle file or does not exist. EOFError Provided state file is corrupted """ log = logging.getLogger("AcqParam") # Resolve file path _path = filepath or cls.state_filepath _path = Path(_path) # Enforce `.state` suffix if not _path.name.endswith(".state"): _path = _path / "acquisition-parameter.state" try: data = json.loads(_path.read_text()) return cls(**data) except Exception: log.exception( "Error loading AcquisitionParameter state file '%s'.", str(_path), ) return None