diff --git a/examples/verification/tst01_console_selftest.py b/examples/verification/tst01_console_selftest.py index 9f027fce..045fabd4 100644 --- a/examples/verification/tst01_console_selftest.py +++ b/examples/verification/tst01_console_selftest.py @@ -2,8 +2,6 @@ import sys -import base58 - from openlifu_sdk.io.LIFUInterface import LIFUInterface # set PYTHONPATH=%cd%\src;%PYTHONPATH% @@ -46,8 +44,7 @@ print("Get HW ID") hw_id = interface.hvcontroller.get_hardware_id() print(f"HW ID: {hw_id}") -encoded_id = base58.b58encode(bytes.fromhex(hw_id)).decode() -print(f"OW-LIFU-CON-{encoded_id}") +print(f"OW-LIFU-CON-{hw_id}") print("Get Temperature1") temp1 = interface.hvcontroller.get_temperature1() 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/pyproject.toml b/pyproject.toml index 4652de68..738dbf8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,74 +29,72 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "xarray[io]", - "numpy<2", - "pandas", - "scipy", - "requests", + "xarray[io]>=2026.2.0", + "numpy>=2.0.0", + "pandas>=3.0.2", + "scipy>=1.14.1", + "requests>=2.33.1", ] [project.optional-dependencies] jupyter = [ - "ipykernel", - "matplotlib", + "ipykernel>=7.2.0", + "matplotlib>=3.10.0", ] mesh = [ - "embreex; platform_machine=='x86_64' or platform_machine=='AMD64'", - "trimesh", - "scikit-image", - "vtk", - "Pillow", - "OpenEXR" + "embreex>=2.17.7.post7; platform_machine=='x86_64' or platform_machine=='AMD64'", + "trimesh>=4.11.5", + "scikit-image>=0.26.0", + "vtk>=9.6.1", + "Pillow>=12.2.0", + "OpenEXR>=3.4.9" ] db = [ - "nibabel", - "pydicom" + "nibabel>=5.4.2", + "pydicom>=3.0.2" ] io = [ - "base58", - "crc", - "crcmod", - "pyserial", - "openlifu-sdk>=2.0.12" + "crc>=7.1.0", + "crcmod>=1.7", + "openlifu-sdk>=2.0.14" ] sim = [ "k-wave-python==0.4.0", - "nvidia-ml-py" + "nvidia-ml-py>=13.595.45" ] cloud = [ - "watchdog", - "python-socketio[client]" + "watchdog>=6.0.0", + "python-socketio[client]>=5.16.1" ] photogrammetry = [ - "embreex; platform_machine=='x86_64' or platform_machine=='AMD64'", - "opencv-contrib-python", - "onnxruntime==1.18.0", - "trimesh", - "scikit-image", - "vtk", - "Pillow", - "OpenEXR" + "embreex>=2.17.7.post7; platform_machine=='x86_64' or platform_machine=='AMD64'", + "opencv-contrib-python>=4.11.0.86", + "onnxruntime>=1.20.0", + "trimesh>=4.11.5", + "scikit-image>=0.26.0", + "vtk>=9.6.1", + "Pillow>=12.2.0", + "OpenEXR>=3.4.9" ] dev = [ "pytest >=6", "pytest-cov >=3", - "pytest-mock", - "dvc[gdrive]", + "pytest-mock>=3.15.1", + "dvc[gdrive]>=3.67.1", ] docs = [ "openlifu[mesh, db, cloud]", "sphinx>=7.0", "myst_parser>=0.13", - "sphinx_copybutton", - "sphinx_autodoc_typehints", + "sphinx_copybutton>=0.5.2", + "sphinx_autodoc_typehints>=3.9.11", "furo>=2023.08.17", ] test = [ "openlifu[mesh, db, io, sim, cloud, photogrammetry]", "pytest >=6", "pytest-cov >=3", - "pytest-mock", + "pytest-mock>=3.15.1", ] all = [ "openlifu[jupyter, mesh, db, io, sim, cloud, photogrammetry, test, dev, docs]", 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/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..0eaee8e0 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 @@ -146,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) @@ -172,7 +179,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"], diff --git a/src/openlifu/db/database.py b/src/openlifu/db/database.py index f82dfa48..0052bb05 100644 --- a/src/openlifu/db/database.py +++ b/src/openlifu/db/database.py @@ -10,7 +10,7 @@ from typing import Dict, List from openlifu.nav.photoscan import Photoscan, load_data_from_photoscan -from openlifu.plan import Protocol, Run, Solution +from openlifu.plan import Protocol, Run, Solution, SolutionAnalysis from openlifu.util.json import PYFUSEncoder from openlifu.util.types import PathLike from openlifu.util.volume_conversion import ( @@ -568,6 +568,57 @@ def write_solution(self, session:Session, solution:Solution, on_conflict: OnConf self.logger.info(f"Wrote solution with ID {solution.id} to the database.") + def write_solution_analysis( + self, + session: Session, + solution_id: str, + analysis: SolutionAnalysis, + on_conflict: OnConflictOpts | str = OnConflictOpts.OVERWRITE, + ) -> None: + """Write a SolutionAnalysis next to its parent Solution. + + The analysis file lives at ``/_analysis.json``. Defaults to overwriting + because the analysis is a derived artifact: re-running ``Solution.analyze`` should always be safe to + re-persist over a stale copy. + """ + on_conflict = _normalize_on_conflict(on_conflict) + analysis_filepath = self.get_solution_analysis_filepath(session.subject_id, session.id, solution_id) + if analysis_filepath.exists(): + if on_conflict == OnConflictOpts.ERROR: + raise ValueError( + f"SolutionAnalysis for solution {solution_id} already exists in the database." + ) + if on_conflict == OnConflictOpts.SKIP: + self.logger.info( + f"Skipping SolutionAnalysis for solution {solution_id} as it already exists." + ) + return + if on_conflict == OnConflictOpts.OVERWRITE: + self.logger.info( + f"Overwriting SolutionAnalysis for solution {solution_id} in the database." + ) + else: + raise ValueError("Invalid 'on_conflict' option. Use 'error', 'overwrite', or 'skip'.") + analysis_filepath.parent.mkdir(parents=True, exist_ok=True) + analysis_filepath.write_text(analysis.to_json(compact=False)) + self.logger.info( + f"Wrote SolutionAnalysis for solution {solution_id} to the database." + ) + + def load_solution_analysis(self, session: Session, solution_id: str) -> SolutionAnalysis: + """Load the SolutionAnalysis associated with the given Solution within the given Session.""" + analysis_filepath = self.get_solution_analysis_filepath(session.subject_id, session.id, solution_id) + if not analysis_filepath.exists() or not analysis_filepath.is_file(): + self.logger.error( + f"SolutionAnalysis file not found for solution {solution_id}, session {session.id}" + ) + raise FileNotFoundError( + f"SolutionAnalysis file not found for solution {solution_id}, session {session.id}" + ) + analysis = SolutionAnalysis.from_json(analysis_filepath.read_text()) + self.logger.info(f"Loaded SolutionAnalysis for solution {solution_id}") + return analysis + def choose_session(self, subject, options=None): # Implement the logic to choose a session raise NotImplementedError("Method not yet implemented") @@ -912,7 +963,48 @@ def load_session(self, subject, session_id, options=None): options = {} session_filename = self.get_session_filename(subject.id, session_id) if os.path.isfile(session_filename): + # Read raw JSON first so we can detect legacy sessions that lack + # the photoscans / photocollections index fields and migrate them + # from the on-disk index files. Present-but-empty lists are + # respected (user may have intentionally cleared them). + with open(session_filename) as _legacy_fh: + _raw_session_dict = json.load(_legacy_fh) session = Session.from_file(session_filename) + if 'photoscans' not in _raw_session_dict: + try: + session.photoscans = list(self.get_photoscan_ids(subject.id, session_id) or []) + except Exception: + session.photoscans = [] + if 'photocollections' not in _raw_session_dict: + try: + session.photocollections = list(self.get_photocollection_reference_numbers(subject.id, session_id) or []) + except Exception: + session.photocollections = [] + # Drop any transducer_tracking_results whose photoscan_id no + # longer exists in this session's photoscans index. The session + # JSON and the photoscans index can drift out of sync (e.g. a + # photoscan was deleted from the index without the session JSON + # being rewritten), and ``write_session`` rejects sessions whose + # tracking results reference unknown photoscans. Sanitizing on + # load means a freshly-loaded session can round-trip through + # ``write_session`` without surprise validation errors on exit. + if session.transducer_tracking_results: + indexed_photoscan_ids = set(self.get_photoscan_ids(subject.id, session_id)) + kept: list = [] + dropped: list[str] = [] + for result in session.transducer_tracking_results: + if result.photoscan_id in indexed_photoscan_ids: + kept.append(result) + else: + dropped.append(result.photoscan_id) + if dropped: + self.logger.warning( + "Dropping %d transducer_tracking_result(s) from session " + "%s of subject %s referencing photoscan id(s) not in " + "this session's photoscans index: %s", + len(dropped), session_id, subject.id, sorted(set(dropped)), + ) + session.transducer_tracking_results = kept self.logger.info(f"Loaded session {session_id} for subject {subject.id}") return session else: @@ -1022,6 +1114,14 @@ def get_solution_filepath(self, subject_id, session_id, solution_id) -> Path: session_dir = self.get_session_dir(subject_id, session_id) return Path(session_dir) / 'solutions' / solution_id / f"{solution_id}.json" + def get_solution_analysis_filepath(self, subject_id, session_id, solution_id) -> Path: + """Get the solution-analysis json file for the solution with the given ID. + + Lives next to the solution itself so the analysis and the solution that produced it stay together. + """ + session_dir = self.get_session_dir(subject_id, session_id) + return Path(session_dir) / 'solutions' / solution_id / f"{solution_id}_analysis.json" + def get_solutions_filename(self, subject_id, session_id) -> Path: """Get the path to the overall solutions json file for the requested session""" session_dir = self.get_session_dir(subject_id, session_id) diff --git a/src/openlifu/db/session.py b/src/openlifu/db/session.py index aac35bcf..0288b92f 100644 --- a/src/openlifu/db/session.py +++ b/src/openlifu/db/session.py @@ -16,11 +16,38 @@ from openlifu.util.strings import sanitize +@dataclass +class PhotoscanRegistration: + """A registration of a photoscan model into the volume coordinate frame. + + A photoscan may have multiple registration attempts stored on the session; at most one is + expected to be approved at a time, though this is not enforced at the dataclass level. + Downstream transducer-tracking results refer back to a specific registration by ``id``. + """ + + photoscan_id: Annotated[str, OpenLIFUFieldData("Photoscan ID", "ID of the photoscan that this registration applies to")] + """ID of the photoscan that this registration applies to""" + + transform: Annotated[ArrayTransform, OpenLIFUFieldData("Photoscan-to-volume transform", "Transform that registers the photoscan model to the volume's skin segmentation")] + """Transform that registers the photoscan model to the volume's skin segmentation""" + + approval: Annotated[bool, OpenLIFUFieldData("Registration approved?", "Whether this photoscan registration has been approved by the user.")] = False + """Whether this photoscan registration has been approved by the user.""" + + id: Annotated[str | None, OpenLIFUFieldData("Registration ID", "Stable identifier for this photoscan registration, unique within the session. Survives reordering or deletion of other registrations so that downstream references (e.g. transducer tracking results) remain valid.")] = None + """Stable identifier for this photoscan registration, unique within the session.""" + + @dataclass class TransducerTrackingResult: """ Class representing the results of running the transducer tracking algorithm. + + Each result registers a transducer pose against a volume in the context of a particular + photoscan registration. The photoscan-to-volume transform that the tracking was performed + against is stored separately as a :class:`PhotoscanRegistration` and referenced here by + ``photoscan_registration_id``. """ photoscan_id: Annotated[str, OpenLIFUFieldData("Photoscan ID", "ID of the photoscan object used for transducer tracking")] @@ -29,16 +56,19 @@ class TransducerTrackingResult: transducer_to_volume_transform: Annotated[ArrayTransform, OpenLIFUFieldData("Transducer to volume transform", "Transform output by transducer tracking algorithm to register the transducer surface to the volume")] """Transform output by transducer tracking algorithm to register the transducer surface to the volume""" - photoscan_to_volume_transform: Annotated[ArrayTransform, OpenLIFUFieldData("Photoscan to volume transform", "Transform output by the transducer tracking algorithm to register the photoscan model the volume's skin segmentation")] - """Transform output by the transducer tracking algorithm to register the photoscan model the volume's skin segmentation""" + photoscan_registration_id: Annotated[str | None, OpenLIFUFieldData("Photoscan registration ID", "ID of the PhotoscanRegistration this tracking result was computed against. May be None for legacy entries imported from sessions saved before the registration concept was split out.")] = None + """ID of the :class:`PhotoscanRegistration` this tracking result was computed against.""" + + approval: Annotated[bool, OpenLIFUFieldData("Tracking approved?", "Approval state of the transducer tracking result. `True` means the user has provided some kind of confirmation that the transform result agrees with reality.")] = False + """Approval state of the transducer tracking result. ``True`` means the user has provided + some kind of confirmation that the transform result agrees with reality.""" - transducer_to_volume_tracking_approved: Annotated[bool, OpenLIFUFieldData("Transducer tracking approved?", "Approval state of transducer to volume tracking result. `True` means the user has provided some kind of confirmation that the transform result agrees with reality.")] = False - """Approval state of transducer to volume tracking result. `True` means the user has provided some kind of - confirmation that the transform result agrees with reality.""" + id: Annotated[str | None, OpenLIFUFieldData("Result ID", "Stable identifier for this tracking result, unique within the session. Survives reordering or deletion of other results so that downstream references (e.g. solutions) remain valid.")] = None + """Stable identifier for this tracking result, unique within the session. Survives reordering or + deletion of other results so that downstream references (e.g. solutions) remain valid.""" - photoscan_to_volume_tracking_approved: Annotated[bool, OpenLIFUFieldData("Photoscan tracking approved?", "Approval state of photoscan to volume tracking result. `True` means the user has provided some kind of confirmation that the transform result agrees with reality.")] = False - """Approval state of photoscan to volume tracking result. `True` means the user has provided some kind of - confirmation that the transform result agrees with reality.""" + target_id: Annotated[str | None, OpenLIFUFieldData("Target ID", "ID of the openlifu Point target this tracking result was computed for, if any.")] = None + """ID of the openlifu Point target this tracking result was computed for, if any.""" @dataclass class Session: @@ -71,6 +101,15 @@ class Session: transducer_id: Annotated[str | None, OpenLIFUFieldData("Transducer ID", "ID of the transducer associated with this session")] = None """ID of the transducer associated with this session""" + solution_id: Annotated[str, OpenLIFUFieldData("Solution ID", "ID of the most recently computed sonication Solution for this session, or '' if there is none. Cleared whenever the array_transform changes because a Solution is only valid for the transducer pose it was computed against.")] = "" + """ID of the most recently computed sonication ``Solution`` for this session, or ``""`` if there is none. + + Cleared whenever the ``array_transform`` changes (manual move, virtual fit, transducer tracking), because a + ``Solution`` is only valid for the specific transducer pose it was computed against. Consumers loading a + session can use this to fetch the persisted ``Solution`` (and its analysis) from the database rather than + re-running the simulation. + """ + array_transform: Annotated[ArrayTransform, OpenLIFUFieldData("Array transform", "The transducer affine transform matrix with units, situating the transducer in space")] = field(default_factory=lambda: ArrayTransform(np.eye(4), "mm")) """The transducer affine transform matrix with units, situating the transducer in space""" @@ -80,6 +119,18 @@ class Session: markers: Annotated[List[Point], OpenLIFUFieldData("Markers", "Registration markers saved to this session")] = field(default_factory=list) """Registration markers saved to this session""" + photoscans: Annotated[List[str], OpenLIFUFieldData("Photoscan IDs", "IDs of photoscans that belong to this session. Each ID corresponds to a Photoscan stored under the session's ``photoscans/`` directory in the database.")] = field(default_factory=list) + """IDs of photoscans that belong to this session. Each ID corresponds to a Photoscan + stored under the session's ``photoscans/`` directory in the database. This is the + authoritative list used to decide which photoscans to keep on save; legacy sessions + that omit this field are auto-populated from the on-disk index on load.""" + + photocollections: Annotated[List[str], OpenLIFUFieldData("Photocollection reference numbers", "Reference numbers of photocollections that belong to this session. Each entry corresponds to a directory under the session's ``photocollections/`` directory in the database.")] = field(default_factory=list) + """Reference numbers (scan IDs) of photocollections that belong to this session. + Each entry corresponds to a directory under the session's ``photocollections/`` + directory in the database. Legacy sessions that omit this field are auto-populated + from the on-disk index on load.""" + attrs: Annotated[dict, OpenLIFUFieldData("Custom attributes", "Dictionary of additional custom attributes to save to the session")] = field(default_factory=dict) """Dictionary of additional custom attributes to save to the session""" @@ -98,6 +149,10 @@ class Session: transducer_tracking_results: Annotated[List[TransducerTrackingResult], OpenLIFUFieldData("Tracking results", "List of any transducer tracking results")] = field(default_factory=list) """List of any transducer tracking results""" + photoscan_registrations: Annotated[List[PhotoscanRegistration], OpenLIFUFieldData("Photoscan registrations", "List of photoscan-to-volume registrations stored on this session.")] = field(default_factory=list) + """List of photoscan-to-volume registrations stored on this session. Each transducer tracking + result references one of these registrations by ``photoscan_registration_id``.""" + def __post_init__(self): if self.id is None and self.name is None: self.id = "session" @@ -141,17 +196,62 @@ def from_dict(d:Dict): raise ValueError("Sessions no longer recognize a volume attribute -- it is now volume_id.") if 'array_transform' in d: d['array_transform'] = ArrayTransform.from_dict(d['array_transform']) + + # PhotoscanRegistrations are split out of TT results as of the multi-registration refactor. + # Old session JSONs lack this key; if absent we start with an empty list and may populate it + # below when migrating legacy TT entries that still carry an embedded photoscan_to_volume_transform. + if 'photoscan_registrations' in d: + d['photoscan_registrations'] = [ + PhotoscanRegistration( + photoscan_id=p['photoscan_id'], + transform=ArrayTransform.from_dict(p['transform']), + approval=p.get('approval', False), + id=p.get('id'), + ) + for p in d['photoscan_registrations'] + ] + else: + d['photoscan_registrations'] = [] + if 'transducer_tracking_results' in d: - d['transducer_tracking_results'] = [ - TransducerTrackingResult( - t['photoscan_id'], - ArrayTransform.from_dict(t['transducer_to_volume_transform']), - ArrayTransform.from_dict(t['photoscan_to_volume_transform']), - t['transducer_to_volume_tracking_approved'], - t['photoscan_to_volume_tracking_approved'] - ) - for t in d['transducer_tracking_results'] - ] + # Per-photoscan counter for any registrations we synthesize during legacy migration; + # continues past the count of registrations already present so we don't collide. + pr_count_by_photoscan: Dict[str, int] = {} + for pr in d['photoscan_registrations']: + pr_count_by_photoscan[pr.photoscan_id] = pr_count_by_photoscan.get(pr.photoscan_id, 0) + 1 + + migrated_tt: List[TransducerTrackingResult] = [] + for t in d['transducer_tracking_results']: + if 'photoscan_to_volume_transform' in t: + # Legacy entry: split the embedded PV transform out into its own registration. + pid = t['photoscan_id'] + n = pr_count_by_photoscan.get(pid, 0) + pr_count_by_photoscan[pid] = n + 1 + synthesized_pr_id = f"{pid}__pr__{n:02d}" + d['photoscan_registrations'].append(PhotoscanRegistration( + photoscan_id=pid, + transform=ArrayTransform.from_dict(t['photoscan_to_volume_transform']), + approval=t.get('photoscan_to_volume_tracking_approved', False), + id=synthesized_pr_id, + )) + migrated_tt.append(TransducerTrackingResult( + photoscan_id=pid, + transducer_to_volume_transform=ArrayTransform.from_dict(t['transducer_to_volume_transform']), + photoscan_registration_id=synthesized_pr_id, + approval=t.get('transducer_to_volume_tracking_approved', t.get('approval', False)), + id=t.get('id'), + target_id=t.get('target_id'), + )) + else: + migrated_tt.append(TransducerTrackingResult( + photoscan_id=t['photoscan_id'], + transducer_to_volume_transform=ArrayTransform.from_dict(t['transducer_to_volume_transform']), + photoscan_registration_id=t.get('photoscan_registration_id'), + approval=t.get('approval', t.get('transducer_to_volume_tracking_approved', False)), + id=t.get('id'), + target_id=t.get('target_id'), + )) + d['transducer_tracking_results'] = migrated_tt if isinstance(d['targets'], list): if len(d['targets'])>0 and isinstance(d['targets'][0], dict): d['targets'] = [Point.from_dict(p) for p in d['targets']] @@ -192,6 +292,7 @@ def to_dict(self): ] d['transducer_tracking_results'] = [asdict(t) for t in d['transducer_tracking_results']] + d['photoscan_registrations'] = [asdict(r) for r in d['photoscan_registrations']] return d diff --git a/src/openlifu/plan/__init__.py b/src/openlifu/plan/__init__.py index d3189b3d..48f49515 100644 --- a/src/openlifu/plan/__init__.py +++ b/src/openlifu/plan/__init__.py @@ -14,5 +14,5 @@ "SolutionAnalysis", "SolutionAnalysisOptions", "TargetConstraints", - "ParameterConstraint", + "ParameterConstraint" ] diff --git a/src/openlifu/plan/param_constraint.py b/src/openlifu/plan/param_constraint.py index 0364b9d7..fe08d163 100644 --- a/src/openlifu/plan/param_constraint.py +++ b/src/openlifu/plan/param_constraint.py @@ -10,8 +10,8 @@ PARAM_STATUS_SYMBOLS = { "ok": "✅", - "warning": "❗", - "error": "❌" + "warning": "⚠️", + "error": "⛔", } @dataclass diff --git a/src/openlifu/plan/solution.py b/src/openlifu/plan/solution.py index e88f9bdc..67f85bc6 100644 --- a/src/openlifu/plan/solution.py +++ b/src/openlifu/plan/solution.py @@ -192,6 +192,80 @@ def simulate(self, dim='focal_point_index', ) + def get_mainlobe_mask(self, + simulation_result: xa.Dataset | None = None, + options: SolutionAnalysisOptions = SolutionAnalysisOptions(), + units: str | None = None + ) -> xa.DataArray: + """Get a masked version of the simulation result where only the mainlobe is unmasked. + """ + return self.get_mask(simulation_result=simulation_result, type='mainlobe', options=options, units=units) + + def get_sidelobe_mask(self, + simulation_result: xa.Dataset | None = None, + options: SolutionAnalysisOptions = SolutionAnalysisOptions(), + units: str | None = None + ) -> xa.DataArray: + """Get a masked version of the simulation result where only the sidelobe is unmasked. + """ + return self.get_mask(simulation_result=simulation_result, type='sidelobe', options=options, units=units) + + def get_mask(self, + simulation_result: xa.Dataset | None = None, + type='mainlobe', + options: SolutionAnalysisOptions = SolutionAnalysisOptions(), + units: str | None = None) -> xa.DataArray: + """Get a masked version of the simulation result where only the mainlobe is unmasked. + """ + if simulation_result is None: + if self.simulation_result is None or len(self.simulation_result)==0: + raise ValueError("No simulation result provided for masking, and no simulation result found in the Solution.") + simulation_result = self.simulation_result + simulation_result_scaled = rescale_coords(simulation_result, options.distance_units) + masks = [] + wavelength = options.ref_sound_speed / self.pulse.frequency + for focus_index in range(self.num_foci()): + focus = self.foci[focus_index].get_position(units=options.distance_units) + apodization = self.apodizations[focus_index] + origin = self.transducer.get_effective_origin(apodizations=apodization, units=options.distance_units) + aperture_dia = self.transducer.get_effective_aperture_radius(apodizations=apodization, units=options.distance_units)*2 + focal_dist = np.linalg.norm(np.array(focus) - np.array(origin)) + fnum = np.max([focal_dist / aperture_dia, 1.0]) + if type == 'mainlobe': + if options.mainlobe_radius is None: + distance = fnum * wavelength + else: + distance = options.mainlobe_radius + aspect_ratio = options.mainlobe_aspect_ratio + operator = '<' + elif type == 'sidelobe': + if options.sidelobe_radius is None: + distance = 2.5 * fnum * wavelength + else: + distance = options.sidelobe_radius + aspect_ratio = options.mainlobe_aspect_ratio + operator = '>' + else: + raise ValueError(f"Invalid mask type {type}. Must be 'mainlobe' or 'sidelobe'.") + mask = get_mask( + simulation_result_scaled.isel(focal_point_index=focus_index), + focus = focus, + origin = origin, + distance = distance, + operator = operator, + aspect_ratio = aspect_ratio + ) + if type == 'sidelobe' and options.sidelobe_zmin is not None: + z_mask = simulation_result_scaled.isel(focal_point_index=focus_index).z > options.sidelobe_zmin + mask = mask.where(z_mask, False) + masks.append(mask) + masks = xa.concat(masks, dim='focal_point_index') + if units is None and 'units' in simulation_result.coords['x'].attrs: + units = simulation_result.coords['x'].attrs['units'] + if units is not None: + masks = rescale_coords(masks, units) + return masks + def analyze(self, simulation_result: xa.Dataset | None = None, options: SolutionAnalysisOptions = SolutionAnalysisOptions(), @@ -211,40 +285,39 @@ def analyze(self, solution_analysis = SolutionAnalysis() dt = 1 / (self.pulse.frequency * 20) - t = self.pulse.calc_time(dt) - input_signal_V = self.pulse.calc_pulse(t) * self.voltage if simulation_result is None: if self.simulation_result is None or len(self.simulation_result)==0: raise ValueError("No simulation result provided for analysis, and no simulation result found in the Solution.") simulation_result = self.simulation_result - pnp_MPa_all = rescale_data_arr(rescale_coords(simulation_result['p_min'], options.distance_units),"MPa") - ipa_Wcm2_all = rescale_data_arr(rescale_coords(simulation_result['intensity'], options.distance_units), "W/cm^2") + simulation_result_scaled = rescale_coords(simulation_result, options.distance_units) + pnp_MPa_all = rescale_data_arr(simulation_result_scaled['p_min'],"MPa") + ipa_Wcm2_all = rescale_data_arr(simulation_result_scaled['intensity'], "W/cm^2") if options.sidelobe_radius is np.nan: options.sidelobe_radius = options.mainlobe_radius - standoff_Z = options.standoff_density * 1500 c_tic = 40e-3 # W cm-1 A_cm = self.transducer.get_area("cm") d_eq_cm = np.sqrt(4*A_cm / np.pi) ele_sizes_cm2 = np.array([elem.get_area("cm") for elem in self.transducer.elements]) - # xyz = np.stack(np.meshgrid(*coords, indexing="xy"), axis=-1) #TODO: if fus.Axis is defined, coords.ndgrid(dim="z") - # z_mask = xyz[..., -1] >= options.sidelobe_zmin #TODO: probably wrong here, should be z{1}>=options.sidelobe_zmin; - solution_analysis.duty_cycle_pulse_train_pct = self.get_pulsetrain_dutycycle()*100 solution_analysis.duty_cycle_sequence_pct = self.get_sequence_dutycycle()*100 if self.sequence.pulse_train_interval == 0: solution_analysis.sequence_duration_s = float(self.sequence.pulse_interval * self.sequence.pulse_count * self.sequence.pulse_train_count) else: solution_analysis.sequence_duration_s = float(self.sequence.pulse_train_interval * self.sequence.pulse_train_count) - ita_mWcm2 = rescale_coords(self.get_ita(intensity=simulation_result['intensity'], units="mW/cm^2"), options.distance_units) + ita_mWcm2 = self.get_ita(intensity=simulation_result_scaled['intensity'], units="mW/cm^2") power_W = np.zeros(self.num_foci()) TIC = np.zeros(self.num_foci()) + + mainlobe_masks = self.get_mainlobe_mask(simulation_result=simulation_result_scaled, options=options) + sidelobe_masks = self.get_sidelobe_mask(simulation_result=simulation_result_scaled, options=options) + for focus_index in range(self.num_foci()): pnp_MPa = pnp_MPa_all.isel(focal_point_index=focus_index) ipa_Wcm2 = ipa_Wcm2_all.isel(focal_point_index=focus_index) @@ -265,30 +338,16 @@ def analyze(self, amplitude=self.pulse.amplitude * self.voltage, ), axis=1) - mainlobe_mask = get_mask( - pnp_MPa, - focus = focus, - origin = origin, - distance = options.mainlobe_radius, - operator = '<', - aspect_ratio = options.mainlobe_aspect_ratio - ) - - sidelobe_mask = get_mask( - pnp_MPa, - focus = focus, - origin = origin, - distance = options.sidelobe_radius, - operator = '>', - aspect_ratio=options.mainlobe_aspect_ratio - ) - z_dim = pnp_MPa.dims[2] - z_mask = pnp_MPa.coords[z_dim] > options.sidelobe_zmin - sidelobe_mask = sidelobe_mask.where(z_mask, False) + mainlobe_mask = mainlobe_masks.isel(focal_point_index=focus_index) + sidelobe_mask = sidelobe_masks.isel(focal_point_index=focus_index) + z_mask = pnp_MPa.z > options.sidelobe_zmin + sidelobe_mask = sidelobe_mask.where(z_mask, other=False) pnp_mainlobe = pnp_MPa.where(mainlobe_mask) + ipa_mainlobe = ipa_Wcm2.where(mainlobe_mask) pk = float(pnp_mainlobe.max()) - mainlobe_focus = find_centroid(pnp_mainlobe, pk*10**(-3/20), "mm") + ipk = float(ipa_mainlobe.max()) + mainlobe_focus = find_centroid(ipa_mainlobe, ipk*10**(-3/20), "mm") solution_analysis.focal_centroid_lat_mm += [mainlobe_focus[0]] solution_analysis.focal_centroid_ele_mm += [mainlobe_focus[1]] solution_analysis.focal_centroid_ax_mm += [mainlobe_focus[2]] @@ -296,6 +355,15 @@ def analyze(self, solution_analysis.mainlobe_pnp_MPa += [pk] solution_analysis.focal_gain += [pk*1e6/np.max(p0_Pa)] + if options.beamwidth_radius is None: + aperture_dia = self.transducer.get_effective_aperture_radius(apodizations=apodization, units=options.distance_units)*2 + focal_dist = np.linalg.norm(np.array(focus) - np.array(origin)) + fnum = np.max([focal_dist / aperture_dia, 1.0]) + wavelength = options.ref_sound_speed / self.pulse.frequency + beamwidth_radius = 2.0 * fnum * wavelength + else: + beamwidth_radius = options.beamwidth_radius + for dim, named_dim, scale in zip(pnp_MPa.dims, ("lat","ele","ax"), options.mainlobe_aspect_ratio): for threshdB in [3, 6]: attr_name = f'beamwidth_{named_dim}_{threshdB}dB_mm' @@ -307,8 +375,8 @@ def analyze(self, dim=dim, cutoff=cutoff, origin=origin, - min_offset=-scale*options.beamwidth_radius, - max_offset=scale*options.beamwidth_radius) + min_offset=-scale*beamwidth_radius, + max_offset=scale*beamwidth_radius) bw = getunitconversion(options.distance_units, "mm") * bw bw = [*bw0, bw] setattr(solution_analysis, attr_name, bw) diff --git a/src/openlifu/plan/solution_analysis.py b/src/openlifu/plan/solution_analysis.py index d3e8a680..918ba9f8 100644 --- a/src/openlifu/plan/solution_analysis.py +++ b/src/openlifu/plan/solution_analysis.py @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) DEFAULT_ORIGIN = np.zeros(3) +DEFAULT_SIDELOBE_ZMIN_MM = 10.0 PARAM_FORMATS = { "mainlobe_pnp_MPa": ["max", "0.3f", "MPa", "Mainlobe Peak Negative Pressure"], @@ -50,7 +51,7 @@ "duty_cycle_pulse_train_pct": [None, "0.1f", "%", "Pulse Train Duty Cycle"], "duty_cycle_sequence_pct": [None, "0.1f", "%", "Sequence Duty Cycle"], "sequence_duration_s": [None, "0.0f", "s", "Sequence Duration"], - "estimated_tx_temperature_rise_C": [None, "0.2f", "°C", "Estimated TX Temperature Rise"]} + "estimated_tx_temperature_rise_C": [None, "0.2f", "°C", "Est. Transmitter Heating"]} @dataclass class SolutionAnalysis(DictMixin): @@ -237,40 +238,84 @@ 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.) - """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 - """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 + 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., 7.) + """Aspect ratio of the mainlobe ellipsoid mask, in the form (lat,ele,ax). (1,1,7) means an ellipsoid 7x as long as it is wide.""" + + mainlobe_radius: Annotated[float | None, 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, + )] = None + """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`. If not provided, will be calculated from estimated beamwidth""" + + beamwidth_radius: Annotated[float | None, 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, + )] = None """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 - """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_radius: Annotated[float | None, 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, + )] = None + """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. If not provided, will be estimated from the beamwidth * 1.5""" + + sidelobe_zmin: Annotated[float | None, 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, + )] = None """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) """TODO: Add description""" def __post_init__(self): + if not isinstance(self.distance_units, str): + raise TypeError("Distance units must be a string") + if getunittype(self.distance_units) != 'distance': + raise ValueError(f"Distance units must be a length unit, got {self.distance_units}") if self.standoff_sound_speed <= 0: raise ValueError("Standoff sound speed must be greater than 0") if self.standoff_density <= 0: @@ -284,18 +329,16 @@ def __post_init__(self): self.mainlobe_aspect_ratio = tuple(self.mainlobe_aspect_ratio) # Ensure it's a tuple if not all(isinstance(x, int | float) for x in self.mainlobe_aspect_ratio): raise TypeError("Mainlobe aspect ratio must contain only numbers") - if not isinstance(self.mainlobe_radius, int | float) or self.mainlobe_radius <= 0: + if self.mainlobe_radius is not None and (not isinstance(self.mainlobe_radius, int | float) or self.mainlobe_radius <= 0): raise ValueError("Mainlobe radius must be a positive number") - if not isinstance(self.beamwidth_radius, int | float) or self.beamwidth_radius <= 0: + if self.beamwidth_radius is not None and (not isinstance(self.beamwidth_radius, int | float) or self.beamwidth_radius <= 0): raise ValueError("Beamwidth radius must be a positive number") - if not isinstance(self.sidelobe_radius, int | float) or self.sidelobe_radius <= 0: + if self.sidelobe_radius is not None and (not isinstance(self.sidelobe_radius, int | float) or self.sidelobe_radius <= 0): raise ValueError("Sidelobe radius must be a positive number") + if self.sidelobe_zmin is None: + self.sidelobe_zmin = getunitconversion("mm", self.distance_units) * DEFAULT_SIDELOBE_ZMIN_MM if not isinstance(self.sidelobe_zmin, int | float) or self.sidelobe_zmin < 0: raise ValueError("Sidelobe minimum z must be a non-negative number") - if not isinstance(self.distance_units, str): - raise TypeError("Distance units must be a string") - if getunittype(self.distance_units) != 'distance': - raise ValueError(f"Distance units must be a length unit, got {self.distance_units}") @classmethod def from_dict(cls: Type[SolutionAnalysisOptions], parameter_dict: Dict[str, Any]) -> SolutionAnalysisOptions: @@ -313,6 +356,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': @@ -351,11 +403,11 @@ def get_focus_matrix(focus, origin=[0,0,0]) -> np.ndarray: M[3,3] = 1 return M -def get_gridded_transformed_coords(da: xa.DataArray, matrix: np.ndarray, as_dataset=True): - """Transform the coords of a DataArray using a transform matrix. +def get_gridded_transformed_coords(da: xa.DataArray | xa.Dataset, matrix: np.ndarray, as_dataset=True): + """Transform the coords of a DataArray or Dataset using a transform matrix. Args: - da: DataArray whose coordinates will be used + da: DataArray or Dataset whose coordinates will be used matrix: a 4x4 coordinate transformation matrix, transforming from the desired coordinate system to the coordinate system of `da` as_dataset: Whether to return the transformed coords as a numpy array or an xarray Dataset @@ -372,13 +424,13 @@ def get_gridded_transformed_coords(da: xa.DataArray, matrix: np.ndarray, as_data coords = xa.Dataset({f'd_{dim}': (da.dims, coords[...,i]) for i, dim in enumerate(da.dims)}, coords=da.coords) return coords -def get_offset_grid(da: xa.DataArray, focus, origin=DEFAULT_ORIGIN, as_dataset=True): +def get_offset_grid(da: xa.DataArray | xa.Dataset, focus, origin=DEFAULT_ORIGIN, as_dataset=True): """Transform the coords of a DataArray that is in transducer coordinates to focus coordinates See `get_focus_matrix` for the meaning of "focus coordinates" Args: - da: DataArray whose coordinates will be used (presumably the transducer coordinates) + da: DataArray or Dataset whose coordinates will be used (presumably the transducer coordinates) focus: A 3D point describing the focus location in the coordinates of `da` origin: A 3D point describing the "effective origin" in the coordinates of `da` (see `Transducer.get_effective_origin` for the meaning of this). @@ -391,12 +443,12 @@ def get_offset_grid(da: xa.DataArray, focus, origin=DEFAULT_ORIGIN, as_dataset=T coords = get_gridded_transformed_coords(da, M, as_dataset=as_dataset) return coords -def calc_dist_from_focus(da: xa.DataArray, focus, origin=DEFAULT_ORIGIN, aspect_ratio=[1,1,1], as_dataarray=True): +def calc_dist_from_focus(da: xa.DataArray | xa.Dataset, focus, origin=DEFAULT_ORIGIN, aspect_ratio=[1,1,1], as_dataarray=True): """Compute a distance map from a focus point in transducer space, using a possibly distorted metric that respects the symmetry of the focus shape (e.g. it could be cigar-shaped). Args: - da: DataArray that will supply the coordnate grid (presumably transducer coordinates) + da: DataArray or Dataset that will supply the coordnate grid (presumably transducer coordinates) focus: A 3D point describing the focus location in the coordinates of `da` origin: A 3D point describing the "effective origin" in the coordinates of `da` (see `Transducer.get_effective_origin` for the meaning of this). @@ -413,7 +465,7 @@ def calc_dist_from_focus(da: xa.DataArray, focus, origin=DEFAULT_ORIGIN, aspect_ return dist def get_mask( - da: xa.DataArray, + da: xa.DataArray | xa.Dataset, focus, distance:float, origin=DEFAULT_ORIGIN, @@ -425,7 +477,7 @@ def get_mask( The focus region is an ellipsoid centered at the focus point. Args: - da: DataArray that will supply the coordnate grid (presumably transducer coordinates) + da: DataArray or Dataset that will supply the coordnate grid (presumably transducer coordinates) focus: A 3D point describing the focus location in the coordinates of `da` distance: How far from the `focus` to include points in the mask. See `calc_dist_from_focus` for the distorted metric under which a ball of points becomes an ellispoid in euclidean space. @@ -504,6 +556,7 @@ def get_beam_bounds( origin=DEFAULT_ORIGIN, min_offset:float | None=None, max_offset:float | None=None, + clip_to_bounds:bool=True, ) -> Tuple[float, float]: """Determine how far along a focal coordinate system axis a DataArray's value stays above a certain cutoff. @@ -535,11 +588,15 @@ def get_beam_bounds( da_negoff = da_negoff.where(da_negoff < float(cutoff), drop=True) if da_negoff.size > 0: negoff = float(da_negoff.coords[f'offset_d{dim}'][-1]) + elif clip_to_bounds: + negoff = float(interp_da.coords[f'offset_d{dim}'][0]) else: negoff = np.nan da_posoff = da_posoff.where(da_posoff < float(cutoff), drop=True) if da_posoff.size > 0: posoff = float(da_posoff.coords[f'offset_d{dim}'][0]) + elif clip_to_bounds: + posoff = float(interp_da.coords[f'offset_d{dim}'][-1]) else: posoff = np.nan return negoff, posoff @@ -621,16 +678,16 @@ def model_tx_temperature_rise(voltage: float, T0 = T0_degC if T0 < 20 or T0 > 40: - logger.warning("Initial temperature T0 must be between 20 and 40 degrees Celsius for the model to be valid.") + logger.debug("Initial temperature T0 must be between 20 and 40 degrees Celsius for the electronics thermal model to be valid.") if P < 50 or P > 500: - logger.warning("Squared Voltage must be between 50 and 500 V^2 for the model to be valid.") + logger.debug("Squared Voltage must be between 50 and 500 V^2 for the electronics thermal model to be valid.") if t < 1 or t > 600: - logger.warning("Time t must be between 1 and 600 seconds for the model to be valid.") + logger.debug("Time t must be between 1 and 600 seconds for the electronics thermal model to be valid.") if frequency_kHz < 380 or frequency_kHz > 420: - logger.warning("Frequency must be between 380 and 420 kHz for the model to be valid.") + logger.debug("Frequency must be between 380 and 420 kHz for the electronics thermal model to be valid.") # Predict power law parameters using polynomial regression (degree 2) n = (2.131832 + -0.003475*P + -0.044916*T0 + 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 ``"