From c15c217493995b89d5fd9e712a9bcfcf89625c71 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Thu, 2 Apr 2026 17:02:16 -0400 Subject: [PATCH 01/23] Reorganize Project and Imports #8 - Trims unnecessary parts of the project, including - legacy scripts - the io module (now covered in `openlifu-sdk`) - unused parts of Database (gridweights) - Reorganizes `virtual_fit` module into `seg` - Eliminates "eager" imports in `openlifu.__init__`, to prevent the many-seconds-long importing when accessing any submodule. - Moves importing of heavier-weight packages (which are being made optional by this branch) into the methods that call them, so that the objects can be partially-used without having those dependencies imported. Code that touches `openlifu.virtual_fit` needs to be refactored to use `openlifu.seg.virtualfit`, and code that used things like `openlifu.Transducer` will need to be changed to use `openlifu.xdc.Transducer` reorganize virtual fit and geo remove gridweights Unused enhancement Update transducer.py remove legacy scripts, --- src/openlifu/plan/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/openlifu/plan/__init__.py b/src/openlifu/plan/__init__.py index d3189b3d..6b6a3487 100644 --- a/src/openlifu/plan/__init__.py +++ b/src/openlifu/plan/__init__.py @@ -15,4 +15,5 @@ "SolutionAnalysisOptions", "TargetConstraints", "ParameterConstraint", + "run_virtual_fit", ] From 3aa52278397689c6fdf70b214518d5d32a9ea4fd Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 26 May 2026 10:39:12 -0400 Subject: [PATCH 02/23] add transducer retrieval --- src/openlifu/xdc/transducer.py | 29 +++ src/openlifu/xdc/transducerarray.py | 360 ++++++++++++++++++++++++++++ tests/test_transducer.py | 308 ++++++++++++++++++++++++ 3 files changed, 697 insertions(+) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index 529180f0..28386587 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -393,6 +393,35 @@ def to_json(self, compact:bool=False) -> str: else: return json.dumps(self.to_dict(), indent=4) + @classmethod + def from_module_user_config(cls, user_config: dict) -> Transducer: + """Build a single-module ``Transducer`` from a module ``user_config`` dict. + + The dict is expected to follow the structure produced by the SDK's + :py:meth:`openlifu_sdk.io.LIFUTXDevice.TxDevice.read_config`: a top-level + record with a nested ``"module"`` sub-dict whose fields are the kwargs + for :py:meth:`gen_matrix_array` (``nx``, ``ny``, ``pitch``, ``kerf``, + plus any ``Transducer`` fields such as ``frequency``, ``sensitivity``, + ``crosstalk_frac``, ``crosstalk_dist``, ``id``, ``name``). The + top-level ``"hwid"`` is preserved in the resulting transducer's + ``attrs`` dict under the key ``"hwid"`` so callers can identify the + physical module later. + + Mesh filenames, standoff transforms, and any other per-module data + that cannot be carried in the user_config remain at their dataclass + defaults; supply a template via :py:meth:`TransducerArray.from_module_user_configs` + to inject those. + """ + module_cfg = (user_config.get("module") or {}) + if not module_cfg: + raise ValueError("user_config has no 'module' sub-dict") + t = cls.gen_matrix_array(**module_cfg) + hwid = user_config.get("hwid") + if hwid is not None: + t.attrs = dict(t.attrs) + t.attrs["hwid"] = hwid + return t + @staticmethod def gen_matrix_array(nx=2, ny=2, pitch=1, kerf=0, units="mm", **kwargs): """Generate a 2D flat matrix array diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index e552f20b..225f1249 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -1,7 +1,9 @@ from __future__ import annotations +import copy import json from dataclasses import dataclass, field +from typing import Sequence import numpy as np @@ -9,6 +11,90 @@ from openlifu.util.units import getunitconversion from openlifu.xdc import Transducer, TransformedTransducer +# Mapping from (num_connected_modules, freq_khz) to the canonical +# template id consumed by :py:meth:`TransducerArray.get_connected`. +_DEFAULT_TEMPLATE_IDS: dict[tuple[int, int], str] = { + (1, 155): "openlifu_1x155", + (1, 400): "openlifu_1x400", + (2, 155): "openlifu_2x155", + (2, 400): "openlifu_2x400", +} + +# Locally-embedded per-module transforms and array-level standoff for +# the canonical default templates. Used as a meshless fallback when no +# database is provided to :py:meth:`TransducerArray.get_connected`. +# The 2x155 entries currently mirror the openlifu_2x180_evt1 template +# as a stand-in until a dedicated 155 kHz template ships. +_DEFAULT_TEMPLATE_DATA: dict[str, dict] = { + "openlifu_1x155": { + "name": "OpenLIFU 1x 155kHz", + "module_transforms": [np.eye(4, dtype=float)], + "standoff_transform": np.eye(4, dtype=float), + }, + "openlifu_1x400": { + "name": "OpenLIFU 1x 400kHz", + "module_transforms": [np.eye(4, dtype=float)], + "standoff_transform": np.eye(4, dtype=float), + }, + "openlifu_2x155": { + "name": "OpenLIFU 2x 155kHz", + "module_transforms": [ + np.array([ + [0.9697859993972769, 0.0, -0.2439571998794554, 25.84571998794554], + [0.0, 1.0, 0.0, 0.0], + [0.24395719987945538, 0.0, 0.9697859993972772, 3.20098197421292], + [0.0, 0.0, 0.0, 1.0], + ], dtype=float), + np.array([ + [0.9697859993972769, 0.0, 0.2439571998794554, -25.84571998794554], + [0.0, 1.0, 0.0, 0.0], + [-0.24395719987945538, 0.0, 0.9697859993972772, 3.20098197421292], + [0.0, 0.0, 0.0, 1.0], + ], dtype=float), + ], + "standoff_transform": np.array([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.997684, -0.0680153, 0.0], + [0.0, 0.0680153, 0.997684, -8.0], + [0.0, 0.0, 0.0, 1.0], + ], dtype=float), + }, + "openlifu_2x400": { + "name": "OpenLIFU 2x 400kHz", + "module_transforms": [ + np.array([ + [0.9659258262890683, 0.0, -0.25881904510252074, 25.84571998794554], + [0.0, 1.0, 0.0, 0.0], + [0.25881904510252074, 0.0, 0.9659258262890683, 3.20098197421292], + [0.0, 0.0, 0.0, 1.0], + ], dtype=float), + np.array([ + [0.9659258262890683, 0.0, 0.25881904510252074, -25.84571998794554], + [0.0, 1.0, 0.0, 0.0], + [-0.25881904510252074, 0.0, 0.9659258262890683, 3.20098197421292], + [0.0, 0.0, 0.0, 1.0], + ], dtype=float), + ], + "standoff_transform": np.array([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, -8.0], + [0.0, 0.0, 0.0, 1.0], + ], dtype=float), + }, +} + + +def _build_meshless_default_template(template_id: str) -> TransducerArray: + """Build a meshless template :class:`TransducerArray` from embedded transforms.""" + spec = _DEFAULT_TEMPLATE_DATA[template_id] + modules: list[TransformedTransducer] = [] + for tform in spec["module_transforms"]: + t = Transducer(id=template_id, elements=[]) + modules.append(TransformedTransducer.from_transducer(t, transform=np.array(tform, dtype=float))) + attrs = {"standoff_transform": np.array(spec["standoff_transform"], dtype=float)} + return TransducerArray(id=template_id, name=spec["name"], modules=modules, attrs=attrs) + def get_angle_from_gap(width, gap, roc): a = roc @@ -151,6 +237,280 @@ def from_file(filename: str) -> TransducerArray: data = json.load(f) return TransducerArray.from_dict(data) + @classmethod + def from_module_user_configs( + cls, + user_configs: Sequence[dict], + template: TransducerArray | None = None, + module_transforms: Sequence[np.ndarray] | None = None, + arr_id: str | None = None, + arr_name: str | None = None, + ) -> TransducerArray: + """Construct a :class:`TransducerArray` from one or more module ``user_config`` dicts. + + Each ``user_config`` describes a single physical module as reported by + the SDK (``hwid``, ``module`` sub-dict suitable for + :py:meth:`Transducer.gen_matrix_array`, optional ``device`` sub-dict on + the lead module). User configs cannot carry mesh data, so a + ``template`` :class:`TransducerArray` is normally supplied to inject + per-module mesh filenames / standoff transforms / placement transforms + and array-level metadata (id, name, attrs). + + Sources of array-level metadata, lowest priority first: + + 1. ``template``: provides ``id``, ``name``, ``attrs``, and per-module + ``transform``, ``standoff_transform``, ``registration_surface_filename``, + ``transducer_body_filename``. Modules are matched to ``user_configs`` + positionally. + 2. ``user_configs[0]["device"]`` (if present): overrides ``id``, + ``name``, merges into ``attrs``, and supplies per-module transforms + keyed by ``hwid`` (falling back to positional matching when + ``hwid`` is not present in the device entry). + 3. ``module_transforms`` (if given): per-module 4x4 transforms that + override everything else. Length must match ``user_configs``. + 4. ``arr_id`` / ``arr_name`` (if given): explicit array id/name that + override the values picked up from the device config or template. + + The per-module ``Transducer`` is always rebuilt from the user_config's + ``module`` field (this is the on-device truth for nx/ny/pitch/kerf/ + frequency/sensitivity/etc.); only metadata that cannot live in the + user_config is taken from the template. + + Args: + user_configs: ordered list of user_config dicts. Order corresponds + to module index as reported by the device. + template: optional template array; see above for what it supplies. + module_transforms: optional list of explicit 4x4 transforms, + one per user_config, that override template/device transforms. + arr_id: optional explicit array id. Highest-priority source for the + resulting ``TransducerArray.id`` (overrides device/template/default). + arr_name: optional explicit array name. Highest-priority source for + the resulting ``TransducerArray.name``. + + Returns: + A :class:`TransducerArray` whose ``modules`` are + :class:`TransformedTransducer` instances built from the + user_configs. + """ + if not user_configs: + raise ValueError("user_configs must contain at least one user_config dict") + if module_transforms is not None and len(module_transforms) != len(user_configs): + raise ValueError( + f"module_transforms length ({len(module_transforms)}) does not match " + f"user_configs length ({len(user_configs)})" + ) + + # ---- Resolve array-level metadata ---- + # Priority (highest first): explicit arg > device config > template > default. + resolved_id: str = "transducer_array" + resolved_name: str = "Transducer Array" + arr_attrs: dict = {} + if template is not None: + resolved_id = template.id + resolved_name = template.name + arr_attrs = copy.deepcopy(template.attrs) + + device_cfg = user_configs[0].get("device") or None + device_modules_in_order: list = [] + device_modules_by_hwid: dict = {} + if device_cfg: + resolved_id = device_cfg.get("id", resolved_id) + resolved_name = device_cfg.get("name", resolved_name) + for k, v in (device_cfg.get("attrs") or {}).items(): + arr_attrs[k] = v + device_modules_in_order = list(device_cfg.get("modules") or []) + device_modules_by_hwid = { + m["hwid"]: m + for m in device_modules_in_order + if isinstance(m, dict) and m.get("hwid") + } + + if arr_id is not None: + resolved_id = arr_id + if arr_name is not None: + resolved_name = arr_name + + # Normalize array-level standoff_transform to ndarray if present + st = arr_attrs.get("standoff_transform") + if st is not None and not isinstance(st, np.ndarray): + arr_attrs["standoff_transform"] = np.array(st, dtype=float) + + # ---- Build each module ---- + template_modules: list = list(template.modules) if template is not None else [] + modules: list[TransformedTransducer] = [] + for i, cfg in enumerate(user_configs): + t = Transducer.from_module_user_config(cfg) + template_mod = template_modules[i] if i < len(template_modules) else None + + # Inherit per-module data from template (mesh/standoff/etc.) + if template_mod is not None: + t.registration_surface_filename = template_mod.registration_surface_filename + t.transducer_body_filename = template_mod.transducer_body_filename + if template_mod.standoff_transform is not None: + t.standoff_transform = np.array(template_mod.standoff_transform, dtype=float) + if template_mod.module_invert: + t.module_invert = list(template_mod.module_invert) + + # Resolve transform: template < device < explicit override + transform = np.eye(4) + if template_mod is not None: + transform = np.array(template_mod.transform, dtype=float) + + hwid = cfg.get("hwid") + device_mod: dict | None = None + if hwid and hwid in device_modules_by_hwid: + device_mod = device_modules_by_hwid[hwid] + elif device_modules_in_order and i < len(device_modules_in_order): + candidate = device_modules_in_order[i] + if isinstance(candidate, dict): + device_mod = candidate + if device_mod is not None and device_mod.get("transform") is not None: + transform = np.array(device_mod["transform"], dtype=float) + + if module_transforms is not None: + transform = np.array(module_transforms[i], dtype=float) + + modules.append(TransformedTransducer.from_transducer(t, transform=transform)) + + return cls(id=resolved_id, name=resolved_name, modules=modules, attrs=arr_attrs) + + @classmethod + def get_connected( + cls, + interface=None, + db=None, + arr_id: str | None = None, + arr_name: str | None = None, + module_transforms: Sequence[np.ndarray] | None = None, + use_default_template: bool = True, + ) -> TransducerArray: + """Read ``user_config`` from every connected TX module and build a :class:`TransducerArray`. + + Picks a default template based on the number of connected modules + and the per-module ``freq`` value (which must agree across modules + when more than one is connected). The mapping is: + + ====================== ===================== + ``(n_modules, freq)`` template id + ====================== ===================== + ``(1, 155)`` ``openlifu_1x155`` + ``(1, 400)`` ``openlifu_1x400`` + ``(2, 155)`` ``openlifu_2x155`` + ``(2, 400)`` ``openlifu_2x400`` + ====================== ===================== + + When ``db`` is provided, the template (with its meshes) is loaded + from the database via ``db.load_transducer(template_id, convert_array=False)``. + If no database is provided (or the lookup fails) and + ``use_default_template`` is ``True``, a meshless fallback template + is constructed from the transforms embedded in this module — the + resulting array has correct module/standoff transforms but no + mesh filenames. + + Args: + interface: an :py:class:`openlifu_sdk.io.LIFUInterface`-like + object exposing ``txdevice.get_tx_module_count()`` and + ``txdevice.read_config(module=i)``. A fresh + :py:class:`LIFUInterface` is constructed when omitted + (requires ``openlifu_sdk`` to be installed). + db: optional :py:class:`openlifu.db.Database` used to load the + template by id (so the resulting array references the + database's mesh files). + arr_id: optional explicit override for the resulting array id. + arr_name: optional explicit override for the resulting array name. + module_transforms: optional explicit per-module 4x4 transforms + (e.g. from a per-module calibration step) that override + both the template and any device-config transforms. + use_default_template: when ``True`` (default), fall back to a + meshless embedded template if no database template can be + found. Set to ``False`` to skip the template entirely and + rely on identity / device-config / explicit transforms. + + Returns: + A :class:`TransducerArray` representing the connected device. + """ + if interface is None: + try: + from openlifu_sdk.io import LIFUInterface + except ImportError as exc: + raise ImportError( + "openlifu_sdk is required to auto-create a LIFUInterface; " + "install it or pass an explicit `interface=` argument." + ) from exc + interface = LIFUInterface() + + txdevice = interface.txdevice + count = int(txdevice.get_tx_module_count()) + if count <= 0: + raise RuntimeError("No TX modules are connected.") + + user_configs: list[dict] = [] + for i in range(count): + cfg = txdevice.read_config(module=i) + if cfg is None: + raise RuntimeError(f"Failed to read user_config from module {i}.") + user_configs.append(json.loads(cfg.get_json_str())) + + # All connected modules must report the same frequency for the + # template lookup to be unambiguous. + freqs = {c.get("freq") for c in user_configs} + if len(freqs) > 1: + raise ValueError( + f"Connected modules have mismatched frequencies: " + f"{sorted(f for f in freqs if f is not None)}" + ) + freq = next(iter(freqs)) if freqs else None + + # Resolve a template: prefer db lookup, fall back to embedded transforms. + template: TransducerArray | None = None + template_id = _DEFAULT_TEMPLATE_IDS.get((count, freq)) + if template_id is not None: + if db is not None: + try: + loaded = db.load_transducer(template_id, convert_array=False) + except Exception: # -- db can raise many things; treat as "not found" + loaded = None + if isinstance(loaded, TransducerArray): + template = loaded + if template is None and use_default_template and template_id in _DEFAULT_TEMPLATE_DATA: + template = _build_meshless_default_template(template_id) + + return cls.from_module_user_configs( + user_configs, + template=template, + module_transforms=module_transforms, + arr_id=arr_id, + arr_name=arr_name, + ) + + def to_device_config(self) -> dict: + """Serialize array-level info to a ``device`` dict for the lead module's user_config. + + Captures the array ``id``, ``name``, and ``attrs`` (mesh filenames and + array-level ``standoff_transform``) along with per-module ``hwid`` + + ``transform`` entries. Mesh files themselves are not stored — consumers + must combine this with a template :class:`TransducerArray` (which + provides the mesh files via :py:attr:`Transducer.registration_surface_filename` + / :py:attr:`Transducer.transducer_body_filename`) when reconstructing + the array via :py:meth:`from_module_user_configs`. + """ + attrs_serialized: dict = {} + for k, v in self.attrs.items(): + attrs_serialized[k] = v.tolist() if isinstance(v, np.ndarray) else v + modules_entries: list[dict] = [] + for m in self.modules: + entry = { + "hwid": (m.attrs or {}).get("hwid"), + "transform": np.array(m.transform).tolist(), + } + modules_entries.append(entry) + return { + "id": self.id, + "name": self.name, + "modules": modules_entries, + "attrs": attrs_serialized, + } + @property def registration_surface_filename(self): if "registration_surface_filename" in self.attrs: diff --git a/tests/test_transducer.py b/tests/test_transducer.py index 5b34382f..f56d25d1 100644 --- a/tests/test_transducer.py +++ b/tests/test_transducer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import numpy as np @@ -395,3 +396,310 @@ def test_element_in_transducer_sensitivity_from_json_is_list_of_tuples(): assert all(isinstance(pair, tuple) for pair in el_sensitivity) assert all(isinstance(f, float) and isinstance(v, float) for f, v in el_sensitivity) assert el_sensitivity == [(100e3, 5.0), (300e3, 9.0)] + + +# --------------------------------------------------------------------------- +# Module user_config integration +# --------------------------------------------------------------------------- + +def _example_module_user_config(hwid: str = "ABCD1234") -> dict: + return { + "sn": "EVT2B-400K-TEST", + "hwid": hwid, + "freq": 400, + "module": { + "id": f"txm_400_{hwid.lower()}", + "name": f"TXM 400kHz ({hwid})", + "nx": 8, + "ny": 8, + "pitch": 5, + "frequency": 400000.0, + "kerf": 0.3, + "crosstalk_frac": 0.12, + "crosstalk_dist": 0.00505, + "sensitivity": [(400e3, 2800.0), (405e3, 1950.0)], + }, + "device": {}, + } + + +def test_transducer_from_module_user_config(): + cfg = _example_module_user_config(hwid="HW1") + t = Transducer.from_module_user_config(cfg) + assert isinstance(t, Transducer) + assert t.numelements() == 64 + assert t.id == "txm_400_hw1" + assert t.frequency == 400000.0 + assert t.attrs["hwid"] == "HW1" + assert t.sensitivity == [(400e3, 2800.0), (405e3, 1950.0)] + + +def test_transducer_from_module_user_config_missing_module(): + with pytest.raises(ValueError, match="no 'module'"): + Transducer.from_module_user_config({"hwid": "X"}) + + +def test_transducer_array_from_module_user_configs_bare(): + cfgs = [_example_module_user_config("HW1"), _example_module_user_config("HW2")] + arr = TransducerArray.from_module_user_configs(cfgs) + assert isinstance(arr, TransducerArray) + assert len(arr.modules) == 2 + assert arr.id == "transducer_array" + # No template/device/override -> identity transforms. + for m in arr.modules: + np.testing.assert_allclose(m.transform, np.eye(4)) + assert {m.attrs.get("hwid") for m in arr.modules} == {"HW1", "HW2"} + + +def test_transducer_array_from_module_user_configs_with_device_field(): + cfg1 = _example_module_user_config("HW1") + cfg2 = _example_module_user_config("HW2") + cfg1["device"] = { + "id": "test_array", + "name": "Test Array", + "modules": [ + {"hwid": "HW2", "transform": np.diag([1, 1, 1, 1]).tolist()}, + {"hwid": "HW1", + "transform": [[1, 0, 0, 10.0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}, + ], + "attrs": {"registration_surface_filename": "x.obj"}, + } + arr = TransducerArray.from_module_user_configs([cfg1, cfg2]) + assert arr.id == "test_array" + assert arr.name == "Test Array" + assert arr.attrs["registration_surface_filename"] == "x.obj" + # HW1 is module index 0 but its transform is keyed by hwid -> the + # (10, 0, 0) offset must end up on module 0, not module 1. + np.testing.assert_allclose(arr.modules[0].transform[0, 3], 10.0) + np.testing.assert_allclose(arr.modules[1].transform, np.eye(4)) + + +def test_transducer_array_from_module_user_configs_with_template(): + cfgs = [_example_module_user_config("HW1"), _example_module_user_config("HW2")] + # Build a template with mesh metadata + nontrivial transforms. + base_template = TransducerArray.get_concave_cylinder( + Transducer.gen_matrix_array(nx=8, ny=8, pitch=5, kerf=0.3, units="mm"), + rows=1, cols=2, width=40, gap=0.0, units="mm", + id="template_array", name="Template Array", + attrs={"registration_surface_filename": "tpl.obj"}, + ) + for m in base_template.modules: + m.registration_surface_filename = "module.surf.obj" + m.transducer_body_filename = "module.body.obj" + + arr = TransducerArray.from_module_user_configs(cfgs, template=base_template) + assert arr.id == "template_array" + assert arr.attrs["registration_surface_filename"] == "tpl.obj" + for m in arr.modules: + assert m.registration_surface_filename == "module.surf.obj" + assert m.transducer_body_filename == "module.body.obj" + # Template's transforms should propagate when no device/override provided. + np.testing.assert_allclose(arr.modules[0].transform, base_template.modules[0].transform) + + +def test_transducer_array_from_module_user_configs_module_transforms_override(): + cfgs = [_example_module_user_config("HW1"), _example_module_user_config("HW2")] + cfgs[0]["device"] = { + "id": "x", + "name": "x", + "modules": [ + {"hwid": "HW1", "transform": np.eye(4).tolist()}, + {"hwid": "HW2", "transform": np.eye(4).tolist()}, + ], + "attrs": {}, + } + overrides = [ + np.array([[1, 0, 0, 1.0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float), + np.array([[1, 0, 0, 2.0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float), + ] + arr = TransducerArray.from_module_user_configs(cfgs, module_transforms=overrides) + np.testing.assert_allclose(arr.modules[0].transform[0, 3], 1.0) + np.testing.assert_allclose(arr.modules[1].transform[0, 3], 2.0) + + +def test_transducer_array_to_device_config_roundtrip(): + cfg1 = _example_module_user_config("HW1") + cfg2 = _example_module_user_config("HW2") + cfg1["device"] = { + "id": "rt_array", + "name": "Roundtrip Array", + "modules": [ + {"hwid": "HW1", + "transform": [[1, 0, 0, 5.0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}, + {"hwid": "HW2", + "transform": [[1, 0, 0, -5.0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}, + ], + "attrs": {"standoff_transform": np.eye(4).tolist()}, + } + arr = TransducerArray.from_module_user_configs([cfg1, cfg2]) + device_dict = arr.to_device_config() + assert device_dict["id"] == "rt_array" + assert [m["hwid"] for m in device_dict["modules"]] == ["HW1", "HW2"] + np.testing.assert_allclose(device_dict["modules"][0]["transform"][0][3], 5.0) + np.testing.assert_allclose(device_dict["modules"][1]["transform"][0][3], -5.0) + + +def test_transducer_array_from_module_user_configs_empty_raises(): + with pytest.raises(ValueError, match="at least one user_config"): + TransducerArray.from_module_user_configs([]) + + +def test_transducer_array_from_module_user_configs_length_mismatch_raises(): + cfgs = [_example_module_user_config("HW1")] + with pytest.raises(ValueError, match="module_transforms length"): + TransducerArray.from_module_user_configs(cfgs, module_transforms=[np.eye(4), np.eye(4)]) + + +def test_transducer_array_from_module_user_configs_explicit_arr_id_name_override(): + cfg1 = _example_module_user_config("HW1") + cfg2 = _example_module_user_config("HW2") + cfg1["device"] = { + "id": "from_device", + "name": "From Device", + "modules": [ + {"hwid": "HW1", "transform": np.eye(4).tolist()}, + {"hwid": "HW2", "transform": np.eye(4).tolist()}, + ], + "attrs": {}, + } + arr = TransducerArray.from_module_user_configs( + [cfg1, cfg2], arr_id="explicit_id", arr_name="Explicit Name", + ) + assert arr.id == "explicit_id" + assert arr.name == "Explicit Name" + + +def test_transducer_array_from_module_user_configs_arr_id_falls_through(): + """No explicit arg / no device id -> template id is used.""" + cfgs = [_example_module_user_config("HW1"), _example_module_user_config("HW2")] + template = TransducerArray.get_concave_cylinder( + Transducer.gen_matrix_array(nx=8, ny=8, pitch=5, kerf=0.3, units="mm"), + rows=1, cols=2, width=40, gap=0.0, units="mm", + id="tpl_id", name="Tpl Name", + ) + arr = TransducerArray.from_module_user_configs(cfgs, template=template) + assert arr.id == "tpl_id" + assert arr.name == "Tpl Name" + + +class _FakeUserConfig: + def __init__(self, payload: dict): + self._payload = payload + + def get_json_str(self) -> str: + return json.dumps(self._payload) + + +class _FakeTxDevice: + def __init__(self, configs: list[dict]): + self._configs = configs + + def get_tx_module_count(self) -> int: + return len(self._configs) + + def read_config(self, module: int = 0): + return _FakeUserConfig(self._configs[module]) + + +class _FakeInterface: + def __init__(self, configs: list[dict]): + self.txdevice = _FakeTxDevice(configs) + + +def _user_cfg_for_freq(hwid: str, freq_khz: int) -> dict: + cfg = _example_module_user_config(hwid) + cfg["freq"] = freq_khz + cfg["module"]["frequency"] = float(freq_khz) * 1e3 + return cfg + + +def test_transducer_array_get_connected_no_db_2x400(): + """No db -> meshless embedded template, but transforms applied.""" + interface = _FakeInterface([_user_cfg_for_freq("HW1", 400), _user_cfg_for_freq("HW2", 400)]) + arr = TransducerArray.get_connected(interface=interface) + assert arr.id == "openlifu_2x400" + assert len(arr.modules) == 2 + # Transforms from the embedded default should be applied (not identity). + np.testing.assert_allclose(arr.modules[0].transform[0, 3], 25.84571998794554) + np.testing.assert_allclose(arr.modules[1].transform[0, 3], -25.84571998794554) + # Standoff transform present, no mesh filenames in the meshless fallback. + assert "standoff_transform" in arr.attrs + assert arr.attrs.get("registration_surface_filename") is None + + +def test_transducer_array_get_connected_no_db_1x400(): + interface = _FakeInterface([_user_cfg_for_freq("HW1", 400)]) + arr = TransducerArray.get_connected(interface=interface) + assert arr.id == "openlifu_1x400" + assert len(arr.modules) == 1 + np.testing.assert_allclose(arr.modules[0].transform, np.eye(4)) + + +def test_transducer_array_get_connected_mismatched_freqs_raises(): + interface = _FakeInterface([_user_cfg_for_freq("HW1", 400), _user_cfg_for_freq("HW2", 155)]) + with pytest.raises(ValueError, match="mismatched frequencies"): + TransducerArray.get_connected(interface=interface) + + +def test_transducer_array_get_connected_with_db_uses_template_meshes(): + """A db that returns a TransducerArray template should provide mesh filenames.""" + template = TransducerArray.get_concave_cylinder( + Transducer.gen_matrix_array(nx=8, ny=8, pitch=5, kerf=0.3, units="mm"), + rows=1, cols=2, width=40, gap=0.0, units="mm", + id="openlifu_2x400", name="OpenLIFU 2x 400kHz", + attrs={ + "registration_surface_filename": "fake.surf.obj", + "transducer_body_filename": "fake.body.obj", + }, + ) + for m in template.modules: + m.registration_surface_filename = "module.surf.obj" + m.transducer_body_filename = "module.body.obj" + + class _FakeDb: + def load_transducer(self, transducer_id, convert_array=True): + assert transducer_id == "openlifu_2x400" + assert convert_array is False + return template + + interface = _FakeInterface([_user_cfg_for_freq("HW1", 400), _user_cfg_for_freq("HW2", 400)]) + arr = TransducerArray.get_connected(interface=interface, db=_FakeDb()) + assert arr.attrs["registration_surface_filename"] == "fake.surf.obj" + assert arr.modules[0].registration_surface_filename == "module.surf.obj" + + +def test_transducer_array_get_connected_explicit_overrides(): + interface = _FakeInterface([_user_cfg_for_freq("HW1", 400), _user_cfg_for_freq("HW2", 400)]) + overrides = [ + np.array([[1, 0, 0, 1.0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float), + np.array([[1, 0, 0, 2.0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float), + ] + arr = TransducerArray.get_connected( + interface=interface, + arr_id="my_array", arr_name="My Array", + module_transforms=overrides, + ) + assert arr.id == "my_array" + assert arr.name == "My Array" + np.testing.assert_allclose(arr.modules[0].transform[0, 3], 1.0) + np.testing.assert_allclose(arr.modules[1].transform[0, 3], 2.0) + + +def test_transducer_array_get_connected_no_modules_raises(): + interface = _FakeInterface([]) + with pytest.raises(RuntimeError): + TransducerArray.get_connected(interface=interface) + + +def test_transducer_array_get_connected_unknown_combo_no_template(): + """Unknown (n, freq) combo -> no template, identity transforms.""" + interface = _FakeInterface([ + _user_cfg_for_freq("HW1", 250), + _user_cfg_for_freq("HW2", 250), + _user_cfg_for_freq("HW3", 250), + ]) + arr = TransducerArray.get_connected(interface=interface) + # No template id matched (3, 250) -> bare default + assert arr.id == "transducer_array" + for m in arr.modules: + np.testing.assert_allclose(m.transform, np.eye(4)) From 9218684de1455ef25736025d6fb51e1367e11181 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 26 May 2026 11:40:36 -0400 Subject: [PATCH 03/23] repr --- src/openlifu/xdc/element.py | 89 +++++++++++++++++++++++++++ src/openlifu/xdc/transducer.py | 90 +++++++++++++++++++++++++++ src/openlifu/xdc/transducerarray.py | 94 +++++++++++++++++++++++++++-- tests/test_transducer.py | 70 +++++++++++++++++++++ 4 files changed, 337 insertions(+), 6 deletions(-) diff --git a/src/openlifu/xdc/element.py b/src/openlifu/xdc/element.py index 2b9bc84c..6bb3f6fd 100644 --- a/src/openlifu/xdc/element.py +++ b/src/openlifu/xdc/element.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import html from dataclasses import dataclass, field from typing import Annotated, List @@ -50,6 +51,30 @@ def matrix2xyz(matrix): roll = np.arctan2(xyp, xxp) return x, y, z, az, el, roll + +def _format_scalar(value: float, precision: int = 3) -> str: + return np.format_float_positional(float(value), precision=precision, trim="-") + + +def _format_sensitivity_summary(sensitivity: float | List[tuple[float, float]]) -> str: + if isinstance(sensitivity, list): + if not sensitivity: + return "[]" + low_f, low_v = sensitivity[0] + high_f, high_v = sensitivity[-1] + if len(sensitivity) == 1: + return ( + f"[{_format_scalar(low_f, precision=0)} Hz: " + f"{_format_scalar(low_v)} Pa/V]" + ) + return ( + f"[{len(sensitivity)} pts, " + f"{_format_scalar(low_f, precision=0)}-" + f"{_format_scalar(high_f, precision=0)} Hz, " + f"{_format_scalar(low_v)}-{_format_scalar(high_v)} Pa/V]" + ) + return f"{_format_scalar(sensitivity)} Pa/V" + @dataclass class Element: index: Annotated[int, OpenLIFUFieldData("Element index", "Element index")] = 0 @@ -88,6 +113,70 @@ def __post_init__(self): elif isinstance(self.sensitivity, list): self.sensitivity = sorted(((float(f), float(v)) for f, v in self.sensitivity), key=lambda t: t[0]) + def __repr__(self) -> str: + pos = ", ".join(_format_scalar(v) for v in self.position) + size = ", ".join(_format_scalar(v) for v in self.size) + return ( + "Element(" + f"index={self.index}, pin={self.pin}, units='{self.units}', " + f"position=[{pos}], size=[{size}], " + f"sensitivity={_format_sensitivity_summary(self.sensitivity)}" + ")" + ) + + def __str__(self) -> str: + el_deg, az_deg, roll_deg = np.degrees([self.el, self.az, self.roll]) + return "\n".join( + [ + f"Element {self.index} (pin {self.pin})", + f" Position [{self.units}]: " + f"x={_format_scalar(self.x)}, y={_format_scalar(self.y)}, z={_format_scalar(self.z)}", + f" Orientation [deg]: " + f"az={_format_scalar(az_deg)}, el={_format_scalar(el_deg)}, roll={_format_scalar(roll_deg)}", + f" Size [{self.units}]: " + f"width={_format_scalar(self.width)}, length={_format_scalar(self.length)}", + f" Sensitivity: {_format_sensitivity_summary(self.sensitivity)}", + ] + ) + + def _repr_pretty_(self, p, cycle: bool) -> None: + if cycle: + p.text("Element(...)") + return + p.text(str(self)) + + def _repr_html_(self) -> str: + el_deg, az_deg, roll_deg = np.degrees([self.el, self.az, self.roll]) + rows = [ + ("Index", str(self.index)), + ("Pin", str(self.pin)), + ("Units", self.units), + ( + "Position", + f"[{_format_scalar(self.x)}, {_format_scalar(self.y)}, {_format_scalar(self.z)}]", + ), + ( + "Orientation (deg)", + f"[{_format_scalar(az_deg)}, {_format_scalar(el_deg)}, {_format_scalar(roll_deg)}]", + ), + ( + "Size", + f"[{_format_scalar(self.width)}, {_format_scalar(self.length)}]", + ), + ("Sensitivity", _format_sensitivity_summary(self.sensitivity)), + ] + row_html = "".join( + f"{html.escape(k)}" + f"{html.escape(v)}" + for k, v in rows + ) + return ( + "
" + "
Element
" + f"{row_html}
" + "
" + ) + @property def x(self): return self.position[0] diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index 28386587..9c2d41d3 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import html import json import logging from dataclasses import dataclass, field @@ -44,6 +45,30 @@ def _combine_sensitivities( else: return float(base_sensitivity) * float(scale_sensitivity) + +def _format_scalar(value: float, precision: int = 3) -> str: + return np.format_float_positional(float(value), precision=precision, trim="-") + + +def _format_sensitivity_summary(sensitivity: float | List[tuple[float, float]]) -> str: + if isinstance(sensitivity, list): + if not sensitivity: + return "[]" + low_f, low_v = sensitivity[0] + high_f, high_v = sensitivity[-1] + if len(sensitivity) == 1: + return ( + f"[{_format_scalar(low_f, precision=0)} Hz: " + f"{_format_scalar(low_v)} Pa/V]" + ) + return ( + f"[{len(sensitivity)} pts, " + f"{_format_scalar(low_f, precision=0)}-" + f"{_format_scalar(high_f, precision=0)} Hz, " + f"{_format_scalar(low_v)}-{_format_scalar(high_v)} Pa/V]" + ) + return f"{_format_scalar(sensitivity)} Pa/V" + @dataclass class Transducer: id: Annotated[str, OpenLIFUFieldData("Transducer ID", "Unique identifier for transducer")] = "transducer" @@ -106,6 +131,71 @@ def __post_init__(self): elif isinstance(self.sensitivity, list): self.sensitivity = sorted(((float(f), float(v)) for f, v in self.sensitivity), key=lambda t: t[0]) + def __repr__(self) -> str: + return ( + "Transducer(" + f"id='{self.id}', name='{self.name}', " + f"elements={self.numelements()}, " + f"frequency={_format_scalar(self.frequency, precision=0)} Hz, " + f"units='{self.units}', " + f"sensitivity={_format_sensitivity_summary(self.sensitivity)}" + ")" + ) + + def __str__(self) -> str: + lines = [ + f"Transducer '{self.name}' ({self.id})", + f" Elements: {self.numelements()}", + f" Frequency: {_format_scalar(self.frequency, precision=0)} Hz", + f" Units: {self.units}", + f" Sensitivity: {_format_sensitivity_summary(self.sensitivity)}", + f" Crosstalk: frac={_format_scalar(self.crosstalk_frac)}, " + f"dist={_format_scalar(self.crosstalk_dist)} {self.units}", + f" Meshes: registration={self.registration_surface_filename}, " + f"body={self.transducer_body_filename}", + ] + if self.attrs: + attr_keys = sorted(str(k) for k in self.attrs) + preview = ", ".join(attr_keys[:5]) + suffix = " ..." if len(attr_keys) > 5 else "" + lines.append(f" Attr Keys ({len(attr_keys)}): {preview}{suffix}") + return "\n".join(lines) + + def _repr_pretty_(self, p, cycle: bool) -> None: + if cycle: + p.text("Transducer(...)") + return + p.text(str(self)) + + def _repr_html_(self) -> str: + rows = [ + ("ID", self.id), + ("Name", self.name), + ("Elements", str(self.numelements())), + ("Frequency", f"{_format_scalar(self.frequency, precision=0)} Hz"), + ("Units", self.units), + ("Sensitivity", _format_sensitivity_summary(self.sensitivity)), + ( + "Crosstalk", + f"frac={_format_scalar(self.crosstalk_frac)}, " + f"dist={_format_scalar(self.crosstalk_dist)} {self.units}", + ), + ("Registration Mesh", str(self.registration_surface_filename)), + ("Body Mesh", str(self.transducer_body_filename)), + ("Attr Keys", ", ".join(sorted(str(k) for k in self.attrs)) or "-"), + ] + row_html = "".join( + f"{html.escape(k)}" + f"{html.escape(v)}" + for k, v in rows + ) + return ( + "
" + "
Transducer
" + f"{row_html}
" + "
" + ) + def calc_output(self, cycles: float, frequency: float, dt: float, delays: np.ndarray = None, apod: np.ndarray = None, amplitude: float = 1.0) -> np.ndarray: if delays is None: delays = np.zeros(self.numelements()) diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index 225f1249..c794dbc4 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import html import json from dataclasses import dataclass, field from typing import Sequence @@ -11,6 +12,15 @@ from openlifu.util.units import getunitconversion from openlifu.xdc import Transducer, TransformedTransducer +try: + from openlifu_sdk.io import LIFUInterface as _SDKLIFUInterface +except ImportError: + _SDKLIFUInterface = None + + +def _format_scalar(value: float, precision: int = 3) -> str: + return np.format_float_positional(float(value), precision=precision, trim="-") + # Mapping from (num_connected_modules, freq_khz) to the canonical # template id consumed by :py:meth:`TransducerArray.get_connected`. _DEFAULT_TEMPLATE_IDS: dict[tuple[int, int], str] = { @@ -125,6 +135,78 @@ class TransducerArray(DictMixin): modules: list[TransformedTransducer] = field(default_factory=list) attrs: dict = field(default_factory=dict) + def __repr__(self) -> str: + total_elements = sum(m.numelements() for m in self.modules) + return ( + "TransducerArray(" + f"id='{self.id}', name='{self.name}', " + f"modules={len(self.modules)}, total_elements={total_elements}" + ")" + ) + + def __str__(self) -> str: + total_elements = sum(m.numelements() for m in self.modules) + lines = [ + f"TransducerArray '{self.name}' ({self.id})", + f" Modules: {len(self.modules)}", + f" Total Elements: {total_elements}", + ] + if self.modules: + module_preview = [m.id for m in self.modules[:4]] + suffix = " ..." if len(self.modules) > 4 else "" + lines.append(f" Module IDs: {', '.join(module_preview)}{suffix}") + lines.append( + " Module HWIDs: " + + ", ".join(str((m.attrs or {}).get("hwid")) for m in self.modules[:4]) + + (" ..." if len(self.modules) > 4 else "") + ) + if self.attrs: + attr_keys = sorted(str(k) for k in self.attrs) + preview = ", ".join(attr_keys[:6]) + lines.append(f" Attr Keys ({len(attr_keys)}): {preview}{' ...' if len(attr_keys) > 6 else ''}") + return "\n".join(lines) + + def _repr_pretty_(self, p, cycle: bool) -> None: + if cycle: + p.text("TransducerArray(...)") + return + p.text(str(self)) + + def _repr_html_(self) -> str: + total_elements = sum(m.numelements() for m in self.modules) + module_rows = "".join( + "" + f"{i}" + f"{html.escape(m.id)}" + f"{m.numelements()}" + f"{html.escape(str((m.attrs or {}).get('hwid')))}" + f"" + f"x={_format_scalar(m.transform[0, 3])}, " + f"y={_format_scalar(m.transform[1, 3])}, " + f"z={_format_scalar(m.transform[2, 3])}" + "" + for i, m in enumerate(self.modules) + ) + attr_text = ", ".join(sorted(str(k) for k in self.attrs)) or "-" + return ( + "
" + "
TransducerArray
" + f"
ID: {html.escape(self.id)} | Name: {html.escape(self.name)}
" + f"
Modules: {len(self.modules)} | Total Elements: {total_elements}
" + f"
Attr Keys: {html.escape(attr_text)}
" + "" + "" + "" + "" + "" + "" + "" + "" + f"{module_rows}" + "
#Module IDElementsHWIDTransform t
" + "
" + ) + def to_transducer(self, offset_pins=True, offset_indices=True): t = Transducer.merge([t.bake() for t in self.modules], offset_pins=offset_pins, offset_indices=offset_indices, merged_attrs=self.attrs) t.name = self.name @@ -430,14 +512,12 @@ def get_connected( A :class:`TransducerArray` representing the connected device. """ if interface is None: - try: - from openlifu_sdk.io import LIFUInterface - except ImportError as exc: + if _SDKLIFUInterface is None: raise ImportError( "openlifu_sdk is required to auto-create a LIFUInterface; " "install it or pass an explicit `interface=` argument." - ) from exc - interface = LIFUInterface() + ) + interface = _SDKLIFUInterface() txdevice = interface.txdevice count = int(txdevice.get_tx_module_count()) @@ -463,7 +543,9 @@ def get_connected( # Resolve a template: prefer db lookup, fall back to embedded transforms. template: TransducerArray | None = None - template_id = _DEFAULT_TEMPLATE_IDS.get((count, freq)) + template_id: str | None = None + if freq is not None: + template_id = _DEFAULT_TEMPLATE_IDS.get((count, int(freq))) if template_id is not None: if db is not None: try: diff --git a/tests/test_transducer.py b/tests/test_transducer.py index f56d25d1..3043a262 100644 --- a/tests/test_transducer.py +++ b/tests/test_transducer.py @@ -703,3 +703,73 @@ def test_transducer_array_get_connected_unknown_combo_no_template(): assert arr.id == "transducer_array" for m in arr.modules: np.testing.assert_allclose(m.transform, np.eye(4)) + + +def test_element_pretty_repr_methods(): + element = Element( + index=7, + pin=11, + position=[1.0, -2.0, 3.0], + size=[4.0, 5.0], + units="mm", + ) + text_repr = repr(element) + assert "Element(" in text_repr + assert "index=7" in text_repr + assert "pin=11" in text_repr + + pretty_text = str(element) + assert "Element 7" in pretty_text + assert "Position [mm]" in pretty_text + + html_repr = element._repr_html_() + assert "" in html_repr + assert "Sensitivity" in html_repr + + +def test_transducer_pretty_repr_methods(): + transducer = Transducer.gen_matrix_array( + nx=2, + ny=2, + pitch=5, + kerf=0.3, + units="mm", + id="demo_tx", + name="Demo TX", + ) + text_repr = repr(transducer) + assert "Transducer(" in text_repr + assert "elements=4" in text_repr + + pretty_text = str(transducer) + assert "Transducer 'Demo TX'" in pretty_text + assert "Elements: 4" in pretty_text + + html_repr = transducer._repr_html_() + assert "
" in html_repr + assert "Frequency" in html_repr + + +def test_transducer_array_pretty_repr_methods(): + module = Transducer.gen_matrix_array(nx=1, ny=1, pitch=5, kerf=0.3, units="mm") + arr = TransducerArray.get_concave_cylinder( + module, + rows=1, + cols=2, + width=40, + gap=0.0, + units="mm", + id="arr_demo", + name="Array Demo", + ) + text_repr = repr(arr) + assert "TransducerArray(" in text_repr + assert "modules=2" in text_repr + + pretty_text = str(arr) + assert "TransducerArray 'Array Demo'" in pretty_text + assert "Modules: 2" in pretty_text + + html_repr = arr._repr_html_() + assert " Date: Tue, 26 May 2026 11:47:42 -0400 Subject: [PATCH 04/23] upgrade repr --- src/openlifu/xdc/element.py | 48 ++++++++++++++++-- src/openlifu/xdc/transducer.py | 57 ++++++++++++++++++++-- src/openlifu/xdc/transducerarray.py | 75 ++++++++++++++++++++++------- tests/test_transducer.py | 4 +- 4 files changed, 155 insertions(+), 29 deletions(-) diff --git a/src/openlifu/xdc/element.py b/src/openlifu/xdc/element.py index 6bb3f6fd..17ce8c54 100644 --- a/src/openlifu/xdc/element.py +++ b/src/openlifu/xdc/element.py @@ -2,6 +2,7 @@ import copy import html +import json from dataclasses import dataclass, field from typing import Annotated, List @@ -166,14 +167,51 @@ def _repr_html_(self) -> str: ("Sensitivity", _format_sensitivity_summary(self.sensitivity)), ] row_html = "".join( - f"" - f"" + "" + f"" + f"" + "" for k, v in rows ) + + sensitivity_section = "" + if isinstance(self.sensitivity, list): + sens_rows = "".join( + "" + f"" + f"" + "" + for freq, value in self.sensitivity + ) + sensitivity_section = ( + "
" + "Sensitivity Table" + "
" + "
{html.escape(k)}{html.escape(v)}
{html.escape(k)}{html.escape(v)}
{_format_scalar(freq, precision=0)}{_format_scalar(value)}
" + "" + "" + "" + "" + f"{sens_rows}" + "
Frequency (Hz)Sensitivity (Pa/V)
" + "" + "" + ) + + raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( - "
" - "
Element
" - f"{row_html}
" + "
" + "
Element
" + "" + f"{row_html}" + "
" + f"{sensitivity_section}" + "
" + "Raw JSON" + "
"
+            f"{raw_json}"
+            "
" + "
" "
" ) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index 9c2d41d3..193c4cfe 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -184,15 +184,62 @@ def _repr_html_(self) -> str: ("Body Mesh", str(self.transducer_body_filename)), ("Attr Keys", ", ".join(sorted(str(k) for k in self.attrs)) or "-"), ] + row_html = "".join( - f"{html.escape(k)}" - f"{html.escape(v)}" + "" + f"{html.escape(k)}" + f"{html.escape(v)}" + "" for k, v in rows ) + + element_rows = "".join( + "" + f"{element.index}" + f"{element.pin}" + f"" + f"[{_format_scalar(element.position[0])}, {_format_scalar(element.position[1])}, {_format_scalar(element.position[2])}]" + "" + f"" + f"[{_format_scalar(element.size[0])}, {_format_scalar(element.size[1])}]" + "" + f"{html.escape(_format_sensitivity_summary(element.sensitivity))}" + "" + for element in self.elements + ) + + elements_section = ( + "
" + f"Elements ({self.numelements()})" + "
" + "" + "" + "" + "" + "" + "" + "" + "" + f"{element_rows}" + "
IndexPinPositionSizeSensitivity
" + "
" + "
" + ) + + raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( - "
" - "
Transducer
" - f"{row_html}
" + "
" + "
Transducer
" + "" + f"{row_html}" + "
" + f"{elements_section}" + "
" + "Raw JSON" + "
"
+            f"{raw_json}"
+            "
" + "
" "
" ) diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index c794dbc4..1fcfb9a6 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -174,37 +174,78 @@ def _repr_pretty_(self, p, cycle: bool) -> None: def _repr_html_(self) -> str: total_elements = sum(m.numelements() for m in self.modules) + summary_rows = [ + ("ID", self.id), + ("Name", self.name), + ("Modules", str(len(self.modules))), + ("Total Elements", str(total_elements)), + ("Attr Keys", ", ".join(sorted(str(k) for k in self.attrs)) or "-"), + ] + summary_html = "".join( + "" + f"{html.escape(k)}" + f"{html.escape(v)}" + "" + for k, v in summary_rows + ) + module_rows = "".join( "" - f"{i}" - f"{html.escape(m.id)}" - f"{m.numelements()}" - f"{html.escape(str((m.attrs or {}).get('hwid')))}" - f"" + f"{i}" + f"{html.escape(m.id)}" + f"{m.numelements()}" + f"{html.escape(str((m.attrs or {}).get('hwid')))}" + f"" f"x={_format_scalar(m.transform[0, 3])}, " f"y={_format_scalar(m.transform[1, 3])}, " f"z={_format_scalar(m.transform[2, 3])}" "" for i, m in enumerate(self.modules) ) - attr_text = ", ".join(sorted(str(k) for k in self.attrs)) or "-" + + module_sections = "".join( + "
" + f"Module {i}: {html.escape(m.id)} ({m.numelements()} elements)" + "
" + f"{m._repr_html_()}" + "
" + "
" + for i, m in enumerate(self.modules) + ) + + raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( - "
" - "
TransducerArray
" - f"
ID: {html.escape(self.id)} | Name: {html.escape(self.name)}
" - f"
Modules: {len(self.modules)} | Total Elements: {total_elements}
" - f"
Attr Keys: {html.escape(attr_text)}
" - "" + "
" + "
TransducerArray
" + "
" + f"{summary_html}" + "
" + "
" + f"Modules ({len(self.modules)})" + "
" + "" "" - "" - "" - "" - "" - "" + "" + "" + "" + "" + "" "" f"{module_rows}" "
#Module IDElementsHWIDTransform t#Module IDElementsHWIDTransform t
" "
" + "
" + "
" + "Module Details" + f"{module_sections}" + "
" + "
" + "Raw JSON" + "
"
+            f"{raw_json}"
+            "
" + "
" + "
" ) def to_transducer(self, offset_pins=True, offset_indices=True): diff --git a/tests/test_transducer.py b/tests/test_transducer.py index 3043a262..56b37450 100644 --- a/tests/test_transducer.py +++ b/tests/test_transducer.py @@ -723,7 +723,7 @@ def test_element_pretty_repr_methods(): assert "Position [mm]" in pretty_text html_repr = element._repr_html_() - assert "" in html_repr + assert "" in html_repr + assert " Date: Tue, 26 May 2026 11:56:37 -0400 Subject: [PATCH 05/23] hybrid repr --- src/openlifu/xdc/element.py | 24 ++++++++-------- src/openlifu/xdc/transducer.py | 38 +++++++++++++------------ src/openlifu/xdc/transducerarray.py | 44 ++++++++++++++++------------- 3 files changed, 57 insertions(+), 49 deletions(-) diff --git a/src/openlifu/xdc/element.py b/src/openlifu/xdc/element.py index 17ce8c54..2176faa1 100644 --- a/src/openlifu/xdc/element.py +++ b/src/openlifu/xdc/element.py @@ -168,8 +168,8 @@ def _repr_html_(self) -> str: ] row_html = "".join( "" - f"" - f"" + f"" + f"" "" for k, v in rows ) @@ -178,19 +178,19 @@ def _repr_html_(self) -> str: if isinstance(self.sensitivity, list): sens_rows = "".join( "" - f"" - f"" + f"" + f"" "" for freq, value in self.sensitivity ) sensitivity_section = ( "
" - "Sensitivity Table" - "
" + "Sensitivity Table" + "
" "
{html.escape(k)}{html.escape(v)}{html.escape(k)}{html.escape(v)}
{_format_scalar(freq, precision=0)}{_format_scalar(value)}{_format_scalar(freq, precision=0)}{_format_scalar(value)}
" "" - "" - "" + "" + "" "" f"{sens_rows}" "
Frequency (Hz)Sensitivity (Pa/V)Frequency (Hz)Sensitivity (Pa/V)
" @@ -201,16 +201,18 @@ def _repr_html_(self) -> str: raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( "
" - "
Element
" + "
Element
" "" f"{row_html}" "
" f"{sensitivity_section}" "
" - "Raw JSON" - "
"
+            "Raw JSON"
+            "
" + "
"
             f"{raw_json}"
             "
" + "
" "
" "
" ) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index 193c4cfe..dda2a361 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -187,38 +187,38 @@ def _repr_html_(self) -> str: row_html = "".join( "" - f"{html.escape(k)}" - f"{html.escape(v)}" + f"{html.escape(k)}" + f"{html.escape(v)}" "" for k, v in rows ) element_rows = "".join( "" - f"{element.index}" - f"{element.pin}" - f"" + f"{element.index}" + f"{element.pin}" + f"" f"[{_format_scalar(element.position[0])}, {_format_scalar(element.position[1])}, {_format_scalar(element.position[2])}]" "" - f"" + f"" f"[{_format_scalar(element.size[0])}, {_format_scalar(element.size[1])}]" "" - f"{html.escape(_format_sensitivity_summary(element.sensitivity))}" + f"{html.escape(_format_sensitivity_summary(element.sensitivity))}" "" for element in self.elements ) elements_section = ( - "
" - f"Elements ({self.numelements()})" - "
" + "
" + f"Elements ({self.numelements()})" + "
" "" "" - "" - "" - "" - "" - "" + "" + "" + "" + "" + "" "" f"{element_rows}" "
IndexPinPositionSizeSensitivityIndexPinPositionSizeSensitivity
" @@ -229,16 +229,18 @@ def _repr_html_(self) -> str: raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( "
" - "
Transducer
" + "
Transducer
" "" f"{row_html}" "
" f"{elements_section}" "
" - "Raw JSON" - "
"
+            "Raw JSON"
+            "
" + "
"
             f"{raw_json}"
             "
" + "
" "
" "
" ) diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index 1fcfb9a6..869d9ec4 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -183,19 +183,19 @@ def _repr_html_(self) -> str: ] summary_html = "".join( "" - f"{html.escape(k)}" - f"{html.escape(v)}" + f"{html.escape(k)}" + f"{html.escape(v)}" "" for k, v in summary_rows ) module_rows = "".join( "" - f"{i}" - f"{html.escape(m.id)}" - f"{m.numelements()}" - f"{html.escape(str((m.attrs or {}).get('hwid')))}" - f"" + f"{i}" + f"{html.escape(m.id)}" + f"{m.numelements()}" + f"{html.escape(str((m.attrs or {}).get('hwid')))}" + f"" f"x={_format_scalar(m.transform[0, 3])}, " f"y={_format_scalar(m.transform[1, 3])}, " f"z={_format_scalar(m.transform[2, 3])}" @@ -206,7 +206,7 @@ def _repr_html_(self) -> str: module_sections = "".join( "
" f"Module {i}: {html.escape(m.id)} ({m.numelements()} elements)" - "
" + "
" f"{m._repr_html_()}" "
" "
" @@ -216,34 +216,38 @@ def _repr_html_(self) -> str: raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( "
" - "
TransducerArray
" + "
TransducerArray
" "" f"{summary_html}" "
" - "
" - f"Modules ({len(self.modules)})" - "
" + "
" + f"Modules ({len(self.modules)})" + "
" "" "" - "" - "" - "" - "" - "" + "" + "" + "" + "" + "" "" f"{module_rows}" "
#Module IDElementsHWIDTransform t#Module IDElementsHWIDTransform t
" "
" "
" "
" - "Module Details" + "Module Details" + "
" f"{module_sections}" + "
" "
" "
" - "Raw JSON" - "
"
+            "Raw JSON"
+            "
" + "
"
             f"{raw_json}"
             "
" + "
" "
" "
" ) From 6cbeb5214d23488eff807d04fb21a0bd4bcbd01f Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 26 May 2026 12:06:13 -0400 Subject: [PATCH 06/23] fix standoff transform --- src/openlifu/xdc/transducer.py | 9 ++++++++- tests/test_transducer.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index dda2a361..e3f7b2de 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -120,10 +120,16 @@ class Transducer: module_invert: Annotated[List[bool], OpenLIFUFieldData("Invert polarity", "Whether to invert the polarity of the transducer output, per module")] = field(default_factory=lambda: [False]) """Whether to invert the polarity of the transducer output""" + def _normalize_standoff_transform(self) -> None: + self.standoff_transform = np.array(self.standoff_transform, dtype=float) + if self.standoff_transform.shape != (4, 4): + raise ValueError("standoff_transform must be a 4x4 matrix.") + def __post_init__(self): logging.info("Initializing transducer array") if self.name == "": self.name = self.id + self._normalize_standoff_transform() for element in self.elements: element.rescale(self.units) if self.sensitivity is None: @@ -427,6 +433,7 @@ def merge(list_of_transducers:List[Transducer], offset_pins:bool=False, offset_i merged_array.module_invert += xform_array.module_invert for k, v in merged_attrs.items(): merged_array.__setattr__(k, v) + merged_array._normalize_standoff_transform() return merged_array def numelements(self): @@ -451,7 +458,7 @@ def sort_by_pin(self): def to_dict(self): d = self.__dict__.copy() d["elements"] = [element.to_dict() for element in d["elements"]] - d["standoff_transform"] = d["standoff_transform"].tolist() + d["standoff_transform"] = np.array(d["standoff_transform"], dtype=float).tolist() return d def to_file(self, filename): diff --git a/tests/test_transducer.py b/tests/test_transducer.py index 56b37450..c1e2c171 100644 --- a/tests/test_transducer.py +++ b/tests/test_transducer.py @@ -117,6 +117,28 @@ def test_transducer_array_to_transducer_data_types(transducer_array_id): assert isinstance(transducer.elements[0], Element) +def test_transducer_array_to_transducer_normalizes_list_standoff_transform_for_repr(): + transducer = Transducer.gen_matrix_array(nx=1, ny=1, pitch=5, kerf=0.3, units="mm") + arr = TransducerArray.get_concave_cylinder( + transducer, + rows=1, + cols=1, + width=40, + gap=0.0, + units="mm", + attrs={ + # Reproduces get_connected/device-config path where this can be a list. + "standoff_transform": np.eye(4).tolist(), + }, + ) + merged = arr.to_transducer() + assert isinstance(merged.standoff_transform, np.ndarray) + np.testing.assert_allclose(merged.standoff_transform, np.eye(4)) + # Regression guard: rich repr serialization should not raise. + html_repr = merged._repr_html_() + assert "Raw JSON" in html_repr + + def test_transducer_calc_output_interpolates_dictionary_sensitivity(): transducer = Transducer.gen_matrix_array( nx=1, From 393c4330a9d967a61bd9a1881611cfd1e43a8a9a Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 26 May 2026 12:11:17 -0400 Subject: [PATCH 07/23] tidy repr --- src/openlifu/xdc/element.py | 12 +----------- src/openlifu/xdc/transducer.py | 13 ++----------- src/openlifu/xdc/transducerarray.py | 13 ++----------- tests/test_transducer.py | 2 +- 4 files changed, 6 insertions(+), 34 deletions(-) diff --git a/src/openlifu/xdc/element.py b/src/openlifu/xdc/element.py index 2176faa1..990af055 100644 --- a/src/openlifu/xdc/element.py +++ b/src/openlifu/xdc/element.py @@ -2,7 +2,6 @@ import copy import html -import json from dataclasses import dataclass, field from typing import Annotated, List @@ -198,22 +197,13 @@ def _repr_html_(self) -> str: "
" ) - raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( "
" "
Element
" - "" + "
" f"{row_html}" "
" f"{sensitivity_section}" - "
" - "Raw JSON" - "
" - "
"
-            f"{raw_json}"
-            "
" - "
" - "
" "
" ) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index e3f7b2de..9f7cad54 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -218,7 +218,7 @@ def _repr_html_(self) -> str: "
" f"Elements ({self.numelements()})" "
" - "" + "
" "" "" "" @@ -232,22 +232,13 @@ def _repr_html_(self) -> str: "" ) - raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( "
" "
Transducer
" - "
IndexPin
" + "
" f"{row_html}" "
" f"{elements_section}" - "
" - "Raw JSON" - "
" - "
"
-            f"{raw_json}"
-            "
" - "
" - "
" "
" ) diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index 869d9ec4..518e2526 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -213,17 +213,16 @@ def _repr_html_(self) -> str: for i, m in enumerate(self.modules) ) - raw_json = html.escape(json.dumps(self.to_dict(), indent=2)) return ( "
" "
TransducerArray
" - "" + "
" f"{summary_html}" "
" "
" f"Modules ({len(self.modules)})" "
" - "" + "
" "" "" "" @@ -241,14 +240,6 @@ def _repr_html_(self) -> str: f"{module_sections}" "" "" - "
" - "Raw JSON" - "
" - "
"
-            f"{raw_json}"
-            "
" - "
" - "
" "" ) diff --git a/tests/test_transducer.py b/tests/test_transducer.py index c1e2c171..f337a9f8 100644 --- a/tests/test_transducer.py +++ b/tests/test_transducer.py @@ -136,7 +136,7 @@ def test_transducer_array_to_transducer_normalizes_list_standoff_transform_for_r np.testing.assert_allclose(merged.standoff_transform, np.eye(4)) # Regression guard: rich repr serialization should not raise. html_repr = merged._repr_html_() - assert "Raw JSON" in html_repr + assert "Elements" in html_repr def test_transducer_calc_output_interpolates_dictionary_sensitivity(): From a56c2dd2c7adefcc3d0701e526cd5bcc689cf7c7 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 26 May 2026 12:20:32 -0400 Subject: [PATCH 08/23] tidier --- src/openlifu/xdc/element.py | 71 +++++++++++++------------ src/openlifu/xdc/transducer.py | 82 ++++++++++++----------------- src/openlifu/xdc/transducerarray.py | 77 +++++++++------------------ tests/test_transducer.py | 8 +-- 4 files changed, 102 insertions(+), 136 deletions(-) diff --git a/src/openlifu/xdc/element.py b/src/openlifu/xdc/element.py index 990af055..d64d1a16 100644 --- a/src/openlifu/xdc/element.py +++ b/src/openlifu/xdc/element.py @@ -147,62 +147,65 @@ def _repr_pretty_(self, p, cycle: bool) -> None: def _repr_html_(self) -> str: el_deg, az_deg, roll_deg = np.degrees([self.el, self.az, self.roll]) - rows = [ - ("Index", str(self.index)), - ("Pin", str(self.pin)), - ("Units", self.units), - ( + def line(label: str, value_html: str) -> str: + return ( + "
" + f"{html.escape(label)}: " + f"{value_html}" + "
" + ) + + summary_lines = [ + line("Index", html.escape(str(self.index))), + line("Pin", html.escape(str(self.pin))), + line("Units", html.escape(self.units)), + line( "Position", - f"[{_format_scalar(self.x)}, {_format_scalar(self.y)}, {_format_scalar(self.z)}]", + html.escape( + f"[{_format_scalar(self.x)}, {_format_scalar(self.y)}, {_format_scalar(self.z)}]" + ), ), - ( + line( "Orientation (deg)", - f"[{_format_scalar(az_deg)}, {_format_scalar(el_deg)}, {_format_scalar(roll_deg)}]", + html.escape( + f"[{_format_scalar(az_deg)}, {_format_scalar(el_deg)}, {_format_scalar(roll_deg)}]" + ), ), - ( + line( "Size", - f"[{_format_scalar(self.width)}, {_format_scalar(self.length)}]", + html.escape(f"[{_format_scalar(self.width)}, {_format_scalar(self.length)}]"), ), - ("Sensitivity", _format_sensitivity_summary(self.sensitivity)), ] - row_html = "".join( - "
" - f"" - f"" - "" - for k, v in rows - ) sensitivity_section = "" if isinstance(self.sensitivity, list): sens_rows = "".join( - "" - f"" - f"" - "" + "
" + f"{_format_scalar(freq, precision=0)} Hz" + f"{_format_scalar(value)} Pa/V" + "
" for freq, value in self.sensitivity ) sensitivity_section = ( - "
" - "Sensitivity Table" + "
" + f"" + f"Sensitivity: {html.escape(_format_sensitivity_summary(self.sensitivity))}" + "" "
" - "
#Module ID
{html.escape(k)}{html.escape(v)}
{_format_scalar(freq, precision=0)}{_format_scalar(value)}
" - "" - "" - "" - "" - f"{sens_rows}" - "
Frequency (Hz)Sensitivity (Pa/V)
" + f"{sens_rows}" "
" "
" ) + else: + sensitivity_section = line( + "Sensitivity", + html.escape(_format_sensitivity_summary(self.sensitivity)), + ) return ( "
" "
Element
" - "" - f"{row_html}" - "
" + f"{''.join(summary_lines)}" f"{sensitivity_section}" "
" ) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index 9f7cad54..379c1279 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -174,60 +174,50 @@ def _repr_pretty_(self, p, cycle: bool) -> None: p.text(str(self)) def _repr_html_(self) -> str: - rows = [ - ("ID", self.id), - ("Name", self.name), - ("Elements", str(self.numelements())), - ("Frequency", f"{_format_scalar(self.frequency, precision=0)} Hz"), - ("Units", self.units), - ("Sensitivity", _format_sensitivity_summary(self.sensitivity)), - ( + def line(label: str, value_html: str) -> str: + return ( + "
" + f"{html.escape(label)}: " + f"{value_html}" + "
" + ) + + summary_lines = [ + line("ID", html.escape(self.id)), + line("Name", html.escape(self.name)), + line("Frequency", html.escape(f"{_format_scalar(self.frequency, precision=0)} Hz")), + line("Units", html.escape(self.units)), + line("Sensitivity", html.escape(_format_sensitivity_summary(self.sensitivity))), + line( "Crosstalk", - f"frac={_format_scalar(self.crosstalk_frac)}, " - f"dist={_format_scalar(self.crosstalk_dist)} {self.units}", + html.escape( + f"frac={_format_scalar(self.crosstalk_frac)}, " + f"dist={_format_scalar(self.crosstalk_dist)} {self.units}" + ), ), - ("Registration Mesh", str(self.registration_surface_filename)), - ("Body Mesh", str(self.transducer_body_filename)), - ("Attr Keys", ", ".join(sorted(str(k) for k in self.attrs)) or "-"), + line("Registration Mesh", html.escape(str(self.registration_surface_filename))), + line("Body Mesh", html.escape(str(self.transducer_body_filename))), + line("Attr Keys", html.escape(", ".join(sorted(str(k) for k in self.attrs)) or "-")), ] - row_html = "".join( - "" - f"{html.escape(k)}" - f"{html.escape(v)}" - "" - for k, v in rows - ) - element_rows = "".join( - "" - f"{element.index}" - f"{element.pin}" - f"" - f"[{_format_scalar(element.position[0])}, {_format_scalar(element.position[1])}, {_format_scalar(element.position[2])}]" - "" - f"" - f"[{_format_scalar(element.size[0])}, {_format_scalar(element.size[1])}]" - "" - f"{html.escape(_format_sensitivity_summary(element.sensitivity))}" - "" + "
" + f"#{element.index}" + f"pin {element.pin}" + f"pos [{_format_scalar(element.position[0])}, {_format_scalar(element.position[1])}, {_format_scalar(element.position[2])}]" + f"size [{_format_scalar(element.size[0])}, {_format_scalar(element.size[1])}]" + f"{html.escape(_format_sensitivity_summary(element.sensitivity))}" + "
" for element in self.elements ) elements_section = ( - "
" - f"Elements ({self.numelements()})" + "
" + f"" + f"Elements: {self.numelements()}" + "" "
" - "" - "" - "" - "" - "" - "" - "" - "" - f"{element_rows}" - "
IndexPinPositionSizeSensitivity
" + f"{element_rows}" "
" "
" ) @@ -235,9 +225,7 @@ def _repr_html_(self) -> str: return ( "
" "
Transducer
" - "" - f"{row_html}" - "
" + f"{''.join(summary_lines)}" f"{elements_section}" "
" ) diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index 518e2526..7da66812 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -174,38 +174,28 @@ def _repr_pretty_(self, p, cycle: bool) -> None: def _repr_html_(self) -> str: total_elements = sum(m.numelements() for m in self.modules) - summary_rows = [ - ("ID", self.id), - ("Name", self.name), - ("Modules", str(len(self.modules))), - ("Total Elements", str(total_elements)), - ("Attr Keys", ", ".join(sorted(str(k) for k in self.attrs)) or "-"), + def line(label: str, value_html: str) -> str: + return ( + "
" + f"{html.escape(label)}: " + f"{value_html}" + "
" + ) + + summary_lines = [ + line("ID", html.escape(self.id)), + line("Name", html.escape(self.name)), + line("Total Elements", html.escape(str(total_elements))), + line("Attr Keys", html.escape(", ".join(sorted(str(k) for k in self.attrs)) or "-")), ] - summary_html = "".join( - "" - f"{html.escape(k)}" - f"{html.escape(v)}" - "" - for k, v in summary_rows - ) module_rows = "".join( - "" - f"{i}" - f"{html.escape(m.id)}" - f"{m.numelements()}" - f"{html.escape(str((m.attrs or {}).get('hwid')))}" - f"" - f"x={_format_scalar(m.transform[0, 3])}, " - f"y={_format_scalar(m.transform[1, 3])}, " - f"z={_format_scalar(m.transform[2, 3])}" - "" - for i, m in enumerate(self.modules) - ) - - module_sections = "".join( - "
" - f"Module {i}: {html.escape(m.id)} ({m.numelements()} elements)" + "
" + f"" + f"Module {i}: {html.escape(m.id)} | {m.numelements()} elements | " + f"HWID={html.escape(str((m.attrs or {}).get('hwid')))} | " + f"t=[{_format_scalar(m.transform[0, 3])}, {_format_scalar(m.transform[1, 3])}, {_format_scalar(m.transform[2, 3])}]" + "" "
" f"{m._repr_html_()}" "
" @@ -216,28 +206,13 @@ def _repr_html_(self) -> str: return ( "
" "
TransducerArray
" - "" - f"{summary_html}" - "
" - "
" - f"Modules ({len(self.modules)})" - "
" - "" - "" - "" - "" - "" - "" - "" - "" - f"{module_rows}" - "
#Module IDElementsHWIDTransform t
" - "
" - "
" - "
" - "Module Details" - "
" - f"{module_sections}" + f"{''.join(summary_lines)}" + "
" + f"" + f"Modules: {len(self.modules)}" + "" + "
" + f"{module_rows}" "
" "
" "
" diff --git a/tests/test_transducer.py b/tests/test_transducer.py index f337a9f8..57bb5b50 100644 --- a/tests/test_transducer.py +++ b/tests/test_transducer.py @@ -745,7 +745,7 @@ def test_element_pretty_repr_methods(): assert "Position [mm]" in pretty_text html_repr = element._repr_html_() - assert " Date: Wed, 27 May 2026 13:14:08 -0700 Subject: [PATCH 09/23] use device config if available --- notebooks/get_all_versions.py | 112 +++++++++++++ src/openlifu/xdc/__init__.py | 8 +- src/openlifu/xdc/transducerarray.py | 85 +++++++++- tests/test_transducer_array_device_config.py | 158 +++++++++++++++++++ 4 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 notebooks/get_all_versions.py create mode 100644 tests/test_transducer_array_device_config.py diff --git a/notebooks/get_all_versions.py b/notebooks/get_all_versions.py new file mode 100644 index 00000000..bc64f083 --- /dev/null +++ b/notebooks/get_all_versions.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import logging +import os +import sys +import threading +import time +from pathlib import Path + +if os.name == 'nt': + import msvcrt +else: + import select + +import numpy as np + +from openlifu.bf.pulse import Pulse +from openlifu.bf.sequence import Sequence +from openlifu.db import Database +from openlifu.geo import Point +from openlifu.io.LIFUInterface import LIFUInterface +from openlifu.plan.solution import Solution + +# set PYTHONPATH=%cd%\src;%PYTHONPATH% +# python notebooks/test_watertank.py + +""" +Test script to automate: +1. Connect to the device. +2. Test HVController: Turn HV on/off and check voltage. +3. Test Device functionality. +""" + +# TO BE USED TO MONITOR TEMPERATURE CURVE TO SEE HOW LONG IT TAKES TO COOL DOWN + +# Configure logging +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +# Prevent duplicate handlers and cluttered terminal output +if not logger.hasHandlers(): + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) + logger.addHandler(handler) + logger.propagate = False + +log_interval = 1 # seconds; you can adjust this variable as needed +num_modules = 2 # Number of modules in the system + +use_external_power_supply = False # Select whether to use console or power supply + +logger.info("Starting LIFU Test Script...") +interface = LIFUInterface(ext_power_supply=use_external_power_supply) +tx_connected, hv_connected = interface.is_device_connected() + +if not use_external_power_supply and not tx_connected: + logger.warning("TX device not connected. Attempting to turn on 12V...") + interface.hvcontroller.turn_12v_on() + + # Give time for the TX device to power up and enumerate over USB + time.sleep(2) + + # Cleanup and recreate interface to reinitialize USB devices + interface.stop_monitoring() + del interface + time.sleep(1) # Short delay before recreating + + logger.info("Reinitializing LIFU interface after powering 12V...") + interface = LIFUInterface(ext_power_supply=use_external_power_supply) + + # Re-check connection + tx_connected, hv_connected = interface.is_device_connected() + +if not use_external_power_supply: + if hv_connected: + logger.info(f" HV Connected: {hv_connected}") + else: + logger.error("❌ HV NOT fully connected.") + sys.exit(1) +else: + logger.info(" Using external power supply") + +if tx_connected: + logger.info(f" TX Connected: {tx_connected}") + logger.info("✅ LIFU Device fully connected.") +else: + logger.error("❌ TX NOT fully connected.") + sys.exit(1) + +# Verify communication with the devices +if not interface.txdevice.ping(): + logger.error("Failed to ping the transmitter device.") + sys.exit(1) + +if not use_external_power_supply and not interface.hvcontroller.ping(): + logger.error("Failed to ping the console device.") + sys.exit(1) + +print(f"console version: {interface.hvcontroller.get_version()}") + +logger.info("Enumerate TX7332 chips") +# num_tx_devices = interface.txdevice.get_tx_module_count() +num_tx_devices = 10 + +for module in range(0, num_tx_devices+1): + try: + tx_firmware_version = interface.txdevice.get_version(module=module) + logger.info(f"TX Firmware Version: {tx_firmware_version}") + except Exception as e: + logger.error(f"Error querying TX firmware version: {e}") + + diff --git a/src/openlifu/xdc/__init__.py b/src/openlifu/xdc/__init__.py index 1e941740..9f8f725f 100644 --- a/src/openlifu/xdc/__init__.py +++ b/src/openlifu/xdc/__init__.py @@ -2,7 +2,12 @@ from .element import Element from .transducer import Transducer, TransformedTransducer -from .transducerarray import TransducerArray, get_angle_from_gap, get_roc_from_angle +from .transducerarray import ( + DeviceConfigMismatchError, + TransducerArray, + get_angle_from_gap, + get_roc_from_angle, +) __all__ = [ "element", @@ -11,6 +16,7 @@ "Transducer", "TransformedTransducer", "TransducerArray", + "DeviceConfigMismatchError", "get_angle_from_gap", "get_roc_from_angle" ] diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index 7da66812..f689ae01 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -95,6 +95,59 @@ def _format_scalar(value: float, precision: int = 3) -> str: } +class DeviceConfigMismatchError(ValueError): + """Raised when a stored ``device`` block does not match the connected hardware. + + The ``device`` block (typically stored on the lead module's ``user_config`` + via :py:meth:`TransducerArray.to_device_config` or written by the test app's + "Add Device Configuration" dialog) records the expected module count and + each module's ``hwid``. :py:meth:`TransducerArray.get_connected` validates + the connected hardware against that block before assembling an array. + """ + + +def _validate_device_config_against_connected( + device_cfg: dict, + user_configs: Sequence[dict], +) -> None: + """Check a stored ``device`` block matches the connected modules. + + Validates module count and, when ``hwid`` values are recorded on both + sides, that the set of HWIDs matches (order-insensitive). Module entries + in ``device_cfg`` without an ``hwid`` field are skipped for the HWID + comparison so partially-populated configs do not falsely fail. + + Raises: + DeviceConfigMismatchError: if the count or the HWID sets disagree. + """ + expected_modules = list(device_cfg.get("modules") or []) + if len(expected_modules) != len(user_configs): + raise DeviceConfigMismatchError( + f"Device config '{device_cfg.get('id')}' lists {len(expected_modules)} " + f"module(s) but {len(user_configs)} module(s) are connected." + ) + + expected_hwids = { + m["hwid"] + for m in expected_modules + if isinstance(m, dict) and m.get("hwid") + } + if not expected_hwids: + # No HWIDs to compare; count match alone is acceptable. + return + connected_hwids = { + c.get("hwid") for c in user_configs if c.get("hwid") + } + missing = expected_hwids - connected_hwids + extra = connected_hwids - expected_hwids + if missing or extra: + raise DeviceConfigMismatchError( + f"Device config '{device_cfg.get('id')}' HWIDs do not match connected " + f"hardware. Missing from connected: {sorted(missing)!r}; " + f"unexpected on connected: {sorted(extra)!r}." + ) + + def _build_meshless_default_template(template_id: str) -> TransducerArray: """Build a meshless template :class:`TransducerArray` from embedded transforms.""" spec = _DEFAULT_TEMPLATE_DATA[template_id] @@ -479,9 +532,19 @@ def get_connected( ) -> TransducerArray: """Read ``user_config`` from every connected TX module and build a :class:`TransducerArray`. - Picks a default template based on the number of connected modules - and the per-module ``freq`` value (which must agree across modules - when more than one is connected). The mapping is: + If the lead module's ``user_config`` contains a ``device`` block + (typically written by :py:meth:`to_device_config` or by the test app's + "Add Device Configuration" dialog), it is validated against the + connected hardware first: the number of modules listed must match the + number of connected modules, and the recorded base58 ``hwid`` values + must match the ones reported by the connected modules. A mismatch + raises :class:`DeviceConfigMismatchError`. When the ``device`` block + carries a ``"template"`` field, that template id is preferred for the + ``db`` lookup over the default ``(n_modules, freq)`` mapping below. + + Otherwise, picks a default template based on the number of connected + modules and the per-module ``freq`` value (which must agree across + modules when more than one is connected). The mapping is: ====================== ===================== ``(n_modules, freq)`` template id @@ -552,10 +615,22 @@ def get_connected( ) freq = next(iter(freqs)) if freqs else None + # If the lead module's user_config contains a ``device`` block, + # validate it against the connected hardware *before* doing anything + # else, and prefer its recorded template id over the (count, freq) + # default mapping. + device_cfg = user_configs[0].get("device") or None + device_template_id: str | None = None + if device_cfg: + _validate_device_config_against_connected(device_cfg, user_configs) + tid = device_cfg.get("template") + if isinstance(tid, str) and tid: + device_template_id = tid + # Resolve a template: prefer db lookup, fall back to embedded transforms. template: TransducerArray | None = None - template_id: str | None = None - if freq is not None: + template_id: str | None = device_template_id + if template_id is None and freq is not None: template_id = _DEFAULT_TEMPLATE_IDS.get((count, int(freq))) if template_id is not None: if db is not None: diff --git a/tests/test_transducer_array_device_config.py b/tests/test_transducer_array_device_config.py new file mode 100644 index 00000000..d6afc22b --- /dev/null +++ b/tests/test_transducer_array_device_config.py @@ -0,0 +1,158 @@ +"""Tests for the device-config validation + template-override behavior of +:py:meth:`openlifu.xdc.TransducerArray.get_connected` / ``from_module_user_configs``. +""" +from __future__ import annotations + +from types import SimpleNamespace +from typing import Sequence + +import pytest + +from openlifu.xdc import DeviceConfigMismatchError, TransducerArray +from openlifu.xdc.transducerarray import ( + _DEFAULT_TEMPLATE_IDS, + _validate_device_config_against_connected, +) + + +def _module_user_config(hwid: str, freq: int = 400, nx: int = 2, ny: int = 2) -> dict: + """Minimal user_config dict accepted by ``Transducer.from_module_user_config``.""" + return { + "hwid": hwid, + "freq": freq, + "module": { + "nx": nx, + "ny": ny, + "pitch": 1.0, + "kerf": 0.0, + "units": "mm", + "frequency": freq * 1000.0, + }, + } + + +class _FakeTxDevice: + def __init__(self, user_configs: Sequence[dict]): + self._user_configs = list(user_configs) + + def get_tx_module_count(self) -> int: + return len(self._user_configs) + + def read_config(self, module: int): + cfg = self._user_configs[module] + # Mimic the SDK's read_config object that has a .get_json_str() method. + import json + return SimpleNamespace(get_json_str=lambda c=cfg: json.dumps(c)) + + +def _fake_interface(user_configs: Sequence[dict]): + return SimpleNamespace(txdevice=_FakeTxDevice(user_configs)) + + +# ---------- _validate_device_config_against_connected ---------- + +def test_validate_matches_when_count_and_hwids_agree(): + device_cfg = { + "id": "dev", + "modules": [{"hwid": "AAA"}, {"hwid": "BBB"}], + } + user_configs = [_module_user_config("AAA"), _module_user_config("BBB")] + # Order-insensitive: should also pass with swapped hardware order. + _validate_device_config_against_connected(device_cfg, user_configs) + _validate_device_config_against_connected(device_cfg, list(reversed(user_configs))) + + +def test_validate_raises_on_count_mismatch(): + device_cfg = {"id": "dev", "modules": [{"hwid": "AAA"}, {"hwid": "BBB"}]} + with pytest.raises(DeviceConfigMismatchError, match="lists 2 module"): + _validate_device_config_against_connected(device_cfg, [_module_user_config("AAA")]) + + +def test_validate_raises_on_hwid_mismatch(): + device_cfg = {"id": "dev", "modules": [{"hwid": "AAA"}, {"hwid": "BBB"}]} + user_configs = [_module_user_config("AAA"), _module_user_config("ZZZ")] + with pytest.raises(DeviceConfigMismatchError, match="HWIDs do not match"): + _validate_device_config_against_connected(device_cfg, user_configs) + + +def test_validate_skips_hwid_check_when_device_cfg_has_no_hwids(): + # Count matches; device cfg omits hwids. Should not raise. + device_cfg = {"id": "dev", "modules": [{}, {}]} + user_configs = [_module_user_config("AAA"), _module_user_config("BBB")] + _validate_device_config_against_connected(device_cfg, user_configs) + + +# ---------- get_connected: template selection + validation ---------- + +def test_get_connected_uses_device_template_when_present(): + user_configs = [ + { + **_module_user_config("AAA", freq=400), + "device": { + "id": "my-array", + "name": "My Array", + "template": "openlifu_1x155", # deliberately != (1, 400) default + "modules": [{"hwid": "AAA"}], + "attrs": {}, + }, + } + ] + arr = TransducerArray.get_connected( + interface=_fake_interface(user_configs), + db=None, + use_default_template=True, + ) + assert arr.id == "my-array" + assert arr.name == "My Array" + # The template id used to look up the meshless default should be the + # device-specified one (1x155), not the (1, 400) default. + assert "standoff_transform" in arr.attrs + + +def test_get_connected_falls_back_to_count_freq_mapping_without_device(): + user_configs = [_module_user_config("AAA", freq=400)] + arr = TransducerArray.get_connected( + interface=_fake_interface(user_configs), + db=None, + use_default_template=True, + ) + # Default (1, 400) -> openlifu_1x400. + assert arr.id == _DEFAULT_TEMPLATE_IDS[(1, 400)] + + +def test_get_connected_validates_hwid_mismatch(): + user_configs = [ + { + **_module_user_config("AAA"), + "device": { + "id": "dev", + "modules": [{"hwid": "ZZZ"}], + "template": "openlifu_1x400", + }, + } + ] + with pytest.raises(DeviceConfigMismatchError): + TransducerArray.get_connected( + interface=_fake_interface(user_configs), + db=None, + use_default_template=True, + ) + + +def test_get_connected_validates_count_mismatch(): + user_configs = [ + { + **_module_user_config("AAA"), + "device": { + "id": "dev", + "modules": [{"hwid": "AAA"}, {"hwid": "BBB"}], # expects 2 + "template": "openlifu_2x400", + }, + }, + ] + with pytest.raises(DeviceConfigMismatchError, match="lists 2 module"): + TransducerArray.get_connected( + interface=_fake_interface(user_configs), + db=None, + use_default_template=True, + ) From 153107fa91aa776fea907332c2f811dd878a0f40 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Wed, 27 May 2026 13:40:44 -0700 Subject: [PATCH 10/23] checking against db --- src/openlifu/xdc/__init__.py | 2 + src/openlifu/xdc/transducerarray.py | 82 +++++++++++++++++++- tests/test_transducer_array_device_config.py | 75 ++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/openlifu/xdc/__init__.py b/src/openlifu/xdc/__init__.py index 9f8f725f..b6c4e718 100644 --- a/src/openlifu/xdc/__init__.py +++ b/src/openlifu/xdc/__init__.py @@ -5,6 +5,7 @@ from .transducerarray import ( DeviceConfigMismatchError, TransducerArray, + arrays_structurally_equal, get_angle_from_gap, get_roc_from_angle, ) @@ -17,6 +18,7 @@ "TransformedTransducer", "TransducerArray", "DeviceConfigMismatchError", + "arrays_structurally_equal", "get_angle_from_gap", "get_roc_from_angle" ] diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index f689ae01..ed431b8c 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -3,6 +3,8 @@ import copy import html import json +import os +import warnings from dataclasses import dataclass, field from typing import Sequence @@ -159,6 +161,61 @@ def _build_meshless_default_template(template_id: str) -> TransducerArray: return TransducerArray(id=template_id, name=spec["name"], modules=modules, attrs=attrs) +def _canonicalize_array_for_compare(arr: TransducerArray) -> dict: + """Produce a structure suitable for equality-comparing two :class:`TransducerArray`. + + Normalizations applied: + + * NumPy arrays are converted to nested lists and rounded so trivial + floating-point noise does not trigger spurious mismatches. + * Mesh filename fields (``registration_surface_filename``, + ``transducer_body_filename``) are reduced to their basename so absolute + vs database-relative paths are treated as equivalent. + * Fields that legitimately vary between a reconstructed array and a + database-stored one (e.g. ``impulse_response`` / ``impulse_dt`` from + calibration) are dropped. + + Used by :py:meth:`TransducerArray.get_connected` to warn when the array + assembled from connected hardware disagrees with the same-id array in + the supplied database. + """ + def _norm(obj): + if isinstance(obj, np.ndarray): + return _norm(obj.tolist()) + if isinstance(obj, (list, tuple)): + return [_norm(x) for x in obj] + if isinstance(obj, dict): + return {k: _norm(v) for k, v in obj.items()} + if isinstance(obj, float): + return round(obj, 6) + return obj + + raw = _norm(arr.to_dict()) + # Strip per-module fields that do not need to round-trip identically. + for m in raw.get("modules", []): + for k in ("registration_surface_filename", "transducer_body_filename"): + v = m.get(k) + if isinstance(v, str) and v: + m[k] = os.path.basename(v) + attrs = m.get("attrs") or {} + attrs.pop("impulse_response", None) + attrs.pop("impulse_dt", None) + # Strip array-level mesh paths likewise. + arr_attrs = raw.get("attrs") or {} + for k in ("registration_surface_filename", "transducer_body_filename"): + v = arr_attrs.get(k) + if isinstance(v, str) and v: + arr_attrs[k] = os.path.basename(v) + arr_attrs.pop("impulse_response", None) + arr_attrs.pop("impulse_dt", None) + return raw + + +def arrays_structurally_equal(a: TransducerArray, b: TransducerArray) -> bool: + """Return ``True`` if two arrays are equal after :func:`_canonicalize_array_for_compare`.""" + return _canonicalize_array_for_compare(a) == _canonicalize_array_for_compare(b) + + def get_angle_from_gap(width, gap, roc): a = roc b = width/2 @@ -643,7 +700,7 @@ def get_connected( if template is None and use_default_template and template_id in _DEFAULT_TEMPLATE_DATA: template = _build_meshless_default_template(template_id) - return cls.from_module_user_configs( + arr = cls.from_module_user_configs( user_configs, template=template, module_transforms=module_transforms, @@ -651,6 +708,29 @@ def get_connected( arr_name=arr_name, ) + # If a database was supplied and an array with the resolved id is + # already stored there, compare the two and warn on a mismatch so + # callers (e.g. the SlicerOpenLIFU Transducer Manager) can surface + # a UI prompt. + if db is not None: + try: + known_ids = list(db.get_transducer_ids() or []) + except Exception: + known_ids = [] + if arr.id in known_ids: + try: + db_arr = db.load_transducer(arr.id, convert_array=False) + except Exception: + db_arr = None + if isinstance(db_arr, TransducerArray) and not arrays_structurally_equal(arr, db_arr): + warnings.warn( + f"Connected transducer '{arr.id}' differs from the version " + f"stored in the database. The database version was not used.", + stacklevel=2, + ) + + return arr + def to_device_config(self) -> dict: """Serialize array-level info to a ``device`` dict for the lead module's user_config. diff --git a/tests/test_transducer_array_device_config.py b/tests/test_transducer_array_device_config.py index d6afc22b..4340ddab 100644 --- a/tests/test_transducer_array_device_config.py +++ b/tests/test_transducer_array_device_config.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from typing import Sequence +import warnings import pytest @@ -12,6 +13,7 @@ from openlifu.xdc.transducerarray import ( _DEFAULT_TEMPLATE_IDS, _validate_device_config_against_connected, + arrays_structurally_equal, ) @@ -156,3 +158,76 @@ def test_get_connected_validates_count_mismatch(): db=None, use_default_template=True, ) + + +# ---------- db-comparison warning ---------- + +class _FakeDB: + """Stand-in for openlifu.db.Database for the db-mismatch warning test.""" + + def __init__(self, stored: dict[str, TransducerArray]): + self._stored = stored + + def get_transducer_ids(self): + return list(self._stored) + + def load_transducer(self, transducer_id: str, convert_array: bool = True): + return self._stored.get(transducer_id) + + +def test_get_connected_warns_when_db_copy_differs(): + user_configs = [ + { + **_module_user_config("AAA", freq=400), + "device": { + "id": "my-array", + "name": "My Array", + "template": "openlifu_1x400", + "modules": [{"hwid": "AAA"}], + "attrs": {}, + }, + } + ] + # Build a stored array with a clearly different name to force a mismatch. + stored = TransducerArray.get_connected( + interface=_fake_interface(user_configs), db=None + ) + stored.name = "Something Different" + fake_db = _FakeDB({"my-array": stored}) + + with pytest.warns(UserWarning, match="differs from the version stored"): + TransducerArray.get_connected( + interface=_fake_interface(user_configs), + db=fake_db, + use_default_template=True, + ) + + +def test_get_connected_does_not_warn_when_db_copy_matches(): + user_configs = [_module_user_config("AAA", freq=400)] + # First call without db produces the canonical array; stash a copy. + stored = TransducerArray.get_connected( + interface=_fake_interface(user_configs), db=None + ) + fake_db = _FakeDB({stored.id: stored}) + with warnings.catch_warnings(): + warnings.simplefilter("error") # promote any UserWarning to an error + TransducerArray.get_connected( + interface=_fake_interface(user_configs), + db=fake_db, + use_default_template=True, + ) + + +def test_arrays_structurally_equal_handles_path_basename_normalization(): + a = TransducerArray.get_connected( + interface=_fake_interface([_module_user_config("AAA", freq=400)]), db=None + ) + b = TransducerArray.get_connected( + interface=_fake_interface([_module_user_config("AAA", freq=400)]), db=None + ) + # Inject differently-prefixed mesh paths that share a basename. + b.modules[0].registration_surface_filename = "/abs/path/regsurf.stl" + a.modules[0].registration_surface_filename = "regsurf.stl" + assert arrays_structurally_equal(a, b) + From d202e20595129db87109f61de897f732ab2e180b Mon Sep 17 00:00:00 2001 From: Pavel Osnova Date: Fri, 6 Mar 2026 15:29:29 +0200 Subject: [PATCH 11/23] Add dev/prod environment support for cloud API --- src/openlifu/cloud/api/api.py | 4 ++-- src/openlifu/cloud/api/request.py | 16 ++++++++-------- src/openlifu/cloud/cloud.py | 13 +++++++++---- src/openlifu/cloud/const.py | 6 +++++- src/openlifu/cloud/ws.py | 8 ++++---- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/openlifu/cloud/api/api.py b/src/openlifu/cloud/api/api.py index 0948ad8c..956251f6 100644 --- a/src/openlifu/cloud/api/api.py +++ b/src/openlifu/cloud/api/api.py @@ -16,8 +16,8 @@ class Api: - def __init__(self): - self._request = Request() + def __init__(self, api_url: str): + self._request = Request(api_url) self._request.debug_log = True self._databases = DatabasesApi(self._request) self._protocols = ProtocolsApi(self._request) diff --git a/src/openlifu/cloud/api/request.py b/src/openlifu/cloud/api/request.py index 8ed8520e..3c55811a 100644 --- a/src/openlifu/cloud/api/request.py +++ b/src/openlifu/cloud/api/request.py @@ -7,7 +7,6 @@ import urllib3 from requests.adapters import HTTPAdapter -from openlifu.cloud.const import API_URL from openlifu.cloud.utils import logger_cloud, to_json urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -34,7 +33,8 @@ def init_poolmanager(self, *args, **kwargs): class Request: TIMEOUT = (5, 300) - def __init__(self): + def __init__(self, api_url: str): + self._api_url = api_url self.headers = {} self.session = requests.Session() adapter = SlicerAdapter( @@ -48,7 +48,7 @@ def _log_request(self, method: str, url: str, start_time: float, status_code: in def get(self, url: str) -> str: start = time.perf_counter() - response = self.session.get(API_URL + url, headers=self.headers, timeout=self.TIMEOUT, verify=False) + response = self.session.get(self._api_url + url, headers=self.headers, timeout=self.TIMEOUT, verify=False) self._log_request("GET", url, start, response.status_code) logger_cloud.debug(f"GET: {url}, status_code: {response.status_code}\nresponse: {response.text}") @@ -57,7 +57,7 @@ def get(self, url: str) -> str: def get_bytes(self, url: str) -> bytes: start = time.perf_counter() - response = self.session.get(API_URL + url, headers=self.headers, timeout=self.TIMEOUT, verify=False) + response = self.session.get(self._api_url + url, headers=self.headers, timeout=self.TIMEOUT, verify=False) self._log_request("GET_BYTES", url, start, response.status_code) logger_cloud.debug(f"GET bytes: {url}, status_code: {response.status_code}") @@ -66,7 +66,7 @@ def get_bytes(self, url: str) -> bytes: def post(self, url: str, dto) -> str: start = time.perf_counter() - response = self.session.post(API_URL + url, data=to_json(dto), headers=self.headers, timeout=self.TIMEOUT, verify=False) + response = self.session.post(self._api_url + url, data=to_json(dto), headers=self.headers, timeout=self.TIMEOUT, verify=False) self._log_request("POST", url, start, response.status_code) logger_cloud.debug(f"POST: {url}, body: {to_json(dto)}, status_code: {response.status_code}\nresponse: {response.text}") @@ -75,7 +75,7 @@ def post(self, url: str, dto) -> str: def post_bytes(self, url: str, data) -> str: start = time.perf_counter() - response = self.session.post(API_URL + url, data=data, headers=self.headers, timeout=self.TIMEOUT, verify=False) + response = self.session.post(self._api_url + url, data=data, headers=self.headers, timeout=self.TIMEOUT, verify=False) self._log_request("POST_BYTES", url, start, response.status_code) logger_cloud.debug(f"POST bytes: {url}, status_code: {response.status_code}\nresponse: {response.text}") @@ -84,7 +84,7 @@ def post_bytes(self, url: str, data) -> str: def put(self, url: str, dto) -> str: start = time.perf_counter() - response = self.session.put(API_URL + url, data=to_json(dto), headers=self.headers, timeout=self.TIMEOUT, verify=False) + response = self.session.put(self._api_url + url, data=to_json(dto), headers=self.headers, timeout=self.TIMEOUT, verify=False) self._log_request("PUT", url, start, response.status_code) logger_cloud.debug(f"PUT: {url}, body: {to_json(dto)}, status_code: {response.status_code}\nresponse: {response.text}") @@ -93,7 +93,7 @@ def put(self, url: str, dto) -> str: def delete(self, url: str) -> str: start = time.perf_counter() - response = self.session.delete(API_URL + url, headers=self.headers, timeout=self.TIMEOUT, verify=False) + response = self.session.delete(self._api_url + url, headers=self.headers, timeout=self.TIMEOUT, verify=False) self._log_request("DELETE", url, start, response.status_code) logger_cloud.debug(f"DELETE: {url}, status_code: {response.status_code}\nresponse: {response.text}") diff --git a/src/openlifu/cloud/cloud.py b/src/openlifu/cloud/cloud.py index 5ccddcae..61e5fc47 100644 --- a/src/openlifu/cloud/cloud.py +++ b/src/openlifu/cloud/cloud.py @@ -21,6 +21,7 @@ from openlifu.cloud.components.transducers import Transducers from openlifu.cloud.components.users import Users from openlifu.cloud.components.volumes import Volumes +from openlifu.cloud.const import API_URL_DEV, API_URL_PROD, ENV_DEV, ENV_PROD from openlifu.cloud.filesystem_observer import FilesystemObserver from openlifu.cloud.status import Status from openlifu.cloud.sync_thread import SyncThread @@ -30,10 +31,14 @@ class Cloud: - def __init__(self): + def __init__(self, environment: str = ENV_PROD): + if environment == ENV_DEV: + api_url = API_URL_DEV + else: + api_url = API_URL_PROD self._filesystem_observer = FilesystemObserver(self._on_file_system_update) - self._api = Api() - self._websocket = Websocket(self._on_websocket_update) + self._api = Api(api_url) + self._websocket = Websocket(api_url, self._on_websocket_update) self._components: List[AbstractComponent] = [] self._sync_thread = SyncThread(self._on_status_changed) self._db_path: Path | None = None @@ -172,7 +177,7 @@ def _create_components(self): logger_cloud.setLevel(logging.DEBUG) logger_cloud.addHandler(logging.StreamHandler(sys.stdout)) - cloud = Cloud() + cloud = Cloud(ENV_DEV) token = os.getenv("TOKEN") db_path = os.getenv("DB_PATH") diff --git a/src/openlifu/cloud/const.py b/src/openlifu/cloud/const.py index 731e7af6..8089fcbb 100644 --- a/src/openlifu/cloud/const.py +++ b/src/openlifu/cloud/const.py @@ -1,6 +1,10 @@ from __future__ import annotations -API_URL = "https://api.openwater.health" +API_URL_PROD = "https://api.openwater.health" +API_URL_DEV = "https://dev.api.openwater.health" + +ENV_PROD = "prod" +ENV_DEV = "dev" CONFIG_FILE = "config" DATA_FILE = "data" diff --git a/src/openlifu/cloud/ws.py b/src/openlifu/cloud/ws.py index cd41f05b..d3195c82 100644 --- a/src/openlifu/cloud/ws.py +++ b/src/openlifu/cloud/ws.py @@ -5,14 +5,14 @@ import socketio from socketio import exceptions -from openlifu.cloud.const import API_URL from openlifu.cloud.utils import logger_cloud DATABASE_UPDATES_NS = "/database_updates" class Websocket: - def __init__(self, update_callback: Callable[[dict], None]): + def __init__(self, api_url: str, update_callback: Callable[[dict], None]): + self._api_url = api_url self._sio: socketio.Client | None = None self._database_id = None self._auth = {} @@ -28,7 +28,7 @@ def authenticate(self, access_token: str): self.connect(self._database_id) def connect(self, database_id: int): - self.log(f"Attempting connection to {API_URL} for DB {database_id}") + self.log(f"Attempting connection to {self._api_url} for DB {database_id}") if self._sio is not None: self.disconnect() @@ -71,7 +71,7 @@ def on_update(data): try: self._sio.connect( - f"{API_URL}/socket.io", + f"{self._api_url}/socket.io", auth=self._auth, namespaces=[DATABASE_UPDATES_NS], transports=["websocket"], From 773e9ff46d08cc2bcf2eb166e9e8e7f597be14fe Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Mon, 1 Jun 2026 10:57:52 -0700 Subject: [PATCH 12/23] cloud shutdown --- src/openlifu/cloud/cloud.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openlifu/cloud/cloud.py b/src/openlifu/cloud/cloud.py index 61e5fc47..0eaee8e0 100644 --- a/src/openlifu/cloud/cloud.py +++ b/src/openlifu/cloud/cloud.py @@ -151,7 +151,9 @@ def _create_components(self): self._components.clear() self._components.append(Users(self._api, self._db_path, self._db.id, self._sync_thread)) self._components.append(Protocols(self._api, self._db_path, self._db.id, self._sync_thread)) - self._components.append(Systems(self._api, self._db_path, self._db.id, self._sync_thread)) + # Systems are managed elsewhere and the per-database "systems" + # folder is no longer part of the local database layout, so the + # Systems sync component is intentionally not registered here. self._components.append(Transducers(self._api, self._db_path, self._db.id, self._sync_thread)) self._components.append( Subjects(self._api, self._db_path, self._db.id, self._sync_thread) From 7c58b0afb6edb17cf64d34dd91c9964d01c701a0 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 2 Jun 2026 11:41:50 -0700 Subject: [PATCH 13/23] Repr cleanup --- src/openlifu/xdc/transducer.py | 20 ++++++++++++++++---- src/openlifu/xdc/transducerarray.py | 6 +++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index 379c1279..6f638193 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -156,7 +156,7 @@ def __str__(self) -> str: f" Units: {self.units}", f" Sensitivity: {_format_sensitivity_summary(self.sensitivity)}", f" Crosstalk: frac={_format_scalar(self.crosstalk_frac)}, " - f"dist={_format_scalar(self.crosstalk_dist)} {self.units}", + f"dist={_format_scalar(self.crosstalk_dist)} m", f" Meshes: registration={self.registration_surface_filename}, " f"body={self.transducer_body_filename}", ] @@ -192,7 +192,7 @@ def line(label: str, value_html: str) -> str: "Crosstalk", html.escape( f"frac={_format_scalar(self.crosstalk_frac)}, " - f"dist={_format_scalar(self.crosstalk_dist)} {self.units}" + f"dist={_format_scalar(self.crosstalk_dist)} m" ), ), line("Registration Mesh", html.escape(str(self.registration_surface_filename))), @@ -200,14 +200,26 @@ def line(label: str, value_html: str) -> str: line("Attr Keys", html.escape(", ".join(sorted(str(k) for k in self.attrs)) or "-")), ] + # Per-element sensitivity is the product of the element's stored + # sensitivity and the module's sensitivity. For frequency-dependent + # module sensitivity we evaluate it at the module's center frequency + # so the displayed per-element value matches what would be applied + # at the nominal drive frequency. + module_sens_at_f = sensitivity_at_frequency(self.sensitivity, self.frequency) + element_rows = "".join( - "
" + "
" + "" f"#{element.index}" f"pin {element.pin}" f"pos [{_format_scalar(element.position[0])}, {_format_scalar(element.position[1])}, {_format_scalar(element.position[2])}]" f"size [{_format_scalar(element.size[0])}, {_format_scalar(element.size[1])}]" - f"{html.escape(_format_sensitivity_summary(element.sensitivity))}" + f"{html.escape(_format_sensitivity_summary(_combine_sensitivities(element.sensitivity, module_sens_at_f)))}" + "" + "
" + f"{element._repr_html_()}" "
" + "
" for element in self.elements ) diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index ed431b8c..1dd2a7f4 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -296,7 +296,6 @@ def line(label: str, value_html: str) -> str: line("ID", html.escape(self.id)), line("Name", html.escape(self.name)), line("Total Elements", html.escape(str(total_elements))), - line("Attr Keys", html.escape(", ".join(sorted(str(k) for k in self.attrs)) or "-")), ] module_rows = "".join( @@ -313,6 +312,10 @@ def line(label: str, value_html: str) -> str: for i, m in enumerate(self.modules) ) + attr_keys_line = line( + "Attr Keys", html.escape(", ".join(sorted(str(k) for k in self.attrs)) or "-") + ) + return ( "
" "
TransducerArray
" @@ -325,6 +328,7 @@ def line(label: str, value_html: str) -> str: f"{module_rows}" "
" "
" + f"{attr_keys_line}" "
" ) From f8923c984af7220590cf498b9d9b43ab644d8d49 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Fri, 5 Jun 2026 11:30:06 -0700 Subject: [PATCH 14/23] Summary displays and formatting/unit hints --- src/openlifu/bf/apod_methods/maxangle.py | 17 +- .../bf/apod_methods/piecewiselinear.py | 23 +- src/openlifu/bf/apod_methods/uniform.py | 11 +- src/openlifu/bf/delay_methods/direct.py | 11 +- .../bf/focal_patterns/focal_pattern.py | 21 +- src/openlifu/bf/focal_patterns/wheel.py | 30 ++- src/openlifu/bf/pulse.py | 23 +- src/openlifu/bf/sequence.py | 36 ++- src/openlifu/plan/solution_analysis.py | 69 +++++- src/openlifu/seg/seg_method.py | 16 ++ src/openlifu/seg/virtual_fit.py | 80 ++++++- src/openlifu/sim/sim_setup.py | 75 +++++- src/openlifu/util/annotations.py | 77 +++++-- src/openlifu/util/field_display.py | 215 ++++++++++++++++++ 14 files changed, 635 insertions(+), 69 deletions(-) create mode 100644 src/openlifu/util/field_display.py diff --git a/src/openlifu/bf/apod_methods/maxangle.py b/src/openlifu/bf/apod_methods/maxangle.py index e30065df..368bfb29 100644 --- a/src/openlifu/bf/apod_methods/maxangle.py +++ b/src/openlifu/bf/apod_methods/maxangle.py @@ -10,16 +10,25 @@ from openlifu.bf.apod_methods import ApodizationMethod from openlifu.geo.point import Point from openlifu.util.annotations import OpenLIFUFieldData +from openlifu.util.field_display import summarize_fields from openlifu.util.units import getunittype from openlifu.xdc import Transducer @dataclass class MaxAngle(ApodizationMethod): - max_angle: Annotated[float, OpenLIFUFieldData("Maximum acceptance angle", "Maximum acceptance angle for each element from the vector normal to the element surface")] = 30.0 + max_angle: Annotated[float, OpenLIFUFieldData( + name="Maximum acceptance angle", + description="Maximum acceptance angle for each element from the vector normal to the element surface", + units_field="units", display_units="deg", precision=1, + )] = 30.0 """Maximum acceptance angle for each element from the vector normal to the element surface""" - units: Annotated[str, OpenLIFUFieldData("Angle units", "Angle units")] = "deg" + units: Annotated[str, OpenLIFUFieldData( + name="Angle units", + description="Angle units", + unit_options=("deg", "rad"), + )] = "deg" """Angle units""" def __post_init__(self): @@ -47,3 +56,7 @@ def to_table(self) -> pd.DataFrame: records = [{"Name": "Type", "Value": "Max Angle", "Unit": ""}, {"Name": "Max Angle", "Value": self.max_angle, "Unit": self.units}] return pd.DataFrame.from_records(records) + + def get_summary(self) -> str: + """Return a one-liner summary of the apodization parameters.""" + return summarize_fields(self, ("max_angle",)) diff --git a/src/openlifu/bf/apod_methods/piecewiselinear.py b/src/openlifu/bf/apod_methods/piecewiselinear.py index 33179cba..c88e02ab 100644 --- a/src/openlifu/bf/apod_methods/piecewiselinear.py +++ b/src/openlifu/bf/apod_methods/piecewiselinear.py @@ -10,19 +10,32 @@ from openlifu.bf.apod_methods import ApodizationMethod from openlifu.geo.point import Point from openlifu.util.annotations import OpenLIFUFieldData +from openlifu.util.field_display import summarize_fields from openlifu.util.units import getunittype from openlifu.xdc import Transducer @dataclass class PiecewiseLinear(ApodizationMethod): - zero_angle: Annotated[float, OpenLIFUFieldData("Zero Apodization Angle", "Angle at and beyond which the piecewise linear apodization is 0%")] = 90.0 + zero_angle: Annotated[float, OpenLIFUFieldData( + name="Zero apodization angle", + description="Angle at and beyond which the piecewise linear apodization is 0%", + units_field="units", display_units="deg", precision=1, + )] = 90.0 """Angle at and beyond which the piecewise linear apodization is 0%""" - rolloff_angle: Annotated[float, OpenLIFUFieldData("Rolloff start angle", "Angle below which the piecewise linear apodization is 100%")] = 45.0 + rolloff_angle: Annotated[float, OpenLIFUFieldData( + name="Rolloff start angle", + description="Angle below which the piecewise linear apodization is 100%", + units_field="units", display_units="deg", precision=1, + )] = 45.0 """Angle below which the piecewise linear apodization is 100%""" - units: Annotated[str, OpenLIFUFieldData("Angle units", "Angle units")] = "deg" + units: Annotated[str, OpenLIFUFieldData( + name="Angle units", + description="Angle units", + unit_options=("deg", "rad"), + )] = "deg" """Angle units""" def __post_init__(self): @@ -60,3 +73,7 @@ def to_table(self) -> pd.DataFrame: {"Name": "Rolloff Angle", "Value": self.rolloff_angle, "Unit": self.units}, ] return pd.DataFrame.from_records(records) + + def get_summary(self) -> str: + """Return a one-liner summary of the apodization parameters.""" + return summarize_fields(self, ("zero_angle", "rolloff_angle")) diff --git a/src/openlifu/bf/apod_methods/uniform.py b/src/openlifu/bf/apod_methods/uniform.py index fa2c28f3..a294db6b 100644 --- a/src/openlifu/bf/apod_methods/uniform.py +++ b/src/openlifu/bf/apod_methods/uniform.py @@ -10,12 +10,17 @@ from openlifu.bf.apod_methods import ApodizationMethod from openlifu.geo.point import Point from openlifu.util.annotations import OpenLIFUFieldData +from openlifu.util.field_display import summarize_fields from openlifu.xdc import Transducer @dataclass class Uniform(ApodizationMethod): - value: Annotated[float, OpenLIFUFieldData("Value", "Uniform apodization value between 0 and 1.")] = 1.0 + value: Annotated[float, OpenLIFUFieldData( + name="Value", + description="Uniform apodization value between 0 and 1.", + precision=2, + )] = 1.0 """Uniform apodization value between 0 and 1.""" def calc_apodization(self, arr: Transducer, target: Point, params: xa.Dataset, transform:np.ndarray | None=None): @@ -30,3 +35,7 @@ def to_table(self): records = [{"Name": "Type", "Value": "Uniform", "Unit": ""}, {"Name": "Value", "Value": self.value, "Unit": ""}] return pd.DataFrame.from_records(records) + + def get_summary(self) -> str: + """Return a one-liner summary of the apodization parameters.""" + return summarize_fields(self, ("value",)) diff --git a/src/openlifu/bf/delay_methods/direct.py b/src/openlifu/bf/delay_methods/direct.py index c66a7c73..4d67e82e 100644 --- a/src/openlifu/bf/delay_methods/direct.py +++ b/src/openlifu/bf/delay_methods/direct.py @@ -10,12 +10,17 @@ from openlifu.bf.delay_methods import DelayMethod from openlifu.geo.point import Point from openlifu.util.annotations import OpenLIFUFieldData +from openlifu.util.field_display import summarize_fields from openlifu.xdc import Transducer @dataclass class Direct(DelayMethod): - c0: Annotated[float, OpenLIFUFieldData("Speed of Sound (m/s)", "Speed of sound in the medium (m/s)")] = 1480.0 + c0: Annotated[float, OpenLIFUFieldData( + name="Speed of sound", + description="Speed of sound in the medium", + units="m/s", precision=0, + )] = 1480.0 """Speed of sound in the medium (m/s)""" def __post_init__(self): @@ -46,3 +51,7 @@ def to_table(self) -> pd.DataFrame: records = [{"Name": "Type", "Value": "Direct", "Unit": ""}, {"Name": "Default Sound Speed", "Value": self.c0, "Unit": "m/s"}] return pd.DataFrame.from_records(records) + + def get_summary(self) -> str: + """Return a one-liner summary of the delay method.""" + return summarize_fields(self, ("c0",)) diff --git a/src/openlifu/bf/focal_patterns/focal_pattern.py b/src/openlifu/bf/focal_patterns/focal_pattern.py index f12af4f4..643042d0 100644 --- a/src/openlifu/bf/focal_patterns/focal_pattern.py +++ b/src/openlifu/bf/focal_patterns/focal_pattern.py @@ -9,6 +9,7 @@ from openlifu.bf import focal_patterns from openlifu.geo.point import Point from openlifu.util.annotations import OpenLIFUFieldData +from openlifu.util.field_display import summarize_fields from openlifu.util.units import getunittype @@ -18,10 +19,18 @@ class FocalPattern(ABC): Abstract base class for representing a focal pattern """ - target_pressure: Annotated[float, OpenLIFUFieldData("Target pressure", "Target pressure of the focal pattern in given units")] = 1.0 + target_pressure: Annotated[float, OpenLIFUFieldData( + name="Target pressure", + description="Target pressure of the focal pattern", + units_field="units", display_units="kPa", precision=0, + )] = 1.0 """Target pressure of the focal pattern in given units""" - units: Annotated[str, OpenLIFUFieldData("Pressure units", "Pressure units")] = "Pa" + units: Annotated[str, OpenLIFUFieldData( + name="Pressure units", + description="Pressure units (Pa, kPa, MPa)", + unit_options=("Pa", "kPa", "MPa"), + )] = "Pa" """Pressure units""" def __post_init__(self): @@ -83,3 +92,11 @@ def to_table(self) -> pd.DataFrame: :returns: Pandas DataFrame of the focal pattern parameters """ pass + + def get_summary(self) -> str: + """Return a one-liner summary of this focal pattern's parameters. + + Subclasses may override to include their own additional fields; by + default the base class summarizes ``target_pressure``. + """ + return summarize_fields(self, ("target_pressure",)) diff --git a/src/openlifu/bf/focal_patterns/wheel.py b/src/openlifu/bf/focal_patterns/wheel.py index c768ceeb..39303416 100644 --- a/src/openlifu/bf/focal_patterns/wheel.py +++ b/src/openlifu/bf/focal_patterns/wheel.py @@ -9,6 +9,7 @@ from openlifu.bf.focal_patterns import FocalPattern from openlifu.geo.point import Point from openlifu.util.annotations import OpenLIFUFieldData +from openlifu.util.field_display import summarize_fields @dataclass @@ -17,16 +18,30 @@ class Wheel(FocalPattern): Class for representing a wheel pattern """ - center: Annotated[bool, OpenLIFUFieldData("Include center point?", "Whether to include the center for the wheel pattern")] = True + center: Annotated[bool, OpenLIFUFieldData( + name="Include center point?", + description="Whether to include the center for the wheel pattern", + )] = True """Whether to include the center for the wheel pattern""" - num_spokes: Annotated[int, OpenLIFUFieldData("Number of spokes", "Number of spokes in the wheel pattern")] = 4 + num_spokes: Annotated[int, OpenLIFUFieldData( + name="Number of spokes", + description="Number of spokes in the wheel pattern", + )] = 4 """Number of spokes in the wheel pattern""" - spoke_radius: Annotated[float, OpenLIFUFieldData("Spoke radius", "Radius of the spokes in the wheel pattern")] = 1.0 # mm + spoke_radius: Annotated[float, OpenLIFUFieldData( + name="Spoke radius", + description="Radius of the spokes in the wheel pattern", + units_field="distance_units", display_units="mm", precision=1, + )] = 1.0 # mm """Radius of the spokes in the wheel pattern""" - distance_units: Annotated[str, OpenLIFUFieldData("Units", "Units of the wheel pattern parameters")] = "mm" + distance_units: Annotated[str, OpenLIFUFieldData( + name="Distance units", + description="Units of the wheel pattern parameters", + unit_options=("mm", "cm", "m"), + )] = "mm" """Units of the wheel pattern parameters""" def __post_init__(self): @@ -86,3 +101,10 @@ def to_table(self) -> pd.DataFrame: {"Name": "Spoke Radius", "Value": self.spoke_radius, "Unit": self.distance_units}, ] return pd.DataFrame.from_records(records) + + def get_summary(self) -> str: + """Return a one-liner summary including base + wheel-specific fields.""" + return summarize_fields( + self, + ("target_pressure", "num_spokes", "spoke_radius"), + ) diff --git a/src/openlifu/bf/pulse.py b/src/openlifu/bf/pulse.py index de40135a..e4c47a10 100644 --- a/src/openlifu/bf/pulse.py +++ b/src/openlifu/bf/pulse.py @@ -8,6 +8,7 @@ from openlifu.util.annotations import OpenLIFUFieldData from openlifu.util.dict_conversion import DictMixin +from openlifu.util.field_display import summarize_fields @dataclass @@ -16,13 +17,25 @@ class Pulse(DictMixin): Class for representing a sinusoidal pulse """ - frequency: Annotated[float, OpenLIFUFieldData("Frequency (Hz)", "Frequency of the pulse in Hz")] = 1.0 # Hz + frequency: Annotated[float, OpenLIFUFieldData( + name="Frequency", + description="Frequency of the pulse", + units="Hz", display_units="kHz", precision=1, + )] = 1.0 # Hz """Frequency of the pulse in Hz""" - amplitude: Annotated[float, OpenLIFUFieldData("Amplitude (AU)", "Amplitude of the pulse (between 0 and 1). ")] = 1.0 # AU + amplitude: Annotated[float, OpenLIFUFieldData( + name="Amplitude", + description="Amplitude of the pulse (between 0 and 1).", + precision=2, + )] = 1.0 # AU """Amplitude of the pulse in arbitrary units (AU) between 0 and 1""" - duration: Annotated[float, OpenLIFUFieldData("Duration (s)", "Duration of the pulse in s")] = 1.0 # s + duration: Annotated[float, OpenLIFUFieldData( + name="Duration", + description="Duration of the pulse", + units="s", display_units="ms", precision=1, + )] = 1.0 # s """Duration of the pulse in s""" def __post_init__(self): @@ -61,3 +74,7 @@ def to_table(self) -> pd.DataFrame: {"Name": "Amplitude", "Value": self.amplitude, "Unit": "AU"}, {"Name": "Duration", "Value": self.duration, "Unit": "s"}] return pd.DataFrame.from_records(records) + + def get_summary(self) -> str: + """Return a one-liner summary like ``Frequency: 400 kHz, Amplitude: 1, Duration: 5 ms``.""" + return summarize_fields(self, ("frequency", "amplitude", "duration")) diff --git a/src/openlifu/bf/sequence.py b/src/openlifu/bf/sequence.py index a5601075..b86c4c64 100644 --- a/src/openlifu/bf/sequence.py +++ b/src/openlifu/bf/sequence.py @@ -15,16 +15,30 @@ class Sequence(DictMixin): Class for representing a sequence of pulses """ - pulse_interval: Annotated[float, OpenLIFUFieldData("Pulse interval (s)", "Interval between pulses in the sequence (s)")] = 1.0 # s + pulse_interval: Annotated[float, OpenLIFUFieldData( + name="Pulse interval", + description="Interval between pulses in the sequence", + units="s", display_units="ms", precision=1, + )] = 1.0 # s """Interval between pulses in the sequence (s)""" - pulse_count: Annotated[int, OpenLIFUFieldData("Pulse count", "Number of pulses in the sequence")] = 1 + pulse_count: Annotated[int, OpenLIFUFieldData( + name="Pulse count", + description="Number of pulses in the sequence", + )] = 1 """Number of pulses in the sequence""" - pulse_train_interval: Annotated[float, OpenLIFUFieldData("Pulse train interval (s)", "Interval between pulse trains in the sequence (s)")] = 1.0 # s + pulse_train_interval: Annotated[float, OpenLIFUFieldData( + name="Pulse train interval", + description="Interval between pulse trains in the sequence", + units="s", display_units="s", precision=2, + )] = 1.0 # s """Interval between pulse trains in the sequence (s)""" - pulse_train_count: Annotated[int, OpenLIFUFieldData("Pulse train count", "Number of pulse trains in the sequence")] = 1 + pulse_train_count: Annotated[int, OpenLIFUFieldData( + name="Pulse train count", + description="Number of pulse trains in the sequence", + )] = 1 """Number of pulse trains in the sequence""" def __post_init__(self): @@ -72,3 +86,17 @@ def get_sequence_duration(self) -> float: else: interval = self.pulse_train_interval return interval * self.pulse_train_count + + def get_summary(self) -> str: + """Return a one-liner summary of the sequence parameters. + + Format: ``"{pulse_count} pulses every {pulse_interval}ms, + repeated {pulse_train_count}x every {pulse_train_interval}s"``. + Numeric values use ``%g`` formatting (no trailing zeros). + """ + pulse_interval_ms = self.pulse_interval * 1000.0 + pulse_train_interval_s = self.pulse_train_interval + return ( + f"{int(self.pulse_count)} pulses every {pulse_interval_ms:g}ms, " + f"repeated {int(self.pulse_train_count)}x every {pulse_train_interval_s:g}s" + ) diff --git a/src/openlifu/plan/solution_analysis.py b/src/openlifu/plan/solution_analysis.py index d3e8a680..2dfcd1c9 100644 --- a/src/openlifu/plan/solution_analysis.py +++ b/src/openlifu/plan/solution_analysis.py @@ -237,34 +237,74 @@ def to_json(self, compact:bool) -> str: @dataclass class SolutionAnalysisOptions(DictMixin): - standoff_sound_speed: Annotated[float, OpenLIFUFieldData("Standoff sound speed (m/s)", "Speed of sound in standoff, for calculating initial impedance")] = 1500.0 + standoff_sound_speed: Annotated[float, OpenLIFUFieldData( + name="Standoff sound speed", + description="Speed of sound in standoff, for calculating initial impedance", + units="m/s", precision=0, + )] = 1500.0 """Speed of sound in standoff, for calculating initial impedance""" - standoff_density: Annotated[float, OpenLIFUFieldData("Standoff density (kg/m³)", "Density of standoff medium (kg/m³)")] = 1000.0 + standoff_density: Annotated[float, OpenLIFUFieldData( + name="Standoff density", + description="Density of standoff medium", + units="kg/m^3", precision=0, + )] = 1000.0 """Density of standoff medium (kg/m³)""" - ref_sound_speed: Annotated[float, OpenLIFUFieldData("Reference sound speed (m/s)", "Reference speed of sound in the medium (m/s)")] = 1500.0 + ref_sound_speed: Annotated[float, OpenLIFUFieldData( + name="Reference sound speed", + description="Reference speed of sound in the medium", + units="m/s", precision=0, + )] = 1500.0 """Reference speed of sound in the medium (m/s)""" - ref_density: Annotated[float, OpenLIFUFieldData("Reference density (kg/m³)", "Reference density (kg/m³)")] = 1000.0 + ref_density: Annotated[float, OpenLIFUFieldData( + name="Reference density", + description="Reference density", + units="kg/m^3", precision=0, + )] = 1000.0 """Reference density (kg/m³)""" - mainlobe_aspect_ratio: Annotated[Tuple[float, float, float], OpenLIFUFieldData("Mainlobe aspect ratio (lat,ele,ax)", "Aspect ratio of the mainlobe mask")] = (1., 1., 5.) + mainlobe_aspect_ratio: Annotated[Tuple[float, float, float], OpenLIFUFieldData( + name="Mainlobe aspect ratio (lat,ele,ax)", + description="Aspect ratio of the mainlobe mask", + precision=1, + )] = (1., 1., 5.) """Aspect ratio of the mainlobe ellipsoid mask, in the form (lat,ele,ax). (1,1,5) means an ellipsoid 5x as long as it is wide.""" - mainlobe_radius: Annotated[float, OpenLIFUFieldData("Mainlobe mask radius", "Size of the mainlobe mask, in the units provided for Distance units (`distance_units`)")] = 2.5e-3 + mainlobe_radius: Annotated[float, OpenLIFUFieldData( + name="Mainlobe mask radius", + description="Size of the mainlobe mask, in the units provided for Distance units (`distance_units`)", + units_field="distance_units", display_units="mm", precision=2, + )] = 2.5e-3 """Size of the mainlobe mask, in the units provided for Distance units (`distance_units`). The mainlobe mask is an ellipsoid with this radius, scaled by the `mainlobe_aspect_ratio`.""" - beamwidth_radius: Annotated[float, OpenLIFUFieldData("Beamwidth search radius", "Size of the beamwidth search, in the units provided for Distance units (`distance_units`)")] = 5e-3 + beamwidth_radius: Annotated[float, OpenLIFUFieldData( + name="Beamwidth search radius", + description="Size of the beamwidth search, in the units provided for Distance units (`distance_units`)", + units_field="distance_units", display_units="mm", precision=2, + )] = 5e-3 """Size of the beamwidth search, in the units provided for Distance units (`distance_units`). The beamwidth is found along the lateral and elevation lines perpendicular to the focus axis.""" - sidelobe_radius: Annotated[float, OpenLIFUFieldData("Sidelobe radius", "Size of the sidelobe mask, in the units provided for Distance units (`distance_units`)")] = 3e-3 + sidelobe_radius: Annotated[float, OpenLIFUFieldData( + name="Sidelobe radius", + description="Size of the sidelobe mask, in the units provided for Distance units (`distance_units`)", + units_field="distance_units", display_units="mm", precision=2, + )] = 3e-3 """Size of the sidelobe mask, in the units provided for Distance units (`distance_units`). Pressure outside of this ellipsoid (scaled by `mainlobe_aspect_ratio`) is considered outside of the focal region.""" - sidelobe_zmin: Annotated[float, OpenLIFUFieldData("Sidelobe minimum z", "Minimum z coordinate of the sidelobe mask, in the units provided for Distance units (`distance_units`)")] = 1e-3 + sidelobe_zmin: Annotated[float, OpenLIFUFieldData( + name="Sidelobe minimum z", + description="Minimum z coordinate of the sidelobe mask, in the units provided for Distance units (`distance_units`)", + units_field="distance_units", display_units="mm", precision=2, + )] = 1e-3 """Minimum z coordinate of the sidelobe mask, in the units provided for Distance units (`distance_units`). This value is used to ignore emitted pressure artifacts.""" - distance_units: Annotated[str, OpenLIFUFieldData("Distance units", "The units used for distance measurements")] = "m" + distance_units: Annotated[str, OpenLIFUFieldData( + name="Distance units", + description="The units used for distance measurements", + unit_options=("mm", "cm", "m"), + )] = "m" """The units used for distance measurements""" param_constraints: Annotated[Dict[str, ParameterConstraint], OpenLIFUFieldData("Parameter constraints", None)] = field(default_factory=dict) @@ -313,6 +353,15 @@ def from_dict(cls: Type[SolutionAnalysisOptions], parameter_dict: Dict[str, Any] return cls(**parameter_dict) + def get_summary(self) -> str: + """Return a one-liner summary of the analysis options. + + Returns an empty string: the solution-analysis options are too + numerous to render meaningfully on a collapsible header, so callers + should fall back to showing only the section title. + """ + return "" + def find_centroid(da: xa.DataArray, cutoff:float, units:None) -> np.ndarray: """Find the centroid of a thresholded region of a DataArray""" if units is not None and getunittype(units) != 'distance': diff --git a/src/openlifu/seg/seg_method.py b/src/openlifu/seg/seg_method.py index 82c2270d..e143c89f 100644 --- a/src/openlifu/seg/seg_method.py +++ b/src/openlifu/seg/seg_method.py @@ -122,3 +122,19 @@ def to_table(self) -> pd.DataFrame: :returns: Pandas DataFrame of the segmentation method parameters """ pass + + def get_summary(self) -> str: + """Return a one-liner summary of the segmentation method. + + Default implementation returns the human-friendly form of the class name + (e.g. ``"Uniform Tissue"``); subclasses may override to provide more + detail. + """ + # Insert spaces before capital letters: "UniformTissue" -> "Uniform Tissue" + name = type(self).__name__ + result = [] + for i, ch in enumerate(name): + if i > 0 and ch.isupper() and not name[i - 1].isupper(): + result.append(" ") + result.append(ch) + return "".join(result) diff --git a/src/openlifu/seg/virtual_fit.py b/src/openlifu/seg/virtual_fit.py index dd616885..1c318ca6 100644 --- a/src/openlifu/seg/virtual_fit.py +++ b/src/openlifu/seg/virtual_fit.py @@ -52,45 +52,92 @@ class VirtualFitOptions(DictMixin): yaw: 90 degrees minus the polar spherical coordinate. """ - units: Annotated[str, OpenLIFUFieldData("Length units", "The units of length used in the length attributes of this class")] = "mm" + units: Annotated[str, OpenLIFUFieldData( + name="Length units", + description="The units of length used in the length attributes of this class", + unit_options=("mm", "cm", "m"), + )] = "mm" """The units of length used in the length attributes of this class""" - transducer_steering_center_distance: Annotated[float, OpenLIFUFieldData("Steering center distance", "Distance from the transducer origin axially to the center of the steering zone in the units `units`")] = 50. + transducer_steering_center_distance: Annotated[float, OpenLIFUFieldData( + name="Steering center distance", + description="Distance from the transducer origin axially to the center of the steering zone", + units_field="units", display_units="mm", precision=2, + )] = 50. """Distance from the transducer origin axially to the center of the steering zone in the units `units`""" steering_limits: Annotated[Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]], - OpenLIFUFieldData("Steering limits", "Steering bounds along each axis from the transducer origin, in the units `units`")] = ((-50, 50), (-50, 50), (-50, 50)) + OpenLIFUFieldData( + name="Steering limits", + description="Steering bounds along each axis from the transducer origin", + units_field="units", display_units="mm", precision=1, + )] = ((-50, 50), (-50, 50), (-50, 50)) """Distance from the transducer origin axially to the center of the steering zone in the units `units`""" - pitch_range: Annotated[Tuple[float, float], OpenLIFUFieldData("Pitch range (deg)", "Range of pitches to include in the transducer fitting search grid, in degrees")] = (-10, 150) + pitch_range: Annotated[Tuple[float, float], OpenLIFUFieldData( + name="Pitch range", + description="Range of pitches to include in the transducer fitting search grid", + units="deg", precision=0, + )] = (-10, 150) """Range of pitches to include in the transducer fitting search grid, in degrees""" - pitch_step: Annotated[float, OpenLIFUFieldData("Pitch step size (deg)", "Pitch step size when forming the transducer fitting search grid, in degrees")] = 5 + pitch_step: Annotated[float, OpenLIFUFieldData( + name="Pitch step size", + description="Pitch step size when forming the transducer fitting search grid", + units="deg", precision=1, + )] = 5 """Pitch step size when forming the transducer fitting search grid, in degrees""" - yaw_range: Annotated[Tuple[float, float], OpenLIFUFieldData("Yaw range (deg)", "Range of yaws to include in the transducer fitting search grid, in degrees")] = (-65, 65) + yaw_range: Annotated[Tuple[float, float], OpenLIFUFieldData( + name="Yaw range", + description="Range of yaws to include in the transducer fitting search grid", + units="deg", precision=0, + )] = (-65, 65) """Range of yaws to include in the transducer fitting search grid, in degrees""" - yaw_step: Annotated[float, OpenLIFUFieldData("Yaw step size (deg)", "Yaw step size when forming the transducer fitting search grid, in degrees")] = 5 + yaw_step: Annotated[float, OpenLIFUFieldData( + name="Yaw step size", + description="Yaw step size when forming the transducer fitting search grid", + units="deg", precision=1, + )] = 5 """Yaw step size when forming the transducer fitting search grid, in degrees""" - planefit_dyaw_extent: Annotated[float, OpenLIFUFieldData("Plane fit yaw extent", "Left and right extents of the point grid to be used for plane fitting along the local yaw axes, in units of `units`")] = 15 + planefit_dyaw_extent: Annotated[float, OpenLIFUFieldData( + name="Plane fit yaw extent", + description="Left and right extents of the point grid to be used for plane fitting along the local yaw axes", + units_field="units", display_units="mm", precision=2, + )] = 15 """Left and right extents of the point grid to be used for plane fitting along the local yaw axes, in units of `units`. The plane fitting point grid will be twice this size, since this is left and right extents. (Note that this has units of length, not angle!)""" - planefit_dyaw_step: Annotated[float, OpenLIFUFieldData("Plane fit yaw step", "Local yaw axis step size to use when constructing plane fitting grids. In spatial units of `units`")] = 3 + planefit_dyaw_step: Annotated[float, OpenLIFUFieldData( + name="Plane fit yaw step", + description="Local yaw axis step size to use when constructing plane fitting grids", + units_field="units", display_units="mm", precision=2, + )] = 3 """Local yaw axis step size to use when constructing plane fitting grids. In spatial units of `units`.""" - planefit_dpitch_extent: Annotated[float, OpenLIFUFieldData("Plane fit pitch extent", "Left and right extents of the point grid to be used for plane fitting along the local pitch axes, in spatial units of `units`")] = 15 + planefit_dpitch_extent: Annotated[float, OpenLIFUFieldData( + name="Plane fit pitch extent", + description="Left and right extents of the point grid to be used for plane fitting along the local pitch axes", + units_field="units", display_units="mm", precision=2, + )] = 15 """Left and right extents of the point grid to be used for plane fitting along the local pitch axes, in spatial units of `units`. The plane fitting point grid will be twice this size, since this is left and right extents.""" - planefit_dpitch_step: Annotated[float, OpenLIFUFieldData("Plane fit pitch step", "Local pitch axis step size to use when constructing plane fitting grids. In spatial units of `units`")] = 3 + planefit_dpitch_step: Annotated[float, OpenLIFUFieldData( + name="Plane fit pitch step", + description="Local pitch axis step size to use when constructing plane fitting grids", + units_field="units", display_units="mm", precision=2, + )] = 3 """Local pitch axis step size to use when constructing plane fitting grids. In spatial units of `units`.""" - top_n_candidates: Annotated[int, OpenLIFUFieldData("No. of candidates returned", "Sets the limit for the number of transducer transform candidates returned by the algorithm.")] = 4 + top_n_candidates: Annotated[int, OpenLIFUFieldData( + name="No. of candidates returned", + description="Sets the limit for the number of transducer transform candidates returned by the algorithm.", + )] = 4 """Sets the limit for the number of transducer transform candidates returned by the algorithm.""" def __post_init__(self): @@ -169,6 +216,15 @@ def from_dict(parameter_dict: Dict[str,Any]) -> VirtualFitOptions: # Override Di parameter_dict["steering_limits"] = tuple(map(tuple,parameter_dict["steering_limits"])) return VirtualFitOptions(**parameter_dict) + def get_summary(self) -> str: + """Return a one-liner summary of the virtual-fit options. + + Returns an empty string: the virtual-fit options are too numerous to + render meaningfully on a collapsible header, so callers should fall + back to showing only the section title. + """ + return "" + def compute_skin_mesh_from_volume( volume_array : np.ndarray, volume_affine_RAS : np.ndarray, diff --git a/src/openlifu/sim/sim_setup.py b/src/openlifu/sim/sim_setup.py index a8cffbd5..a5119031 100644 --- a/src/openlifu/sim/sim_setup.py +++ b/src/openlifu/sim/sim_setup.py @@ -21,31 +21,67 @@ @dataclass class SimSetup(DictMixin): - spacing: Annotated[float, OpenLIFUFieldData("Spacing", "Simulation grid spacing")] = 1.0 + spacing: Annotated[float, OpenLIFUFieldData( + name="Voxel spacing", + description="Simulation grid spacing", + units_field="units", display_units="mm", precision=2, + )] = 1.0 """Simulation grid spacing""" - units: Annotated[str, OpenLIFUFieldData("Spatial units", "Units used for spatial measurements")] = "mm" + units: Annotated[str, OpenLIFUFieldData( + name="Spatial units", + description="Units used for spatial measurements", + unit_options=("mm", "cm", "m"), + )] = "mm" """Units used for spatial measurements""" - x_extent: Annotated[Tuple[float, float], OpenLIFUFieldData("X-extent", "Simulation grid extent along the first dimension")] = (-30., 30.) + x_extent: Annotated[Tuple[float, float], OpenLIFUFieldData( + name="X-extent", + description="Simulation grid extent along the first dimension", + units_field="units", display_units="mm", precision=1, + )] = (-30., 30.) """Simulation grid extent along the first dimension""" - y_extent: Annotated[Tuple[float, float], OpenLIFUFieldData("Y-extent", "Simulation grid extend along the second dimension")] = (-30., 30.) + y_extent: Annotated[Tuple[float, float], OpenLIFUFieldData( + name="Y-extent", + description="Simulation grid extent along the second dimension", + units_field="units", display_units="mm", precision=1, + )] = (-30., 30.) """Simulation grid extend along the second dimension""" - z_extent: Annotated[Tuple[float, float], OpenLIFUFieldData("Z-extent", "Simulation grid extend along the third dimension")] = (-4., 60.) + z_extent: Annotated[Tuple[float, float], OpenLIFUFieldData( + name="Z-extent", + description="Simulation grid extent along the third dimension", + units_field="units", display_units="mm", precision=1, + )] = (-4., 60.) """Simulation grid extend along the third dimension""" - dt: Annotated[float, OpenLIFUFieldData("Time step", "Simulation time step")] = 0. + dt: Annotated[float, OpenLIFUFieldData( + name="Time step", + description="Simulation time step", + units="s", precision=6, + )] = 0. """Simulation time step""" - t_end: Annotated[float, OpenLIFUFieldData("End time", """Simulation end time""")] = 0. + t_end: Annotated[float, OpenLIFUFieldData( + name="End time", + description="Simulation end time", + units="s", precision=6, + )] = 0. """Simulation end time""" - c0: Annotated[float, OpenLIFUFieldData("Speed of Sound (m/s)", "Reference speed of sound for converting distance to time")] = 1500.0 + c0: Annotated[float, OpenLIFUFieldData( + name="Default speed of sound", + description="Reference speed of sound for converting distance to time", + units="m/s", precision=0, + )] = 1500.0 """Reference speed of sound for converting distance to time""" - cfl: Annotated[float, OpenLIFUFieldData("CFL number", "Courant-Friedrichs-Lewy number")] = 0.3 + cfl: Annotated[float, OpenLIFUFieldData( + name="CFL number", + description="Courant-Friedrichs-Lewy number", + precision=2, + )] = 0.3 """Courant-Friedrichs-Lewy number""" options: Annotated[dict[str, str], OpenLIFUFieldData("Simulation options", "Additional simulation options")] = field(default_factory=dict) @@ -205,6 +241,27 @@ def to_table(self) -> pd.DataFrame: ] return pd.DataFrame.from_records(records) + def get_summary(self) -> str: + """Return a one-liner summary of the simulation setup parameters. + + Format: ``"{spacing}mm spacing, [{x0},{x1}]x[{y0},{y1}]x[{z0},{z1}]"``. + Spacing and extents are converted to millimeters regardless of the + configured storage units. + """ + from openlifu.util.units import getunitconversion + try: + scale = getunitconversion(self.units, "mm") + except Exception: + scale = 1.0 + spacing_mm = self.spacing * scale + x0, x1 = self.x_extent[0] * scale, self.x_extent[1] * scale + y0, y1 = self.y_extent[0] * scale, self.y_extent[1] * scale + z0, z1 = self.z_extent[0] * scale, self.z_extent[1] * scale + return ( + f"{spacing_mm:g}mm spacing, " + f"[{x0:g},{x1:g}]x[{y0:g},{y1:g}]x[{z0:g},{z1:g}]" + ) + @staticmethod def from_dict(d: dict, on_keyword_mismatch: Literal['warn', 'raise', 'ignore'] = 'warn') -> SimSetup: """Create a SimSetup instance from a dictionary.""" diff --git a/src/openlifu/util/annotations.py b/src/openlifu/util/annotations.py index 0cd2edda..ff79b0b6 100644 --- a/src/openlifu/util/annotations.py +++ b/src/openlifu/util/annotations.py @@ -1,25 +1,66 @@ from __future__ import annotations -from typing import Annotated, NamedTuple +from dataclasses import dataclass, field +from typing import Annotated, Optional, Tuple -class OpenLIFUFieldData(NamedTuple): +@dataclass(frozen=True) +class OpenLIFUFieldData: """ - A lightweight named tuple representing a name and annotation for the fields - of a dataclass. For example, the Graph dataclass may have fields associated - with this type: - - ```python - class Graph: - units: Annotated[str, OpenLIFUFieldData("Units", "The units of the graph")] = "mm" - dim_names: Annotated[ - Tuple[str, str, str], - OpenLIFUFieldData("Dimensions", "The name of the dimensions of the graph."), - ] = ("x", "y", "z") - ``` - - Annotated[] does not interfere with runtime behavior or type compatibility. + Lightweight metadata attached to a dataclass field via :class:`typing.Annotated`, + primarily consumed by GUI editors (e.g. SlicerOpenLIFU) to render fields with + human-friendly labels, units, and tooltips. + + Example:: + + class Pulse: + frequency: Annotated[ + float, + OpenLIFUFieldData( + name="Frequency", + description="Frequency of the pulse", + units="Hz", + display_units="kHz", + precision=1, + ), + ] = 400e3 + + The presence of ``Annotated[]`` does not affect runtime behavior or type + compatibility, and these fields are *not* serialized -- they describe how to + *display* the underlying value. The stored value remains in ``units``. + + Backwards compatibility: callers that historically constructed + ``OpenLIFUFieldData("Name", "Description")`` positionally continue to work + because ``name`` and ``description`` remain the first two fields and all + new fields are optional. + + Attributes: + name: Display label shown next to the field in editors. ``None`` falls + back to the dataclass field's attribute name. + description: Tooltip text shown on hover. ``None`` falls back to a generic + placeholder. + units: Storage units. The unit in which the underlying dataclass value + is stored (e.g. ``"Hz"``, ``"s"``, ``"Pa"``, ``"m"``, ``"deg"``). + ``None`` means no unit semantics are attached. + display_units: Preferred units for human display (e.g. ``"kHz"``, + ``"ms"``, ``"kPa"``, ``"mm"``). When set, editors should convert + from ``units`` to ``display_units`` for display, and back when + saving. ``None`` (default) means display in ``units``. + unit_options: Optional tuple of unit symbols a user may switch between + for display. Reserved for future use (units dropdown). + precision: Number of decimal places to display. ``None`` means use the + editor's default. + units_field: Optional name of a sibling dataclass field on the same + instance whose value provides the storage unit dynamically (e.g. + ``"distance_units"``). When set, editors should NOT auto-convert; + instead they should display the value as-is and label it with the + sibling's unit symbol. Mutually exclusive with ``units``. """ - name: Annotated[str | None, "The name of the dataclass field."] - description: Annotated[str | None, "The description of the dataclass field."] + name: Optional[str] = None + description: Optional[str] = None + units: Optional[str] = None + display_units: Optional[str] = None + unit_options: Tuple[str, ...] = field(default_factory=tuple) + precision: Optional[int] = None + units_field: Optional[str] = None diff --git a/src/openlifu/util/field_display.py b/src/openlifu/util/field_display.py new file mode 100644 index 00000000..219f0b91 --- /dev/null +++ b/src/openlifu/util/field_display.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from dataclasses import fields, is_dataclass +from typing import Any, Optional, Tuple, get_args, get_origin + +try: + # Python 3.10+ provides typing.get_type_hints with include_extras, but the + # rest of the codebase already uses ``get_type_hints`` from ``typing``. + from typing import get_type_hints as _get_type_hints +except ImportError: # pragma: no cover - defensive + from typing_extensions import get_type_hints as _get_type_hints # type: ignore + +from openlifu.util.annotations import OpenLIFUFieldData +from openlifu.util.units import getunitconversion + + +def get_field_metadata(cls: type, field_name: str) -> Optional[OpenLIFUFieldData]: + """Return the :class:`OpenLIFUFieldData` annotation attached to ``cls.field_name``. + + Returns ``None`` if the field has no such annotation, if it has no + ``Annotated[]`` wrapping, or if the class is not a dataclass. + """ + if not is_dataclass(cls): + return None + try: + hints = _get_type_hints(cls, include_extras=True) + except Exception: + return None + annotated_type = hints.get(field_name) + if annotated_type is None: + return None + args = get_args(annotated_type) + if get_origin(annotated_type) is None or len(args) < 2: + return None + for meta in args[1:]: + if isinstance(meta, OpenLIFUFieldData): + return meta + return None + + +def resolve_units(meta: OpenLIFUFieldData, instance: Any) -> Tuple[Optional[str], Optional[str]]: + """Return ``(storage_units, display_units)`` for ``meta`` applied to ``instance``. + + * Storage unit: ``instance.`` when ``units_field`` is set, + otherwise ``meta.units``. + * Display unit: ``meta.display_units`` when set, otherwise the storage unit. + + This means ``units_field`` and ``display_units`` may be combined: the value + is *stored* in whatever unit the sibling field reports, but the editor + *displays* it in the fixed ``display_units`` (e.g. always ``"mm"`` for a + distance field, regardless of the protocol's ``distance_units`` setting). + """ + if meta.units_field: + sibling = getattr(instance, meta.units_field, None) if instance is not None else None + else: + sibling = None + storage = sibling if meta.units_field else meta.units + display = meta.display_units or storage + return storage, display + + +def to_display(value: float, meta: OpenLIFUFieldData, instance: Optional[Any] = None) -> float: + """Convert ``value`` from storage units to ``meta``'s display units. + + Falls back to the input value if conversion is not possible (no units + declared, or a unit-conversion failure).""" + if value is None: + return value + storage_unit, display_unit = resolve_units(meta, instance) + if storage_unit is None or display_unit is None or storage_unit == display_unit: + return value + try: + return float(value) * getunitconversion(storage_unit, display_unit) + except Exception: + return value + + +def from_display(value: float, meta: OpenLIFUFieldData, instance: Optional[Any] = None) -> float: + """Inverse of :func:`to_display`: convert from display units to storage units.""" + if value is None: + return value + storage_unit, display_unit = resolve_units(meta, instance) + if storage_unit is None or display_unit is None or storage_unit == display_unit: + return value + try: + return float(value) * getunitconversion(display_unit, storage_unit) + except Exception: + return value + + +def format_value( + value: Any, + meta: Optional[OpenLIFUFieldData] = None, + instance: Optional[Any] = None, +) -> str: + """Format ``value`` using the precision/units in ``meta``. + + Numeric values are converted to ``meta.display_units`` (when known), + rounded to ``meta.precision`` (default 2 decimals for floats, no rounding + for ints), with trailing zeros trimmed, and the unit symbol appended. + Non-numeric values are passed through ``str()``. + """ + if value is None: + return "" + if meta is None: + if isinstance(value, float): + return _format_number(value, None) + return str(value) + + # Tuple/list: format element-wise, share unit suffix at the end + if isinstance(value, (tuple, list)): + formatted = [format_value(v, meta, instance) for v in value] + # Strip per-element unit so we don't repeat it; we'll add once at the end. + unit_suffix = _display_unit_suffix(meta, instance) + if unit_suffix: + stripped = [s[: -len(unit_suffix)].rstrip() if s.endswith(unit_suffix) else s for s in formatted] + return ", ".join(stripped) + " " + unit_suffix + return ", ".join(formatted) + + if isinstance(value, bool): + return "yes" if value else "no" + + if isinstance(value, (int, float)): + # Convert to the display unit. We always go through float so that, for + # an int value with display-unit conversion (e.g. an integer count of + # microns displayed in mm), we still get the proper scaled number. + display_value = to_display(float(value), meta, instance) + if isinstance(value, int) and (meta.display_units is None or meta.display_units == meta.units): + # No conversion needed for an int field; keep it integral. + display_value = value + precision = meta.precision + text = _format_number(display_value, precision) + suffix = _display_unit_suffix(meta, instance) + return f"{text} {suffix}" if suffix else text + + return str(value) + + +def _display_unit_suffix(meta: OpenLIFUFieldData, instance: Optional[Any]) -> str: + _, display_unit = ( + resolve_units(meta, instance) if instance is not None else (meta.units, meta.display_units or meta.units) + ) + return display_unit or "" + + +def _strip_trailing_zeros(text: str) -> str: + """Remove trailing zeros (and a trailing dot) from a fixed-precision float string.""" + if "." not in text or "e" in text or "E" in text: + return text + stripped = text.rstrip("0").rstrip(".") + return stripped if stripped not in ("", "-") else "0" + + +def _format_number(value: Any, precision: Optional[int]) -> str: + """Format a number with ``precision`` decimal places, stripping trailing zeros. + + Falls back to ``%g`` formatting whenever a fixed-precision render would + clip a non-zero value to ``"0"`` (for example, ``0.0025`` with + ``precision=1``). This mirrors the intent: don't print + ``300.0000000``, but also don't lose the entire value just because the + declared precision is too coarse for an unusually small number. + """ + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, int): + return str(value) + if not isinstance(value, float): + return str(value) + if precision is None: + return _strip_trailing_zeros(f"{value:.6g}") + fixed = f"{value:.{precision}f}" + fixed = _strip_trailing_zeros(fixed) + if fixed in ("0", "-0") and value != 0.0: + # The declared precision would erase the value entirely; use %g so + # the reader can still see the magnitude. + return _strip_trailing_zeros(f"{value:.6g}") + return fixed + + +def field_summary(instance: Any, field_name: str, label: Optional[str] = None) -> Optional[str]: + """Return ``"