From 58cc8c1760f2abf5c97f8b280e633b825affb3db Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 26 Aug 2025 12:30:08 -0400 Subject: [PATCH 01/14] Updated Transducer Definition and Interface for #380 and #381 Reworking Transducer Def for #380 and #381 Minor fixes for inverting TX polarity. Add module invert to logger printout New TransducerArray made TransformedTransducer subclass of Transducer Update Elements format, database loading add backwards compatibilty, database write Update db_dvc.dvc bugfix in transducer, upload test script new tests Fixes for Pytest Update test_watertank.py Update test_watertank_simulation.py Update test_watertank.py remove `module_invert` from LIFUInterface the `module_invert` attribute of LIFUInterface had no function after instantiation, as it passed it's state through to `TxDevice`. `TxDevice` also passes `module_invert` through to the `TxRegisters`, but because we call `enum_tx_7332_devices()` at various points, which re-instantiates the `TxRegisters`, the `TxDevice` needs to (for now) hold that state. --- db_dvc.dvc | 5 +- notebooks/single_pulse.py | 246 +++++ notebooks/test_watertank.py | 36 +- notebooks/test_watertank_simulation.py | 404 +++++++++ src/openlifu/__init__.py | 2 + src/openlifu/db/database.py | 33 +- src/openlifu/io/LIFUInterface.py | 61 +- src/openlifu/io/LIFUTXDevice.py | 104 ++- src/openlifu/plan/protocol.py | 6 +- src/openlifu/plan/solution.py | 71 +- src/openlifu/xdc/__init__.py | 8 +- src/openlifu/xdc/element.py | 171 ++-- src/openlifu/xdc/transducer.py | 222 ++++- src/openlifu/xdc/transducerarray.py | 136 +++ .../example_solution/example_solution.json | 29 +- .../example_transducer.json | 849 +++++------------- tests/test_solution.py | 43 +- tests/test_sonication_control_mock.py | 2 +- 18 files changed, 1556 insertions(+), 872 deletions(-) create mode 100644 notebooks/single_pulse.py create mode 100644 notebooks/test_watertank_simulation.py create mode 100644 src/openlifu/xdc/transducerarray.py diff --git a/db_dvc.dvc b/db_dvc.dvc index d38682d1..45abb20f 100644 --- a/db_dvc.dvc +++ b/db_dvc.dvc @@ -1,6 +1,5 @@ outs: -- md5: 107d8598bb23a151f184ead9c8593d4a.dir - nfiles: 435 +- md5: 8c01cefb88391f95752dcc83deccc514.dir + nfiles: 438 hash: md5 path: db_dvc - size: 1500527483 diff --git a/notebooks/single_pulse.py b/notebooks/single_pulse.py new file mode 100644 index 00000000..11e0be89 --- /dev/null +++ b/notebooks/single_pulse.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import logging +import os +import sys +import time +from pathlib import Path + +if os.name == 'nt': + pass +else: + pass + +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. +""" + +# 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 + +# set focus +xInput = 0 +yInput = 0 +zInput = 50 + +frequency_kHz = 400 # Frequency in kHz +voltage = 20.0 # Voltage in Volts +duration_msec = 5/400 # Pulse Duration in milliseconds +interval_msec = 20 # Pulse Repetition Interval in milliseconds +num_modules = 2 # Number of modules in the system + +use_external_power_supply = False # Select whether to use console or power supply + +console_shutoff_temp_C = 70.0 # Console shutoff temperature in Celsius +tx_shutoff_temp_C = 70.0 # TX device shutoff temperature in Celsius +ambient_shutoff_temp_C = 70.0 # Ambient shutoff temperature in Celsius + +#TODO: script_timeout_minutes = 30 # Prevent unintentionally leaving unit on for too long +#TODO: log_temp_to_csv_file = True # Log readings to only terminal or both terminal and CSV file + +# Fail-safe parameters if the temperature jumps too fast +rapid_temp_shutoff_C = 40 # Cutoff temperature in Celsius if it jumps too fast +rapid_temp_shutoff_seconds = 5 # Time in seconds to reach rapid temperature shutoff +rapid_temp_increase_per_second_shutoff_C = 3 # Rapid temperature climbing shutoff in Celsius + +peak_to_peak_voltage = voltage * 2 # Peak to peak voltage for the pulse + +here = Path(__file__).parent.resolve() +db_path = here / ".." / "db_dvc" +db = Database(db_path) +arr = db.load_transducer(f"openlifu_{num_modules}x400_evt1_005") +arr.sort_by_pin() + +target = Point(position=(xInput,yInput,zInput), units="mm") +focus = target.get_position(units="mm") +distances = np.sqrt(np.sum((focus - arr.get_positions(units="mm"))**2, 1)).reshape(1,-1) +tof = distances*1e-3 / 1500 +delays = tof.max() - tof +print(f"TOF Max = {tof.max()*1e6} us") + +apodizations = np.ones((1, arr.numelements())) +#active_element = 25 +#active_element = np.arange(65,128) +#apodizations = np.zeros((1, arr.numelements())) +#apodizations[:, active_element-1] = 1 + +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) + +stop_logging = False # flag to signal the logging thread to stop + +# 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) + +if not use_external_power_supply: + try: + console_firmware_version = interface.hvcontroller.get_version() + logger.info(f"Console Firmware Version: {console_firmware_version}") + except Exception as e: + logger.error(f"Error querying console firmware version: {e}") +try: + tx_firmware_version = interface.txdevice.get_version() + logger.info(f"TX Firmware Version: {tx_firmware_version}") +except Exception as e: + logger.error(f"Error querying TX firmware version: {e}") + +logger.info("Enumerate TX7332 chips") +num_tx_devices = interface.txdevice.enum_tx7332_devices() +if num_tx_devices == 0: + raise ValueError("No TX7332 devices found.") +elif num_tx_devices == num_modules*2: + logger.info(f"Number of TX7332 devices found: {num_tx_devices}") + numelements = 32*num_tx_devices +else: + raise Exception(f"Number of TX7332 devices found: {num_tx_devices} != 2x{num_modules}") + +logger.info(f'Apodizations: {apodizations}') +logger.info(f'Delays: {delays}') + +pulse = Pulse(frequency=frequency_kHz*1e3, duration=duration_msec*1e-3) + +sequence = Sequence( + pulse_interval=interval_msec*1e-3, + pulse_count=int(60/(interval_msec*1e-3)), + pulse_train_interval=0, + pulse_train_count=1 +) + +pin_order = np.argsort([el.pin for el in arr.elements]) +solution = Solution( + delays = delays[:, pin_order], + apodizations = apodizations[:, pin_order], + transducer=arr, + pulse = pulse, + voltage=voltage, + sequence = sequence +) +profile_index = 1 +profile_increment = True +trigger_mode = "single" + +if use_external_power_supply: + logger.info(f"Using external power supply. Ensure HV is turned on and set to {voltage}V before starting the trigger.") + +interface.set_solution( + solution=solution, + profile_index=profile_index, + profile_increment=profile_increment, + trigger_mode=trigger_mode) + +logger.info("Get Trigger") +trigger_setting = interface.txdevice.get_trigger_json() +if trigger_setting: + logger.info(f"Trigger Setting: {trigger_setting}") +else: + logger.error("Failed to get trigger setting.") + sys.exit(1) + +duty_cycle = int((duration_msec/interval_msec) * 100) +if duty_cycle > 50: + logger.warning("❗❗ Duty cycle is above 50% ❗❗") + +logger.info(f"User parameters set: \n\ + Module Invert: {arr.module_invert}\n\ + Frequency: {frequency_kHz}kHz\n\ + Voltage Per Rail: {voltage}V\n\ + Voltage Peak to Peak: {peak_to_peak_voltage}V\n\ + Duration: {duration_msec}ms\n\ + Interval: {interval_msec}ms\n\ + Duty Cycle: {duty_cycle}%\n\ + Use External Power Supply: {use_external_power_supply}\n\ + Initial Temp Safety Shutoff: Increase to {rapid_temp_shutoff_C}°C within {rapid_temp_shutoff_seconds}s of starting.\n\ + General Temp Safety Shutoff: Increase of {rapid_temp_increase_per_second_shutoff_C}°C within {log_interval}s at any point.\n") + +def turn_off_console_and_tx(): + if not use_external_power_supply: + logger.info("Attempting to turn off High Voltage...") + interface.hvcontroller.turn_hv_off() + if interface.hvcontroller.get_hv_status(): + logger.error("High Voltage is still on.") + else: + logger.info("High Voltage successfully turned off.") + + logger.info("Attempting to turn off 12V...") + interface.hvcontroller.turn_12v_off() + if interface.hvcontroller.get_12v_status(): + logger.error("12V is still on.") + else: + logger.info("12V successfully turned off.") + +logger.info("turning HV ON") +interface.hvcontroller.turn_hv_on() +s = input("Press any key to start") +logger.info("Sending Single Trigger...") +interface.txdevice.start_trigger() +time.sleep(0.1) +interface.txdevice.stop_trigger() +interface.hvcontroller.turn_hv_off() +turn_off_console_and_tx() +logger.info("Finished") diff --git a/notebooks/test_watertank.py b/notebooks/test_watertank.py index 1c36efe2..4ef058e0 100644 --- a/notebooks/test_watertank.py +++ b/notebooks/test_watertank.py @@ -77,6 +77,7 @@ arr = db.load_transducer(f"openlifu_{num_modules}x400_evt1") arr.sort_by_pin() + target = Point(position=(xInput,yInput,zInput), units="mm") focus = target.get_position(units="mm") distances = np.sqrt(np.sum((focus - arr.get_positions(units="mm"))**2, 1)).reshape(1,-1) @@ -92,7 +93,7 @@ logger.info("Starting LIFU Test Script...") -interface = LIFUInterface() +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: @@ -108,7 +109,7 @@ time.sleep(1) # Short delay before recreating logger.info("Reinitializing LIFU interface after powering 12V...") - interface = LIFUInterface() + interface = LIFUInterface(ext_power_supply=use_external_power_supply) # Re-check connection tx_connected, hv_connected = interface.is_device_connected() @@ -291,6 +292,7 @@ def log_temperature(): solution = Solution( delays = delays[:, pin_order], apodizations = apodizations[:, pin_order], + transducer=arr, pulse = pulse, voltage=voltage, sequence = sequence @@ -300,32 +302,19 @@ def log_temperature(): trigger_mode = "continuous" if use_external_power_supply: - interface.check_solution(solution) - sol_dict = solution.to_dict() - interface.txdevice.set_solution( - pulse = sol_dict['pulse'], - delays = sol_dict['delays'], - apodizations= sol_dict['apodizations'], - sequence= sol_dict['sequence'], - trigger_mode=trigger_mode, - profile_index=profile_index, - profile_increment=profile_increment - ) logger.info(f"Using external power supply. Ensure HV is turned on and set to {voltage}V before starting the trigger.") -else: - interface.set_solution( - solution=solution, - profile_index=profile_index, - profile_increment=profile_increment, - trigger_mode=trigger_mode, - turn_hv_on=True) +interface.set_solution( + solution=solution, + profile_index=profile_index, + profile_increment=profile_increment, + trigger_mode=trigger_mode) logger.info("Get Trigger") trigger_setting = interface.txdevice.get_trigger_json() if trigger_setting: logger.info(f"Trigger Setting: {trigger_setting}") else: - logger.error("Failed to sgt trigger setting.") + logger.error("Failed to get trigger setting.") sys.exit(1) @@ -334,6 +323,7 @@ def log_temperature(): logger.warning("❗❗ Duty cycle is above 50% ❗❗") logger.info(f"User parameters set: \n\ + Module Invert: {arr.module_invert}\n\ Frequency: {frequency_kHz}kHz\n\ Voltage Per Rail: {voltage}V\n\ Voltage Peak to Peak: {peak_to_peak_voltage}V\n\ @@ -385,7 +375,7 @@ def input_wrapper(): user_input = threading.Thread(target=input_wrapper) logger.info("Starting Trigger...") -if interface.start_sonication(use_external_power_supply): +if interface.start_sonication(): logger.info("Trigger Running...") logger.info("Press enter to STOP trigger:") @@ -408,7 +398,7 @@ def input_wrapper(): user_input.join() time.sleep(0.5) # Give the logging thread time to finish - if interface.stop_sonication(use_external_power_supply): + if interface.stop_sonication(): logger.info("Trigger stopped successfully.") else: logger.error("Failed to stop trigger.") diff --git a/notebooks/test_watertank_simulation.py b/notebooks/test_watertank_simulation.py new file mode 100644 index 00000000..d761378a --- /dev/null +++ b/notebooks/test_watertank_simulation.py @@ -0,0 +1,404 @@ +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 + + +from openlifu.bf import apod_methods, focal_patterns +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 import Protocol +from openlifu.sim import SimSetup + +# 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. +""" + +# 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 + +# set focus +xInput = 0 +yInput = 0 +zInput = 50 + +frequency_kHz = 400 # Frequency in kHz +duration_msec = 0.1 # Pulse Duration in milliseconds +interval_msec = 20 # Pulse Repetition Interval in milliseconds +num_modules = 2 # Number of modules in the system + +use_external_power_supply = False # Select whether to use console or power supply + +console_shutoff_temp_C = 70.0 # Console shutoff temperature in Celsius +tx_shutoff_temp_C = 70.0 # TX device shutoff temperature in Celsius +ambient_shutoff_temp_C = 70.0 # Ambient shutoff temperature in Celsius + +#TODO: script_timeout_minutes = 30 # Prevent unintentionally leaving unit on for too long +#TODO: log_temp_to_csv_file = True # Log readings to only terminal or both terminal and CSV file + +# Fail-safe parameters if the temperature jumps too fast +rapid_temp_shutoff_C = 40 # Cutoff temperature in Celsius if it jumps too fast +rapid_temp_shutoff_seconds = 5 # Time in seconds to reach rapid temperature shutoff +rapid_temp_increase_per_second_shutoff_C = 3 # Rapid temperature climbing shutoff in Celsius + + +here = Path(__file__).parent.resolve() +db_path = here / ".." / "db_dvc" +db = Database(db_path) +arr = db.load_transducer(f"openlifu_{num_modules}x400_evt1_005") + +arr.sort_by_pin() + +target = Point(position=(xInput,yInput,zInput), units="mm") + +pulse = Pulse(frequency=frequency_kHz*1e3, duration=duration_msec*1e-3) +sequence = Sequence( + pulse_interval=interval_msec*1e-3, + pulse_count=int(60/(interval_msec*1e-3)), + pulse_train_interval=0, + pulse_train_count=1) +focal_pattern = focal_patterns.SinglePoint(target_pressure=300e3) +apod_method = apod_methods.Uniform() +sim_setup = SimSetup(x_extent=(-55,55), y_extent=(-30,30), z_extent=(-4,70)) +protocol = Protocol( + id='test_protocol', + name='Test Protocol', + pulse=pulse, + sequence=sequence, + focal_pattern=focal_pattern, + apod_method=apod_method, + sim_setup=sim_setup) + +solution, sim_res, scaled_analysis = protocol.calc_solution( + target=target, + transducer=arr, + simulate=True, + scale=True) +voltage = solution.voltage +peak_to_peak_voltage = solution.voltage * 2 # Peak to peak voltage for the pulse + + +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) + +stop_logging = False # flag to signal the logging thread to stop + +def log_temperature(): + # Create a file with the current timestamp in the name + start = time.time() + timestamp = time.strftime("%Y%m%d_%H%M%S") + logpath = here / ".." / "logs" + if not logpath.exists(): + logpath.mkdir(parents=True) + logfile = logpath / f"{timestamp}_{frequency_kHz}kHz_{voltage}V_{duration_msec}ms_Duration_{interval_msec}ms_Interval_Temperature_Readings.csv" + shutdown = False + + prev_tx_temp = None + prev_amb_temp = None + prev_con_temp = None + + if use_external_power_supply: + con_temp = "N/A" + + with open(logfile, "w") as logfile: + # Create header for CSV file + log_line = "Current Time,Frequency (kHz),Duration (ms),Interval (ms),Voltage (Per Rail),Voltage (Peak to Peak),Console Temperature (°C),Transmitter Temperature (°C),Ambient Temperature (°C)\n" + logfile.write(log_line) + logfile.flush() # Ensure the data is written immediately + while not (stop_logging or shutdown): + current_time = time.strftime("%Y-%m-%d %H:%M:%S") + + # Timer for initial rapid temperature increase + within_initial_time_threshold = (time.time() - start) < rapid_temp_shutoff_seconds + + ## Check for too high of a temperature increase between readings + # Console general temperature increase (bypass if using external power supply) + if not use_external_power_supply: + if prev_con_temp is None: + prev_con_temp = interface.hvcontroller.get_temperature1() + prev_con_temp = 0 + con_temp = interface.hvcontroller.get_temperature1() + con_temp = 0 + if (con_temp - prev_con_temp) > rapid_temp_increase_per_second_shutoff_C: + logger.warning(f"Console temperature rose from {prev_con_temp}°C to {con_temp}°C (above {rapid_temp_increase_per_second_shutoff_C}°C threshold) within {log_interval}s.") + log_line = f"{current_time},SHUTDOWN,Console temperature exceeded rapid temp increase shutoff threshold\n" + shutdown=True + else: + prev_con_temp = con_temp + + # TX device general temperature increase + if prev_tx_temp is None: + prev_tx_temp = interface.txdevice.get_temperature() + tx_temp = interface.txdevice.get_temperature() + if (tx_temp - prev_tx_temp) > rapid_temp_increase_per_second_shutoff_C: + logger.warning(f"TX device temperature rose from {prev_tx_temp}°C to {tx_temp}°C (above {rapid_temp_increase_per_second_shutoff_C}°C threshold) within {log_interval}s.") + log_line = f"{current_time},SHUTDOWN,TX device temperature exceeded rapid temp increase shutoff threshold\n" + shutdown=True + else: + prev_tx_temp = tx_temp + + # Ambient temperature general increase + if prev_amb_temp is None: + prev_amb_temp = interface.txdevice.get_ambient_temperature() + amb_temp = interface.txdevice.get_ambient_temperature() + if (amb_temp - prev_amb_temp) > rapid_temp_increase_per_second_shutoff_C: + logger.warning(f"Ambient temperature rose from {prev_amb_temp}°C to {amb_temp}°C (above {rapid_temp_increase_per_second_shutoff_C}°C threshold) within {log_interval}s.") + log_line = f"{current_time},SHUTDOWN,Ambient temperature exceeded rapid temp increase shutoff threshold\n" + shutdown=True + else: + prev_amb_temp = amb_temp + + # Check for initial rapid temperature increase + if (within_initial_time_threshold): + if not use_external_power_supply and (con_temp > rapid_temp_shutoff_C): + logger.warning(f"Console temperature {con_temp}°C exceeds rapid shutoff threshold of {rapid_temp_shutoff_C}°C within {rapid_temp_shutoff_seconds}s.") + log_line = f"{current_time},SHUTDOWN,Console temperature exceeded rapid shutoff threshold\n" + shutdown=True + elif (tx_temp > rapid_temp_shutoff_C): + logger.warning(f"TX device temperature {tx_temp}°C exceeds rapid shutoff threshold of {rapid_temp_shutoff_C}°C within {rapid_temp_shutoff_seconds}s.") + log_line = f"{current_time},SHUTDOWN,TX device temperature exceeded shutoff threshold\n" + shutdown=True + elif (amb_temp > rapid_temp_shutoff_C): + logger.warning(f"Ambient temperature {amb_temp}°C exceeds rapid shutoff threshold of {rapid_temp_shutoff_C}°C within {rapid_temp_shutoff_seconds}s.") + log_line = f"{current_time},SHUTDOWN,Ambient temperature exceeded shutoff threshold\n" + shutdown=True + + log_line = f"{current_time},{frequency_kHz},{duration_msec},{interval_msec},{voltage},{peak_to_peak_voltage},{con_temp},{tx_temp},{amb_temp}\n" + if not use_external_power_supply: + logger.info(f"Console Temp: {con_temp}°C TX Temp: {tx_temp}°C Ambient Temp: {amb_temp}°C") + else: + logger.info(f"Console Temp: {con_temp} TX Temp: {tx_temp}°C Ambient Temp: {amb_temp}°C") + logfile.write(log_line) + logfile.flush() # Ensure the data is written immediately + + # Check if any temperature exceeds the shutoff threshold + if not use_external_power_supply and (con_temp > console_shutoff_temp_C): + logger.warning(f"Console temperature {con_temp}°C exceeds shutoff threshold {console_shutoff_temp_C}°C.") + log_line = f"{current_time},SHUTDOWN,Console temperature exceeded shutoff threshold\n" + shutdown=True + if tx_temp > tx_shutoff_temp_C: + logger.warning(f"TX device temperature {tx_temp}°C exceeds shutoff threshold {tx_shutoff_temp_C}°C.") + log_line = f"{current_time},SHUTDOWN,TX device temperature exceeded shutoff threshold\n" + shutdown=True + if amb_temp > ambient_shutoff_temp_C: + logger.warning(f"Ambient temperature {amb_temp}°C exceeds shutoff threshold {ambient_shutoff_temp_C}°C.") + log_line = f"{current_time},SHUTDOWN,Ambient temperature exceeded shutoff threshold\n" + shutdown=True + + if shutdown: + interface.txdevice.stop_trigger() + logfile.write(log_line) + logfile.flush() # Ensure the data is written immediately + break + time.sleep(log_interval) + minutes, seconds = divmod(int(time.time() - start), 60) + logger.info(f"Temperature logging stopped after {minutes}:{seconds:02d}.") + logger.info(f"Data saved to \"{os.path.relpath(logfile.name)}\".") + sys.exit(0) + +# 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) + +if not use_external_power_supply: + try: + console_firmware_version = interface.hvcontroller.get_version() + logger.info(f"Console Firmware Version: {console_firmware_version}") + except Exception as e: + logger.error(f"Error querying console firmware version: {e}") +try: + tx_firmware_version = interface.txdevice.get_version() + logger.info(f"TX Firmware Version: {tx_firmware_version}") +except Exception as e: + logger.error(f"Error querying TX firmware version: {e}") + +logger.info("Enumerate TX7332 chips") +num_tx_devices = interface.txdevice.enum_tx7332_devices() +if num_tx_devices == 0: + raise ValueError("No TX7332 devices found.") +elif num_tx_devices == num_modules*2: + logger.info(f"Number of TX7332 devices found: {num_tx_devices}") + numelements = 32*num_tx_devices +else: + raise Exception(f"Number of TX7332 devices found: {num_tx_devices} != 2x{num_modules}") + +logger.info(f'Apodizations: {solution.apodizations}') +logger.info(f'Delays: {solution.delays}') + +profile_index = 1 +profile_increment = True +trigger_mode = "continuous" + +if use_external_power_supply: + logger.info(f"Using external power supply. Ensure HV is turned on and set to {solution.voltage}V before starting the trigger.") +interface.set_solution( + solution=solution, + profile_index=profile_index, + profile_increment=profile_increment, + trigger_mode=trigger_mode) + +logger.info("Get Trigger") +trigger_setting = interface.txdevice.get_trigger_json() +if trigger_setting: + logger.info(f"Trigger Setting: {trigger_setting}") +else: + logger.error("Failed to get trigger setting.") + sys.exit(1) + + +duty_cycle = int((duration_msec/interval_msec) * 100) +if duty_cycle > 50: + logger.warning("❗❗ Duty cycle is above 50% ❗❗") + +logger.info(f"User parameters set: \n\ + Module Invert: {arr.module_invert}\n\ + Frequency: {frequency_kHz}kHz\n\ + Voltage Per Rail: {solution.voltage}V\n\ + Voltage Peak to Peak: {peak_to_peak_voltage}V\n\ + Duration: {duration_msec}ms\n\ + Interval: {interval_msec}ms\n\ + Duty Cycle: {duty_cycle}%\n\ + Use External Power Supply: {use_external_power_supply}\n\ + Initial Temp Safety Shutoff: Increase to {rapid_temp_shutoff_C}°C within {rapid_temp_shutoff_seconds}s of starting.\n\ + General Temp Safety Shutoff: Increase of {rapid_temp_increase_per_second_shutoff_C}°C within {log_interval}s at any point.\n") + +logger.info("Press enter to START trigger:") +input() # Wait for the user to press Enter + +def turn_off_console_and_tx(): + if not use_external_power_supply: + logger.info("Attempting to turn off High Voltage...") + interface.hvcontroller.turn_hv_off() + if interface.hvcontroller.get_hv_status(): + logger.error("High Voltage is still on.") + else: + logger.info("High Voltage successfully turned off.") + + logger.info("Attempting to turn off 12V...") + interface.hvcontroller.turn_12v_off() + if interface.hvcontroller.get_12v_status(): + logger.error("12V is still on.") + else: + logger.info("12V successfully turned off.") + +shutdown_event = threading.Event() + +def input_wrapper(): + if os.name == 'nt': + while not shutdown_event.is_set(): + if msvcrt.kbhit(): # Check if a key has been pressed + char = msvcrt.getch() # Read the key + if char == b'\r': # Check if the Enter key was pressed + break + time.sleep(0.1) # Small delay to prevent high CPU usage + else: + while not shutdown_event.is_set(): + # Use select to check if input is available + ready, _, _ = select.select([], [], [], 0.1) # Timeout of 0.1 seconds + if ready: + sys.stdin.readline() # Read the input + break + +t = threading.Thread(target=log_temperature) +user_input = threading.Thread(target=input_wrapper) + +logger.info("Starting Trigger...") +if interface.start_sonication(): + logger.info("Trigger Running...") + logger.info("Press enter to STOP trigger:") + + # Start logging and user input threads + t.start() + user_input.start() + + while (user_input.is_alive() and t.is_alive()): + time.sleep(0.1) + + if (not user_input.is_alive()): + logger.info("Logging interrupted by user.") + else: + logger.info("Logging interrupted by shutdown event.") + shutdown_event.set() # Signal the logging thread to stop + stop_logging = True + + # Wait for threads to finish + t.join() + user_input.join() + + time.sleep(0.5) # Give the logging thread time to finish + if interface.stop_sonication(): + logger.info("Trigger stopped successfully.") + else: + logger.error("Failed to stop trigger.") +else: + logger.error("Failed to get trigger setting.") + +turn_off_console_and_tx() diff --git a/src/openlifu/__init__.py b/src/openlifu/__init__.py index 542fffe6..f936d65b 100644 --- a/src/openlifu/__init__.py +++ b/src/openlifu/__init__.py @@ -42,6 +42,8 @@ __all__ = [ "Point", "Transducer", + "TransformedTransducer", + "TransducerArray", "Protocol", "Solution", "Material", diff --git a/src/openlifu/db/database.py b/src/openlifu/db/database.py index accf8d4d..532236da 100644 --- a/src/openlifu/db/database.py +++ b/src/openlifu/db/database.py @@ -14,7 +14,7 @@ from openlifu.nav.photoscan import Photoscan, load_data_from_photoscan from openlifu.plan import Protocol, Run, Solution from openlifu.util.json import PYFUSEncoder -from openlifu.xdc import Transducer +from openlifu.xdc import Transducer, TransducerArray from .session import Session from .subject import Subject @@ -851,7 +851,7 @@ def load_system(self, sys_id=None): sys = UltrasoundSystem.from_file(sys_filename) return sys - def load_transducer(self, transducer_id) -> Transducer: + def load_transducer(self, transducer_id, convert_array:bool = True) -> Transducer: """Given a transducer_id, reads the corresponding transducer file from database and returns a transducer object. Note: the transducer object includes the relative path to the affiliated transducer model data. `get_transducer_absolute_filepaths`, should be used to obtain the absolute data filepaths based on the Database directory path. @@ -861,7 +861,16 @@ def load_transducer(self, transducer_id) -> Transducer: Corresponding Transducer object """ transducer_filename = self.get_transducer_filename(transducer_id) - transducer = Transducer.from_file(transducer_filename) + with open(transducer_filename) as f: + if not f: + raise FileNotFoundError(f"Transducer file not found for ID: {transducer_id}") + d = json.load(f) + if "type" in d and d["type"] == "TransducerArray": + transducer = TransducerArray.from_dict(d) + if convert_array: + transducer = transducer.to_transducer() + else: + transducer = Transducer.from_file(transducer_filename) return transducer def load_transducer_standoff(self, trans, coords, options=None): @@ -966,6 +975,24 @@ def load_solution(self, session:Session, solution_id:str) -> Solution: self.logger.info(f"Loaded solution {solution_id}") return solution + def load_volume(self, subject, volume_id): + """Load the volume with the given ID for the specified subject.""" + volume_metadata_filepath = self.get_volume_metadata_filepath(subject.id, volume_id) + if not volume_metadata_filepath.exists() or not volume_metadata_filepath.is_file(): + self.logger.error(f"Volume metadata file not found for volume {volume_id}, subject {subject.id}") + raise FileNotFoundError(f"Volume metadata file not found for volume {volume_id}, subject {subject.id}") + with open(volume_metadata_filepath) as f: + volume_metadata = json.load(f) + volume_data_filepath = Path(volume_metadata_filepath).parent / volume_metadata["data_filename"] + if not volume_data_filepath.exists() or not volume_data_filepath.is_file(): + self.logger.error(f"Volume data file not found for volume {volume_id}, subject {subject.id}") + raise FileNotFoundError(f"Volume data file not found for volume {volume_id}, subject {subject.id}") + import nibabel as nib + # Load the volume data using nibabel + volume_data = nib.load(volume_data_filepath) + self.logger.info(f"Loaded volume {volume_id} for subject {subject.id}") + return volume_data + def set_connected_transducer(self, trans, options=None): trans_id = trans.id transducer_ids = self.get_transducer_ids() diff --git a/src/openlifu/io/LIFUInterface.py b/src/openlifu/io/LIFUInterface.py index 798a5357..2d08ea5f 100644 --- a/src/openlifu/io/LIFUInterface.py +++ b/src/openlifu/io/LIFUInterface.py @@ -3,7 +3,7 @@ import asyncio import logging from enum import Enum -from typing import Dict +from typing import Dict, List import numpy as np import pandas as pd @@ -47,7 +47,17 @@ class LIFUInterface: hvcontroller: HVController = None txdevice: TxDevice = None - def __init__(self, vid: int = 0x0483, tx_pid: int = 0x57AF, con_pid: int = 0x57A0, baudrate: int = 921600, timeout: int = 10, TX_test_mode: bool = False, HV_test_mode: bool = False, run_async: bool = False) -> None: + def __init__(self, + vid: int = 0x0483, + tx_pid: int = 0x57AF, + con_pid: int = 0x57A0, + baudrate: int = 921600, + timeout: int = 10, + TX_test_mode: bool = False, + HV_test_mode: bool = False, + run_async: bool = False, + ext_power_supply: bool = False, + module_invert: bool | List[bool] = False) -> None: """ Initialize the LIFUInterface with given parameters and store them in the class. @@ -75,12 +85,17 @@ def __init__(self, vid: int = 0x0483, tx_pid: int = 0x57AF, con_pid: int = 0x57A # Create a TXDevice instance as part of the interface logger.debug("Initializing TX Module of LIFUInterface with VID: %s, PID: %s, baudrate: %s, timeout: %s", vid, tx_pid, baudrate, timeout) self._tx_uart = LIFUUart(vid=vid, pid=tx_pid, baudrate=baudrate, timeout=timeout, desc="TX", demo_mode=TX_test_mode, async_mode=run_async) - self.txdevice = TxDevice(uart=self._tx_uart) + self.txdevice = TxDevice(uart=self._tx_uart, module_invert=module_invert) + + if ext_power_supply: + logger.debug("External power supply selected, skipping HVController initialization.") + self.hvcontroller = None + else: + # Create a LIFUHVController instance as part of the interface + logger.debug("Initializing Console of LIFUInterface with VID: %s, PID: %s, baudrate: %s, timeout: %s", vid, con_pid, baudrate, timeout) + self._hv_uart = LIFUUart(vid=vid, pid=con_pid, baudrate=baudrate, timeout=timeout, desc="HV", demo_mode=HV_test_mode, async_mode=run_async) + self.hvcontroller = HVController(uart=self._hv_uart) - # Create a LIFUHVController instance as part of the interface - logger.debug("Initializing Console of LIFUInterface with VID: %s, PID: %s, baudrate: %s, timeout: %s", vid, con_pid, baudrate, timeout) - self._hv_uart = LIFUUart(vid=vid, pid=con_pid, baudrate=baudrate, timeout=timeout, desc="HV", demo_mode=HV_test_mode, async_mode=run_async) - self.hvcontroller = HVController(uart=self._hv_uart) # Connect signals to internal handlers if self._async_mode: @@ -120,10 +135,12 @@ def is_device_connected(self) -> tuple: tuple: (tx_connected, hv_connected) """ tx_connected = self.txdevice.is_connected() - hv_connected = self.hvcontroller.is_connected() + if self.hvcontroller is None: + hv_connected = False + else: + hv_connected = self.hvcontroller.is_connected() return tx_connected, hv_connected - def get_max_voltage(self, solution: Solution | Dict) -> float: """ Get the maximum voltage for a given solution. @@ -234,12 +251,15 @@ def get_sequence_duration(self, solution: Solution | Dict) -> float: else: return solution['sequence']['pulse_train_interval'] * solution['sequence']['pulse_train_count'] + def set_module_invert(self, module_invert: bool | List[bool]) -> None: + if self.txdevice is not None: + self.txdevice.set_module_invert(module_invert) + def set_solution(self, solution: Solution | Dict, profile_index:int=1, profile_increment:bool=True, trigger_mode: TriggerModeOpts = "sequence", - turn_hv_on: bool = True ) -> None: """ Load a solution to the device. @@ -249,12 +269,18 @@ def set_solution(self, profile_index (int): The profile index to load the solution to (defaults to 0) profile_increment (bool): Increment the profile index trigger_mode (TriggerModeOpts): The trigger mode to use (defaults to "sequence") + module_invert (List[bool]|bool): Invert the signal on all modules (singleton) or specific modules (list) (defaults to False) """ if isinstance(solution, Solution): solution = solution.to_dict() self.check_solution(solution) + if "transducer" in solution and solution["transducer"] is not None and "module_invert" in solution["transducer"]: + self.txdevice.set_module_invert(solution["transducer"]["module_invert"]) + else: + self.txdevice.set_module_invert(False) + self.set_status(LIFUInterfaceStatus.STATUS_PROGRAMMING) if "name" in solution: @@ -274,15 +300,16 @@ def set_solution(self, profile_index=profile_index, profile_increment=profile_increment, trigger_mode=trigger_mode - ) + ) self.set_status(LIFUInterfaceStatus.STATUS_READY) - logger.info(f"Setting HV to {voltage} V...") - self.hvcontroller.set_voltage(voltage) + if self.hvcontroller is not None: + logger.info(f"Setting HV to {voltage} V...") + self.hvcontroller.set_voltage(voltage) logger.info("%s loaded successfully.", solution_name) - def start_sonication(self, use_external_power_supply:bool=False) -> bool: + def start_sonication(self) -> bool: """ Start sonication. @@ -292,7 +319,7 @@ def start_sonication(self, use_external_power_supply:bool=False) -> bool: if self._test_mode: return True - if not use_external_power_supply: + if self.hvcontroller is not None: logger.info("Turn ON HV") bHvOn = self.hvcontroller.turn_hv_on() else: @@ -344,7 +371,7 @@ def get_status(self) -> LIFUInterfaceStatus: return self.status - def stop_sonication(self, use_external_power_supply:bool=False) -> bool: + def stop_sonication(self) -> bool: """ Stop sonication. @@ -358,7 +385,7 @@ def stop_sonication(self, use_external_power_supply:bool=False) -> bool: # Send the solution data to the device bTriggerOff = self.txdevice.stop_trigger() - if not use_external_power_supply: + if self.hvcontroller is not None: logger.info("Turn OFF HV") bHvOff = self.hvcontroller.turn_hv_off() else: diff --git a/src/openlifu/io/LIFUTXDevice.py b/src/openlifu/io/LIFUTXDevice.py index 9cd8d99f..062a2b38 100644 --- a/src/openlifu/io/LIFUTXDevice.py +++ b/src/openlifu/io/LIFUTXDevice.py @@ -14,6 +14,7 @@ from openlifu.util.units import getunitconversion DEFAULT_NUM_TRANSMITTERS = 2 +TRANSMITTERS_PER_MODULE = 2 ADDRESS_GLOBAL_MODE = 0x0 ADDRESS_STANDBY = 0x1 ADDRESS_DYNPWR_2 = 0x6 @@ -149,7 +150,7 @@ logger.addHandler(handler) class TxDevice: - def __init__(self, uart: LIFUUart): + def __init__(self, uart: LIFUUart, module_invert: bool | list[bool] = False): """ Initialize the TxDevice. @@ -159,6 +160,7 @@ def __init__(self, uart: LIFUUart): self._tx_instances = [] self.tx_registers = None self.uart = uart + self.module_invert = module_invert if self.uart and not self.uart.asyncMode: self.uart.check_usb_status() if self.uart.is_connected(): @@ -833,7 +835,8 @@ def async_mode(self, enable: bool | None = None) -> bool: logger.error("ValueError: %s", v) raise - def enum_tx7332_devices(self, num_devices: int | None = None) -> int: + def enum_tx7332_devices(self, + num_devices: int | None = None) -> int: """ Enumerate TX7332 devices connected to the TX device. @@ -866,7 +869,7 @@ def enum_tx7332_devices(self, num_devices: int | None = None) -> int: logger.info("Error enumerating TX devices.") if num_devices is not None and num_detected_devices != num_devices: raise ValueError(f"Expected {num_devices} devices, but detected {num_detected_devices} devices") - self.tx_registers = TxDeviceRegisters(num_transmitters=num_detected_devices) + self.tx_registers = TxDeviceRegisters(num_transmitters=num_detected_devices, module_invert=self.module_invert) logger.info("TX Device Count: %d", num_detected_devices) return num_detected_devices except ValueError as v: @@ -877,6 +880,17 @@ def enum_tx7332_devices(self, num_devices: int | None = None) -> int: logger.error("Unexpected error during process: %s", e) raise # Re-raise the exception for the caller to handle + def set_module_invert(self, module_invert: bool | List[bool]) -> None: + """ + Set the module invert configuration for the TX device. + + Args: + module_invert (bool | List[bool]): The module invert configuration to set. + """ + self.module_invert = module_invert + if self.tx_registers is not None: + self.tx_registers.module_invert = module_invert + def demo_tx7332(self, identifier:int) -> bool: """ Sets all TX7332 chip registers with a test waveform. @@ -1264,13 +1278,13 @@ def write_block_verify(self, start_address: int, reg_values: List[int]) -> bool: raise # Re-raise the exception for the caller to handle def set_solution(self, - pulse: Dict, - delays: np.ndarray, - apodizations: np.ndarray, - sequence: Dict, - trigger_mode: TriggerModeOpts = "sequence", - profile_index: int = 1, - profile_increment: bool = True): + pulse: Dict, + delays: np.ndarray, + apodizations: np.ndarray, + sequence: Dict, + trigger_mode: TriggerModeOpts = "sequence", + profile_index: int = 1, + profile_increment: bool = True): """ Set the solution parameters on the TX device. @@ -1292,7 +1306,12 @@ def set_solution(self, n = delays.shape[0] n_elements = delays.shape[1] n_required_devices = int(n_elements / NUM_CHANNELS) - self.enum_tx7332_devices(num_devices=n_required_devices) + n_detected_tx = self.enum_tx7332_devices(num_devices=n_required_devices) + n_modules = n_detected_tx / TRANSMITTERS_PER_MODULE + if n_required_devices != n_detected_tx: + errmsg = f"Number of detected TX devices ({n_detected_tx}) does not match required ({n_required_devices})" + logger.exception(errmsg) + raise OSError(errmsg) if n != apodizations.shape[0]: raise ValueError("Delays and apodizations must have the same number of rows") @@ -1347,7 +1366,7 @@ def apply_all_registers(self): for txi, txregs in enumerate(registers): for addr, reg_values in txregs.items(): if not self.write_block(identifier=txi, start_address=addr, reg_values=reg_values): - logger.error(f"Error applying TX CHIP ID: {i} registers") + logger.error(f"Error applying TX CHIP ID: {txi} registers") return False return True @@ -1759,27 +1778,27 @@ def get_delay_control_registers(self, profile: int | None=None) -> Dict[int,int] return {ADDRESS_DELAY_SEL: delay_sel_register, ADDRESS_APODIZATION: apod_register} - def get_pulse_control_registers(self, profile: int | None=None) -> Dict[int,int]: + def get_pulse_control_registers(self, profile: int | None=None, pulse_invert: bool = False) -> Dict[int,int]: if profile is None: profile = self.active_pulse_profile - profile_index = self.get_pulse_profile(profile) - if profile_index.profile not in VALID_PATTERN_PROFILES: - raise ValueError(f"Invalid profile {profile_index.profile}.") - pattern = calc_pulse_pattern(profile_index.frequency, profile_index.duty_cycle, bf_clk=self.bf_clk) + pulse_profile = self.get_pulse_profile(profile) + if pulse_profile.profile not in VALID_PATTERN_PROFILES: + raise ValueError(f"Invalid profile {pulse_profile.profile}.") + pattern = calc_pulse_pattern(pulse_profile.frequency, pulse_profile.duty_cycle, bf_clk=self.bf_clk) clk_div_n = pattern['clk_div_n'] clk_div = 2**clk_div_n clk_n = self.bf_clk / clk_div - cycles = int(profile_index.cycles) + cycles = int(pulse_profile.cycles) if cycles > (MAX_REPEAT+1): # Use elastic repeat - pulse_duration_samples = self.bf_clk * ((cycles / profile_index.frequency) + ELASTIC_MODE_PULSE_LENGTH_ADJUST) + pulse_duration_samples = self.bf_clk * ((cycles / pulse_profile.frequency) + ELASTIC_MODE_PULSE_LENGTH_ADJUST) repeat = 0 elastic_repeat = int(pulse_duration_samples / 16) - period_samples = int(clk_n / profile_index.frequency) + period_samples = int(clk_n / pulse_profile.frequency) cycles = 16*elastic_repeat / period_samples y = pattern['y']*int(cycles+1) y = y[:(16*elastic_repeat)] - y = y + ([0]*profile_index.tail_count) + y = y + ([0]*pulse_profile.tail_count) t = np.arange(len(y))*(1/clk_n) elastic_mode = 1 if elastic_repeat > MAX_ELASTIC_REPEAT: @@ -1789,17 +1808,17 @@ def get_pulse_control_registers(self, profile: int | None=None) -> Dict[int,int] elastic_repeat = 0 elastic_mode = 0 y = pattern['y']*(repeat+1) - y = np.array(y + [0]*profile_index.tail_count) - reg_mode = 0x02000003 + y = np.array(y + [0]*pulse_profile.tail_count) + reg_mode = 0x02000043 reg_mode = set_register_value(reg_mode, clk_div_n, lsb=3, width=3) - reg_mode = set_register_value(reg_mode, int(profile_index.invert), lsb=6, width=1) + reg_mode = set_register_value(reg_mode, int(pulse_profile.invert^pulse_invert), lsb=6, width=1) reg_repeat = 0 reg_repeat = set_register_value(reg_repeat, repeat, lsb=1, width=5) - reg_repeat = set_register_value(reg_repeat, profile_index.tail_count, lsb=6, width=5) + reg_repeat = set_register_value(reg_repeat, pulse_profile.tail_count, lsb=6, width=5) reg_repeat = set_register_value(reg_repeat, elastic_mode, lsb=11, width=1) reg_repeat = set_register_value(reg_repeat, elastic_repeat, lsb=12, width=16) reg_pat_sel = 0 - reg_pat_sel = set_register_value(reg_pat_sel, profile_index.profile-1, lsb=0, width=6) + reg_pat_sel = set_register_value(reg_pat_sel, pulse_profile.profile-1, lsb=0, width=6) registers = {ADDRESS_PATTERN_MODE: reg_mode, ADDRESS_PATTERN_REPEAT: reg_repeat, ADDRESS_PATTERN_SEL_G1: reg_pat_sel, @@ -1847,7 +1866,7 @@ def get_pulse_data_registers(self, profile: int | None=None, pack: bool=False, p data_registers = pack_registers(data_registers, pack_single=pack_single) return data_registers - def get_registers(self, profiles: ProfileOpts = "configured", pack: bool=False, pack_single: bool=False) -> Dict[int,int]: + def get_registers(self, profiles: ProfileOpts = "configured", pack: bool=False, pack_single: bool=False, pulse_invert: bool=False) -> Dict[int,int]: if len(self._delay_profiles_list) == 0: raise ValueError("No delay profiles have been configured") if len(self._pulse_profiles_list) == 0: @@ -1858,7 +1877,7 @@ def get_registers(self, profiles: ProfileOpts = "configured", pack: bool=False, raise ValueError("No pulse profile activated") registers = {addr:0x0 for addr in ADDRESSES_GLOBAL} registers.update(self.get_delay_control_registers()) - registers.update(self.get_pulse_control_registers()) + registers.update(self.get_pulse_control_registers(pulse_invert=pulse_invert)) if profiles == "active": delay_data = self.get_delay_data_registers() pulse_data = self.get_pulse_data_registers() @@ -1899,10 +1918,13 @@ class TxDeviceRegisters: num_transmitters: Annotated[int, OpenLIFUFieldData("Number of transmitters", "The number of transmitters available on the device")] = DEFAULT_NUM_TRANSMITTERS """The number of transmitters available on the device""" + module_invert: Annotated[List[bool] | bool, OpenLIFUFieldData("Module Invert", "List of flags indicating whether to invert the pulse amplitude for each module or a single flag for all modules")] = False + """List of flags indicating whether to invert the pulse amplitude for each module or a single flag for all modules""" + def __post_init__(self): self.transmitters = tuple([Tx7332Registers(bf_clk=self.bf_clk) for _ in range(self.num_transmitters)]) - def add_pulse_profile(self, profile_index: Tx7332PulseProfile, activate: bool | None=None): + def add_pulse_profile(self, pulse_profile: Tx7332PulseProfile, activate: bool | None=None): """ Add a pulse profile @@ -1910,17 +1932,17 @@ def add_pulse_profile(self, profile_index: Tx7332PulseProfile, activate: bool | :param activate: Activate the pulse profile """ profiles = self.configured_pulse_profiles() - if profile_index.profile in profiles: - i = profiles.index(profile_index.profile) - self._profiles_list[i] = profile_index + if pulse_profile.profile in profiles: + i = profiles.index(pulse_profile.profile) + self._profiles_list[i] = pulse_profile else: - self._profiles_list.append(profile_index) + self._profiles_list.append(pulse_profile) if activate is None: activate = self.active_profile is None if activate: - self.active_profile = profile_index.profile + self.active_profile = pulse_profile.profile for tx in self.transmitters: - tx.add_pulse_profile(profile_index, activate = activate) + tx.add_pulse_profile(pulse_profile, activate = activate) def add_delay_profile(self, delay_profile: Tx7332DelayProfile, activate: bool | None=None): """ @@ -2082,7 +2104,11 @@ def get_registers(self, profiles: ProfileOpts = "configured", recompute: bool = if recompute: self.recompute_delay_profiles() self.recompute_pulse_profiles() - return [tx.get_registers(profiles, pack=pack, pack_single=pack_single) for tx in self.transmitters] + if isinstance(self.module_invert, bool): + tx_invert = [self.module_invert] * self.num_transmitters + else: + tx_invert = list(np.array(self.module_invert*TRANSMITTERS_PER_MODULE, dtype=bool).reshape(TRANSMITTERS_PER_MODULE, -1).T.reshape(-1)) + return [tx.get_registers(profiles, pack=pack, pack_single=pack_single, pulse_invert=inv) for tx, inv in zip(self.transmitters, tx_invert)] def get_delay_control_registers(self, profile:int | None=None) -> List[Dict[int,int]]: """ @@ -2104,7 +2130,11 @@ def get_pulse_control_registers(self, profile:int | None=None) -> List[Dict[int, """ if profile is None: profile = self.active_profile - return [tx.get_pulse_control_registers(profile) for tx in self.transmitters] + if isinstance(self.module_invert, bool): + tx_invert = [self.module_invert] * self.num_transmitters + else: + tx_invert = list(np.array(self.module_invert*TRANSMITTERS_PER_MODULE, dtype=bool).T.reshape(-1)) + return [tx.get_pulse_control_registers(profile, pulse_invert=inv) for tx, inv in zip(self.transmitters, tx_invert)] def get_delay_data_registers(self, profile:int | None=None, pack: bool=False, pack_single: bool=False) -> List[Dict[int,int]]: """ diff --git a/src/openlifu/plan/protocol.py b/src/openlifu/plan/protocol.py index 28f6efba..ec8e1a35 100644 --- a/src/openlifu/plan/protocol.py +++ b/src/openlifu/plan/protocol.py @@ -354,7 +354,7 @@ def calc_solution( id=solution_id, name=f"Solution {timestamp}", protocol_id=self.id, - transducer_id=transducer.id, + transducer=transducer, delays=np.stack(delays_to_stack, axis=0), apodizations=np.stack(apodizations_to_stack, axis=0), pulse=self.pulse, @@ -377,7 +377,7 @@ def calc_solution( raise ValueError(f"Cannot scale solution {solution.id} if simulation is not enabled!") self.logger.info(f"Scaling solution {solution.id}...") #TODO can analysis be an attribute of solution ? - solution.scale(transducer, self.focal_pattern, analysis_options=analysis_options) + solution.scale(self.focal_pattern, analysis_options=analysis_options) if simulate: # Finally the resulting pressure is max-aggregated and intensity is mean-aggregated, over all focus points . @@ -390,7 +390,7 @@ def calc_solution( simulation_result_aggregated['p_min'] = pnp_aggregated simulation_result_aggregated['p_max'] = ppp_aggregated simulation_result_aggregated['intensity'] = intensity_aggregated - solution_analysis = solution.analyze(transducer=transducer, options=analysis_options, param_constraints=self.param_constraints) + solution_analysis = solution.analyze(options=analysis_options, param_constraints=self.param_constraints) else: simulation_result_aggregated = None solution_analysis = None diff --git a/src/openlifu/plan/solution.py b/src/openlifu/plan/solution.py index 8d548da5..27b573d4 100644 --- a/src/openlifu/plan/solution.py +++ b/src/openlifu/plan/solution.py @@ -50,8 +50,8 @@ class Solution: protocol_id: Annotated[str | None, OpenLIFUFieldData("Protocol ID", "ID of the protocol that was used when generating this solution")] = None # this used to be called plan_id in the matlab code """ID of the protocol that was used when generating this solution""" - transducer_id: Annotated[str | None, OpenLIFUFieldData("Transducer ID", "ID of the transducer that was used when generating this solution")] = None - """ID of the transducer that was used when generating this solution""" + transducer: Annotated[Transducer | None, OpenLIFUFieldData("Transducer", "Transducer used when generating this solution")] = None + """Transducer used when generating this solution""" date_created: Annotated[datetime, OpenLIFUFieldData("Creation date", "Solution creation time")] = field(default_factory=datetime.now) """Solution creation time""" @@ -133,13 +133,11 @@ def num_foci(self) -> int: return len(self.foci) def analyze(self, - transducer: Transducer, options: SolutionAnalysisOptions = SolutionAnalysisOptions(), param_constraints: Dict[str,ParameterConstraint] | None = None) -> SolutionAnalysis: """Analyzes the treatment solution. Args: - transducer: A Transducer item. options: A struct for solution analysis options. param_constraints: A dictionary of parameter constraints to apply to the analysis. The keys are the parameter names and the values are the ParameterConstraint objects. @@ -150,10 +148,6 @@ def analyze(self, param_constraints = {} solution_analysis = SolutionAnalysis() - if transducer.id != self.transducer_id: - # self.logger.error(f"Provided transducer id {transducer.id} does not match Solution transducer id: {self.transducer_id}") - raise ValueError(f"Provided transducer id {transducer.id} does not match Solution transducer id: {self.transducer_id}") - dt = 1 / (self.pulse.frequency * 20) t = self.pulse.calc_time(dt) input_signal_V = self.pulse.calc_pulse(t) * self.voltage @@ -167,9 +161,9 @@ def analyze(self, standoff_Z = options.standoff_density * 1500 c_tic = 40e-3 # W cm-1 - A_cm = transducer.get_area("cm") + 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 transducer.elements]) + 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; @@ -193,13 +187,13 @@ def analyze(self, solution_analysis.target_position_ele_mm += [focus_mm[1]] solution_analysis.target_position_ax_mm += [focus_mm[2]] # TODO: this should be a list, not a single value apodization = self.apodizations[focus_index] - origin = transducer.get_effective_origin(apodizations=apodization, units=options.distance_units) + origin = self.transducer.get_effective_origin(apodizations=apodization, units=options.distance_units) output_signal_Pa = [] - output_signal_Pa = np.zeros((transducer.numelements(), len(input_signal_V))) - for i in range(transducer.numelements()): + output_signal_Pa = np.zeros((self.transducer.numelements(), len(input_signal_V))) + for i in range(self.transducer.numelements()): apod_signal_V = input_signal_V * self.apodizations[focus_index, i] - output_signal_Pa[i] = transducer.elements[i].calc_output(apod_signal_V, dt) + output_signal_Pa[i] = self.transducer.elements[i].calc_output(apod_signal_V, dt) p0_Pa = np.max(output_signal_Pa, axis=1) @@ -323,7 +317,6 @@ def compute_scaling_factors( def scale( self, - transducer: Transducer, focal_pattern: FocalPattern, analysis_options: SolutionAnalysisOptions = SolutionAnalysisOptions() ) -> None: @@ -331,14 +324,13 @@ def scale( Scale the solution in-place to match the target pressure. Args: - transducer: xdc.Transducer focal_pattern: FocalPattern analysis_options: plan.solution.SolutionAnalysisOptions Returns: analysis_scaled: the resulting plan.solution.SolutionAnalysis from scaled solution """ - analysis = self.analyze(transducer, options=analysis_options) + analysis = self.analyze(options=analysis_options) apod_factors, v0, v1 = self.compute_scaling_factors(focal_pattern, analysis) @@ -450,23 +442,23 @@ def to_json(self, include_simulation_data: bool, compact: bool) -> str: return json.dumps(solution_dict, indent=4, cls=PYFUSEncoder) @staticmethod - def from_json(json_string : str, simulation_result: xa.Dataset | None=None) -> Solution: - """Load a Solution from a json string. + def from_dict(solution_dict: dict) -> Solution: + """Load a Solution from a dictionary. Args: - json_string: the json string defining the Solution - simulation_result: the simulation result arrays to use. If the json string has this then it will - be read from the json string and it should not be provided in this argument. + solution_dict: The dictionary containing the solution data. Returns: The new Solution object. """ - solution_dict = json.loads(json_string) + # Convert the dictionary back into a Solution object solution_dict["date_created"] = datetime.fromisoformat(solution_dict["date_created"]) if solution_dict["delays"] is not None: solution_dict["delays"] = np.array(solution_dict["delays"]) if solution_dict["apodizations"] is not None: solution_dict["apodizations"] = np.array(solution_dict["apodizations"], ndmin=2) solution_dict["apodizations"] = np.array(solution_dict["apodizations"], ndmin=2) + if solution_dict["transducer"] is not None: + solution_dict["transducer"] = Transducer.from_dict(solution_dict["transducer"]) solution_dict["pulse"] = Pulse.from_dict(solution_dict["pulse"]) solution_dict["sequence"] = Sequence.from_dict(solution_dict["sequence"]) solution_dict["foci"] = [ @@ -476,6 +468,29 @@ def from_json(json_string : str, simulation_result: xa.Dataset | None=None) -> S if solution_dict["target"] is not None: solution_dict["target"] = Point.from_dict(solution_dict["target"]) + if "simulation_result" in solution_dict and isinstance(solution_dict["simulation_result"], str): + solution_dict["simulation_result"] = xa.open_dataset( + base64.b64decode( + solution_dict["simulation_result"].encode('utf-8') + ), + engine='h5netcdf', + ) + + return Solution(**solution_dict) + + + @staticmethod + def from_json(json_string : str, simulation_result: xa.Dataset | None=None) -> Solution: + """Load a Solution from a json string. + + Args: + json_string: the json string defining the Solution + simulation_result: the simulation result arrays to use. If the json string has this then it will + be read from the json string and it should not be provided in this argument. + + Returns: The new Solution object. + """ + solution_dict = json.loads(json_string) if simulation_result is not None: if "simulation_result" in solution_dict: raise ValueError( @@ -483,16 +498,8 @@ def from_json(json_string : str, simulation_result: xa.Dataset | None=None) -> S "Unclear which to use!" ) solution_dict["simulation_result"] = simulation_result - elif "simulation_result" in solution_dict: - # Deserialize xarray dataset from string - solution_dict["simulation_result"] = xa.open_dataset( - base64.b64decode( - solution_dict["simulation_result"].encode('utf-8') - ), - engine='scipy', - ) - return Solution(**solution_dict) + return Solution.from_dict(solution_dict) def to_files(self, json_filepath:Path, nc_filepath:Path | None=None) -> None: """Save the solution to json and netCDF files. diff --git a/src/openlifu/xdc/__init__.py b/src/openlifu/xdc/__init__.py index 2f3ea2dc..1e941740 100644 --- a/src/openlifu/xdc/__init__.py +++ b/src/openlifu/xdc/__init__.py @@ -1,12 +1,16 @@ from __future__ import annotations -from . import element, transducer from .element import Element -from .transducer import Transducer +from .transducer import Transducer, TransformedTransducer +from .transducerarray import TransducerArray, get_angle_from_gap, get_roc_from_angle __all__ = [ "element", "transducer", "Element", "Transducer", + "TransformedTransducer", + "TransducerArray", + "get_angle_from_gap", + "get_roc_from_angle" ] diff --git a/src/openlifu/xdc/element.py b/src/openlifu/xdc/element.py index 88734a2d..44e7332d 100644 --- a/src/openlifu/xdc/element.py +++ b/src/openlifu/xdc/element.py @@ -1,7 +1,6 @@ from __future__ import annotations import copy -from collections.abc import Iterable from dataclasses import dataclass, field from typing import Annotated @@ -35,34 +34,19 @@ class Element: index: Annotated[int, OpenLIFUFieldData("Element index", "Element index")] = 0 """Element index to identify the element in the array.""" - x: Annotated[float, OpenLIFUFieldData("X position", "X position of the element")] = 0 - """X position of the element.""" + position: Annotated[np.ndarray, OpenLIFUFieldData("Position", "Position of the element in 3D space")] = field(default_factory=lambda: np.array([0., 0., 0.])) + """ Position of the element in 3D space as a numpy array [x, y, z].""" - y: Annotated[float, OpenLIFUFieldData("Y position", "Y position of the element")] = 0 - """Y position of the element.""" + orientation: Annotated[np.ndarray, OpenLIFUFieldData("Orientation", "Orientation of the element in 3D space")] = field(repr=False, default_factory=lambda: np.array([0., 0., 0.])) + """ Orientation of the element in 3D space as a numpy array around the [y, x', z''] axes [az, el, roll] in radians.""" - z: Annotated[float, OpenLIFUFieldData("Z position", "Z position of the element")] = 0 - """Z position of the element.""" + size: Annotated[np.ndarray, OpenLIFUFieldData("Size", "Size of the element in 2D")] = field(default_factory=lambda: np.array([1., 1.])) + """ Size of the element in 2D as a numpy array [width, length].""" - az: Annotated[float, OpenLIFUFieldData("Azimuth angle (rad)", "Azimuth angle of the element")] = 0 - """Azimuth angle of the element, or rotation about the y-axis (rad)""" - - el: Annotated[float, OpenLIFUFieldData("Elevation angle (rad)", "Elevation angle of the element")] = 0 - """Elevation angle of the element, or rotation about the x'-axis (rad)""" - - roll: Annotated[float, OpenLIFUFieldData("Roll angle (rad)", "Roll angle of the element")] = 0 - """Roll angle of the element, or rotation about the z''-axis (rad)""" - - w: Annotated[float, OpenLIFUFieldData("Width", "Width of the element in the x dimension")] = 1 - """Width of the element in the x dimension""" - - l: Annotated[float, OpenLIFUFieldData("Length", "Length of the element in the y dimension")] = 1 - """Length of the element in the y dimension""" - - impulse_response: Annotated[np.ndarray, OpenLIFUFieldData("Impulse response", "Impulse response of the element")] = field(repr=False, default_factory=lambda: np.array([1])) + impulse_response: Annotated[np.ndarray | None, OpenLIFUFieldData("Impulse response", "Impulse response of the element")] = None """Impulse response of the element, can be a single value or an array of values. If an array, `impulse_dt` must be set to the time step of the impulse response. Is convolved with the input signal.""" - impulse_dt: Annotated[float, OpenLIFUFieldData("Impulse response timestep", """Impulse response timestep""")] = field(repr=False, default=1) + impulse_dt: Annotated[float | None, OpenLIFUFieldData("Impulse response timestep", """Impulse response timestep""")] = None """Impulse response timestep. If `impulse_response` is an array, this is the time step of the impulse response.""" pin: Annotated[int, OpenLIFUFieldData("Pin", "Channel pin to which the element is connected")] = -1 @@ -72,13 +56,92 @@ class Element: """Spatial units of the element specification.""" def __post_init__(self): - if isinstance(self.impulse_response, Iterable): + self.position = np.array(self.position, dtype=np.float64) + if self.position.shape != (3,): + raise ValueError("Position must be a 3-element array.") + self.orientation = np.array(self.orientation, dtype=np.float64) + if self.orientation.shape != (3,): + raise ValueError("Orientation must be a 3-element array.") + self.size = np.array(self.size, dtype=np.float64) + if self.size.shape != (2,): + raise ValueError("Size must be a 2-element array.") + if self.impulse_response is not None: + if isinstance(self.impulse_response, (int, float)): + self.impulse_response = np.array([self.impulse_response]) self.impulse_response = np.array(self.impulse_response, dtype=np.float64) - else: - self.impulse_response = np.array([self.impulse_response], dtype=np.float64) + if self.impulse_response.ndim != 1: + raise ValueError("Impulse response must be a 1-dimensional array.") + if len(self.impulse_response)>1 and self.impulse_dt is None: + raise ValueError("Impulse response timestep must be set if impulse response is an array.") + + @property + def x(self): + return self.position[0] + + @x.setter + def x(self, value): + self.position[0] = value + + @property + def y(self): + return self.position[1] + + @y.setter + def y(self, value): + self.position[1] = value + + @property + def z(self): + return self.position[2] + + @z.setter + def z(self, value): + self.position[2] = value + + @property + def az(self): + return self.orientation[0] + + @az.setter + def az(self, value): + self.orientation[0] = value + + @property + def el(self): + return self.orientation[1] + + @el.setter + def el(self, value): + self.orientation[1] = value + + @property + def roll(self): + return self.orientation[2] + + @roll.setter + def roll(self, value): + self.orientation[2] = value + + @property + def width(self): + return self.size[0] + + @width.setter + def width(self, value): + self.size[0] = value + + @property + def length(self): + return self.size[1] + + @length.setter + def length(self, value): + self.size[1] = value def calc_output(self, input_signal, dt): - if len(self.impulse_response) == 1: + if self.impulse_response is None: + return input_signal + elif len(self.impulse_response) == 1: return input_signal * self.impulse_response else: impulse = self.interp_impulse_response(dt) @@ -90,17 +153,14 @@ def copy(self): def rescale(self, units): if self.units != units: scl = getunitconversion(self.units, units) - self.x *= scl - self.y *= scl - self.z *= scl - self.w *= scl - self.l *= scl + self.position *= scl + self.size *= scl self.units = units def get_position(self, units=None, matrix=np.eye(4)): units = self.units if units is None else units scl = getunitconversion(self.units, units) - pos = np.array([self.x, self.y, self.z]) * scl + pos = self.position * scl pos = np.append(pos, 1) pos = np.dot(matrix, pos) return pos[:3] @@ -108,8 +168,8 @@ def get_position(self, units=None, matrix=np.eye(4)): def get_size(self, units=None): units = self.units if units is None else units scl = getunitconversion(self.units, units) - ele_width = self.w * scl - ele_length = self.l * scl + ele_width = self.size[0] * scl + ele_length = self.size[1] * scl return ele_width, ele_length def get_area(self, units=None): @@ -120,8 +180,8 @@ def get_area(self, units=None): def get_corners(self, units=None, matrix=np.eye(4)): units = self.units if units is None else units scl = getunitconversion(self.units, units) - rect = np.array([np.array([-1, -1., 1, 1]) * 0.5 * self.w, - np.array([-1, 1, 1, -1]) * 0.5 * self.l, + rect = np.array([np.array([-1, -1., 1, 1]) * 0.5 * self.width, + np.array([-1, 1, 1, -1]) * 0.5 * self.length, np.zeros(4) , np.ones(4)]) xyz = np.dot(self.get_matrix(), rect) @@ -129,7 +189,7 @@ def get_corners(self, units=None, matrix=np.eye(4)): corner = [] for j in range(3): corner.append(xyz1[j, :] * scl) - return np.array(corner ) + return np.array(corner) def get_matrix(self, units=None): units = self.units if units is None else units @@ -200,28 +260,27 @@ def set_matrix(self, matrix, units=None): if units is not None: self.rescale(units) x, y, z, az, el, roll = matrix2xyz(matrix) - self.x = x - self.y = y - self.z = z - self.az = az - self.el = el - self.roll = roll + self.position = np.array([x, y, z]) + self.orientation = np.array([az, el, roll]) def to_dict(self): - return {"index": self.index, - "x": self.x, - "y": self.y, - "z": self.z, - "az": self.az, - "el": self.el, - "roll": self.roll, - "w": self.w, - "l": self.l, - "impulse_response": self.impulse_response.tolist(), - "impulse_dt": self.impulse_dt, + d = {"index": self.index, + "position": self.position.tolist(), + "orientation": self.orientation.tolist(), + "size": self.size.tolist(), "pin": self.pin, "units": self.units} + if self.impulse_response is not None: + d["impulse_response"] = self.impulse_response.tolist() + if self.impulse_dt is not None: + d["impulse_dt"] = self.impulse_dt + return d @staticmethod def from_dict(d): + if 'x' in d: + d = copy.deepcopy(d) + d["position"] = np.array([d.pop('x'), d.pop('y'), d.pop('z')]) + d["orientation"] = np.array([d.pop('az'), d.pop('el'), d.pop('roll')]) + d["size"] = np.array([d.pop('w'), d.pop('l')]) return Element(**d) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index 14e9c5a2..a60ebf14 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -4,16 +4,17 @@ import json import logging from dataclasses import dataclass, field -from typing import Annotated, Any, Dict, List +from typing import Annotated, Any, Dict, List, Literal import numpy as np import vtk from openlifu.util.annotations import OpenLIFUFieldData from openlifu.util.units import getunitconversion +from openlifu.xdc.element import Element -from .element import Element - +DIMS = ['x', 'y', 'z'] +LDIMS = Literal['x','y','z'] @dataclass class Transducer: @@ -54,19 +55,58 @@ class Transducer: The units of this transform are assumed to be the native units of the transducer, the `Transducer.units` field. """ + impulse_response: Annotated[np.ndarray, OpenLIFUFieldData("Impulse response", "Impulse response of the elements")] = field(repr=False, default_factory=lambda: np.array([1])) + """Impulse response of the element, can be a single value or an array of values. If an array, `impulse_dt` must be set to the time step of the impulse response. Is convolved with the input voltage signal with a time base in seconds to get pressure in Pa. If an impulse response is specified per element, those will also be applied.""" + + impulse_dt: Annotated[float, OpenLIFUFieldData("Impulse response timestep", """Impulse response timestep""")] = field(repr=False, default=1) + """Impulse response timestep. If `impulse_response` is an array, this is the time step of the impulse response.""" + + module_invert: Annotated[List[bool], OpenLIFUFieldData("Invert polarity", "Whether to invert the polarity of the transducer output, per module")] = field(default_factory=lambda: [False]) + """Whether to invert the polarity of the transducer output""" + def __post_init__(self): logging.info("Initializing transducer array") if self.name == "": self.name = self.id for element in self.elements: element.rescale(self.units) + if self.impulse_response is not None: + if isinstance(self.impulse_response, (int, float)): + self.impulse_response = np.array([self.impulse_response]) + self.impulse_response = np.array(self.impulse_response, dtype=np.float64) + if self.impulse_response.ndim != 1: + raise ValueError("Impulse response must be a 1-dimensional array.") + if len(self.impulse_response)>1 and self.impulse_dt is None: + raise ValueError("Impulse response timestep must be set if impulse response is an array.") + + + + def interp_impulse_response(self, dt=None): + if dt is None: + dt = self.impulse_dt + n0 = len(self.impulse_response) + if n0 == 1: + impulse_response= self.impulse_response + else: + t0 = self.impulse_dt * np.arange(n0) + t1 = np.arange(0, t0[-1] + dt, dt) + impulse_response = np.interp(t1, t0, self.impulse_response) + impulse_t = np.arange(len(impulse_response)) * dt + impulse_t = impulse_t - np.mean(impulse_t) + return impulse_response, impulse_t def calc_output(self, input_signal, dt, delays: np.ndarray = None, apod: np.ndarray = None): if delays is None: delays = np.zeros(self.numelements()) if apod is None: apod = np.ones(self.numelements()) - outputs = [np.concatenate([np.zeros(int(delay/dt)), a*element.calc_output(input_signal, dt)],axis=0) for element, delay, a, in zip(self.elements, delays, apod)] + + if len(self.impulse_response) == 1: + filtered_input_signal = input_signal * self.impulse_response + else: + impulse = self.interp_impulse_response(dt) + filtered_input_signal = np.convolve(input_signal, impulse, mode='full') + outputs = [np.concatenate([np.zeros(int(delay/dt)), a*element.calc_output(filtered_input_signal, dt)],axis=0) for element, delay, a, in zip(self.elements, delays, apod)] max_len = max([len(o) for o in outputs]) output_signal = np.zeros([self.numelements(), max_len]) for i, o in enumerate(outputs): @@ -189,11 +229,20 @@ def get_standoff_transform_in_units(self, units:str) -> np.ndarray: return matrix @staticmethod - def merge(list_of_transducers:List[Transducer]) -> Transducer: + def merge(list_of_transducers:List[Transducer], offset_pins:bool=False, offset_indices:bool=False, merged_attrs:dict={}) -> Transducer: merged_array = list_of_transducers[0].copy() for arr in list_of_transducers[1:]: xform_array = arr.copy() + if offset_pins: + for el in xform_array.elements: + el.pin = el.pin + merged_array.numelements() + if offset_indices: + for el in xform_array.elements: + el.index = el.index + merged_array.numelements() merged_array.elements += xform_array.elements + merged_array.module_invert += xform_array.module_invert + for k, v in merged_attrs.items(): + merged_array.__setattr__(k, v) return merged_array def numelements(self): @@ -218,6 +267,7 @@ def sort_by_pin(self): def to_dict(self): d = self.__dict__.copy() d["elements"] = [element.to_dict() for element in d["elements"]] + d["impulse_response"] = d["impulse_response"].tolist() d["standoff_transform"] = d["standoff_transform"].tolist() return d @@ -231,6 +281,37 @@ def transform(self, matrix, units=None): for el in self.elements: el.set_matrix(np.dot(np.linalg.inv(matrix), el.get_matrix())) + def translate(self, dim: LDIMS, amount: float, units=None): + if units is not None: + self.rescale(units) + matrix = np.eye(4) + dim_index = list(DIMS).index(dim) + matrix[dim_index, 3] = amount + self.transform(matrix, units=units) + + def rotate(self, dim: LDIMS, angle: float, units: Literal["deg", "rad"]="deg"): + if units == "deg": + angle_rad = np.deg2rad(angle) + else: + angle_rad = angle + matrix = np.eye(4) + if dim == 'x': + matrix[1,1] = np.cos(angle_rad) + matrix[1,2] = -np.sin(angle_rad) + matrix[2,1] = np.sin(angle_rad) + matrix[2,2] = np.cos(angle_rad) + elif dim == 'y': + matrix[0,0] = np.cos(angle_rad) + matrix[0,2] = np.sin(angle_rad) + matrix[2,0] = -np.sin(angle_rad) + matrix[2,2] = np.cos(angle_rad) + elif dim == 'z': + matrix[0,0] = np.cos(angle_rad) + matrix[0,1] = -np.sin(angle_rad) + matrix[1,0] = np.sin(angle_rad) + matrix[1,1] = np.cos(angle_rad) + self.transform(matrix, units=units) + @staticmethod def from_file(filename): with open(filename) as file: @@ -241,6 +322,8 @@ def from_file(filename): def from_dict(d, **kwargs): d = d.copy() d["elements"] = [Element.from_dict(element) for element in d["elements"]] + if "impulse_response" in d: + d["impulse_response"] = np.array(d["impulse_response"]) if "standoff_transform" in d: d["standoff_transform"] = np.array(d["standoff_transform"]) return Transducer(**d, **kwargs) @@ -250,7 +333,7 @@ def from_json(json_string : str) -> Transducer: """Load a Transducer from a json string""" return Transducer.from_dict(json.loads(json_string)) - def to_json(self, compact:bool) -> str: + def to_json(self, compact:bool=False) -> str: """Serialize a Transducer to a json string Args: @@ -264,16 +347,7 @@ def to_json(self, compact:bool) -> str: return json.dumps(self.to_dict(), indent=4) @staticmethod - def gen_matrix_array(nx:int=2, - ny:int=2, - pitch:float=1.0, - kerf:float=0.0, - units:str="mm", - impulse_response:float|np.ndarray=1.0, - impulse_dt:float=1.0, - id:str='array', - name:str='Array', - attrs:Dict|None=None): + def gen_matrix_array(nx=2, ny=2, pitch=1, kerf=0, units="mm", impulse_response=[1], impulse_dt=1, id='array', name='Array', attrs={}): """Generate a 2D flat matrix array Args: @@ -292,24 +366,108 @@ def gen_matrix_array(nx:int=2, """ N = nx * ny xpos = (np.arange(nx) - (nx - 1) / 2) * pitch # x positions, centered about x=0 - ypos = (np.arange(ny) - (ny - 1) / 2) * pitch # y positions, centered about y=0 + ypos = -(np.arange(ny) - (ny - 1) / 2) * pitch # y positions, centered about y=0 elements = [] for i in range(N): - x = xpos[i % nx] # inner loop through x positions - y = ypos[i // nx] # outer loop through y positions + x = xpos[i // ny] # inner loop through x positions + y = ypos[i % ny] # outer loop through y positions elements.append(Element( - x=x, - y=y, - z=0, - az=0, - el=0, - roll=0, - w=pitch - kerf, - l=pitch - kerf, - impulse_response=impulse_response, - impulse_dt=impulse_dt, + index=i+1, + pin=i+1, + position = np.array([x, y, 0]), + orientation = np.array([0, 0, 0]), + size = np.array([pitch - kerf, pitch - kerf]), units=units )) - if attrs is None: - attrs = {} - return Transducer(elements=elements, id=id, name=name, attrs=attrs) + arr = Transducer(elements=elements, id=id, name=name, attrs=attrs, impulse_response=impulse_response, impulse_dt=impulse_dt, units=units) + return arr + +@dataclass +class TransformedTransducer(Transducer): + transform: np.ndarray = field(default_factory= lambda: np.eye(4)) + + def bake(self): + tdict = self.to_dict() + tdict.pop("transform") + t = Transducer.from_dict(tdict) + t.transform(self.transform, units=self.units) + return t + + def translate_global(self, dim: LDIMS, amount, units=None): + if units is None: + units = self.units + matrix = np.eye(4) + dim_index = DIMS.index(dim) + matrix[dim_index, 3] = amount + self.transform = self.transform @ np.linalg.inv(matrix) + + def translate_local(self, dim: LDIMS, amount, units=None): + if units is None: + units = self.units + matrix = np.eye(4) + dim_index = DIMS.index(dim) + matrix[dim_index, 3] = amount + self.transform = np.linalg.inv(matrix) @ self.transform + + def rotate_global(self, dim: LDIMS, angle: float, units: Literal["deg", "rad"]="deg"): + if units == "deg": + angle_rad = np.deg2rad(angle) + else: + angle_rad = angle + matrix = np.eye(4) + if dim == 'x': + matrix[1,1] = np.cos(angle_rad) + matrix[1,2] = -np.sin(angle_rad) + matrix[2,1] = np.sin(angle_rad) + matrix[2,2] = np.cos(angle_rad) + elif dim == 'y': + matrix[0,0] = np.cos(angle_rad) + matrix[0,2] = np.sin(angle_rad) + matrix[2,0] = -np.sin(angle_rad) + matrix[2,2] = np.cos(angle_rad) + elif dim == 'z': + matrix[0,0] = np.cos(angle_rad) + matrix[0,1] = -np.sin(angle_rad) + matrix[1,0] = np.sin(angle_rad) + matrix[1,1] = np.cos(angle_rad) + self.transform = self.transform @ matrix + + def rotate_local(self, dim: LDIMS, angle: float, units: Literal["deg", "rad"]="deg"): + if units == "deg": + angle_rad = np.deg2rad(angle) + else: + angle_rad = angle + matrix = np.eye(4) + if dim == 'x': + matrix[1,1] = np.cos(angle_rad) + matrix[1,2] = -np.sin(angle_rad) + matrix[2,1] = np.sin(angle_rad) + matrix[2,2] = np.cos(angle_rad) + elif dim == 'y': + matrix[0,0] = np.cos(angle_rad) + matrix[0,2] = np.sin(angle_rad) + matrix[2,0] = -np.sin(angle_rad) + matrix[2,2] = np.cos(angle_rad) + elif dim == 'z': + matrix[0,0] = np.cos(angle_rad) + matrix[0,1] = -np.sin(angle_rad) + matrix[1,0] = np.sin(angle_rad) + matrix[1,1] = np.cos(angle_rad) + self.transform = matrix @ self.transform + + def to_dict(self): + tdict = super().to_dict() + tdict["transform"] = self.transform.tolist() + return tdict + + @staticmethod + def from_dict(data): + d = data.copy() + transform = np.array(d.pop("transform")) + t = Transducer.from_dict(d) + return TransformedTransducer.from_transducer(t, transform) + + @staticmethod + def from_transducer(t: Transducer, transform: np.ndarray) -> TransformedTransducer: + tdict = t.__dict__ + return TransformedTransducer(**tdict, transform=np.array(transform)) diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py new file mode 100644 index 00000000..e08dc420 --- /dev/null +++ b/src/openlifu/xdc/transducerarray.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +import numpy as np + +from openlifu.util.dict_conversion import DictMixin +from openlifu.util.units import getunitconversion +from openlifu.xdc import Transducer, TransformedTransducer + + +def get_angle_from_gap(width, gap, roc): + a = roc + b = width/2 + c = gap/2 + mag = np.sqrt(a**2 + b**2) + A = a/mag + B = b/mag + dth = np.arcsin(c/mag) + np.arcsin(B) + return dth if A >= 0 else -dth + +def get_roc_from_angle(width, gap, dth): + return (0.5*gap + (0.5 * width * np.cos(dth))) / np.sin(dth) + +@dataclass +class TransducerArray(DictMixin): + id: str = "transducer_array" + name: str = "Transducer Array" + modules: list[TransformedTransducer] = field(default_factory=list) + attrs: dict = field(default_factory=dict) + + def to_transducer(self, offset_pins=True, offset_indices=True): + t = Transducer.merge([t.bake() for t in self.modules], offset_pins=offset_pins, offset_indices=offset_indices, merged_attrs=self.attrs) + t.name = self.name + t.id = self.id + return t + + @staticmethod + def from_dict(data: dict): + d = data.copy() + if "type" in d: + d.pop("type") + d["modules"] = [TransformedTransducer.from_dict(t) for t in data["modules"]] + return TransducerArray(**d) + + + def to_dict(self): + d = {"type": "TransducerArray"} + d.update(self.__dict__) + d["modules"] = [t.to_dict() for t in self.modules] + for k, v in self.attrs.items(): + if isinstance(v, np.ndarray): + d["attrs"][k] = v.tolist() + return d + + def to_json(self, compact:bool=False) -> str: + """Serialize a TransducerArray to a json string + + Args: + compact: if enabled then the string is compact (not pretty). Disable for pretty. + + Returns: A json string representing the complete TransducerArray object. + """ + if compact: + return json.dumps(self.to_dict(), separators=(',', ':')) + else: + return json.dumps(self.to_dict(), indent=4) + + def to_file(self, file_path: str, compact: bool = False) -> None: + """Serialize a TransducerArray to a json file + + Args: + file_path: The path to the file where the json string will be written. + compact: if enabled then the string is compact (not pretty). Disable for pretty. + """ + json_string = self.to_json(compact=compact) + with open(file_path, 'w') as f: + f.write(json_string) + + @staticmethod + def get_concave_cylinder(trans, rows=1, cols=1, width=40, gap=0, dth=None, roc=np.inf, units="mm", id="transducer_array", name="Transducer Array", attrs: dict={}): + scl = getunitconversion(units, trans.units) + modules = [] + if roc == np.inf: + for i in range(rows): + y = (width+gap)*(i-(rows-1)/2)*scl + for j in range(cols): + dx = (width+gap)*(j-(cols-1)/2)*scl + M = np.array([[1,0,0,dx], [0,1,0,y], [0,0,1,0], [0,0,0,1]]) + trans_new = TransformedTransducer.from_transducer(trans, transform=np.linalg.inv(M)) + modules.append(trans_new) + else: + if dth is None: + dth = get_angle_from_gap(width, gap, roc) + elif roc is None and dth is not None and gap is not None: + roc = get_roc_from_angle(width, gap, dth) + for i in range(rows): + y = (width+gap)*(i-(rows-1)/2)*scl + for j in range(cols): + th = dth*2*(j-(cols-1)/2) + x = roc*np.sin(th)*scl + z = roc*(1-np.cos(th))*scl + M = np.array([[np.cos(th),0,-np.sin(th),x], + [0,1,0,y], + [np.sin(th),0,np.cos(th),z], + [0,0,0,1]]) + trans_new = TransformedTransducer.from_transducer(trans, transform=np.linalg.inv(M)) + modules.append(trans_new) + return TransducerArray(modules=modules, id=id, name=name, attrs=attrs) + + @staticmethod + def from_file(filename: str) -> TransducerArray: + with open(filename) as f: + data = json.load(f) + return TransducerArray.from_dict(data) + + @property + def registration_surface_filename(self): + if "registration_surface_filename" in self.attrs: + return self.attrs["registration_surface_filename"] + return None + + @registration_surface_filename.setter + def registration_surface_filename(self, value): + self.attrs["registration_surface_filename"] = value + + @property + def transducer_body_filename(self): + if "transducer_body_filename" in self.attrs: + return self.attrs["transducer_body_filename"] + return None + + @transducer_body_filename.setter + def transducer_body_filename(self, value): + self.attrs["transducer_body_filename"] = value diff --git a/tests/resources/example_db/subjects/example_subject/sessions/example_session/solutions/example_solution/example_solution.json b/tests/resources/example_db/subjects/example_subject/sessions/example_session/solutions/example_solution/example_solution.json index 85c79ef8..789b59d8 100644 --- a/tests/resources/example_db/subjects/example_subject/sessions/example_session/solutions/example_solution/example_solution.json +++ b/tests/resources/example_db/subjects/example_subject/sessions/example_session/solutions/example_solution/example_solution.json @@ -2,7 +2,34 @@ "id": "example_solution", "name": "Example Solution", "protocol_id": "example_protocol", - "transducer_id": "example_transducer", + "transducer": { + "id": "example_transducer", + "name": "Example Transducer", + "elements": [ + { + "index": 1, + "position": [-14, -14, 0], + "units": "m" + }, + { + "index": 2, + "position": [-2, -2, 0], + "units": "m" + }, + { + "index": 3, + "position": [2, 2, 0], + "units": "m" + }, + { + "index": 4, + "position": [14, 14, 0], + "units": "m" + } + ], + "frequency": 1e6, + "units": "m" + }, "date_created": "2024-01-30T09:18:11", "description": "Example plan created 30-Jan-2024 09:16:02", "delays": [ diff --git a/tests/resources/example_db/transducers/example_transducer/example_transducer.json b/tests/resources/example_db/transducers/example_transducer/example_transducer.json index 420e8985..36be5969 100644 --- a/tests/resources/example_db/transducers/example_transducer/example_transducer.json +++ b/tests/resources/example_db/transducers/example_transducer/example_transducer.json @@ -4,972 +4,529 @@ "elements": [ { "index": 1, - "x": -14, - "y": -14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-14.0, 14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 1, "units": "mm" }, { "index": 2, - "x": -14, - "y": -10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-14.0, 10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 2, "units": "mm" }, { "index": 3, - "x": -14, - "y": -6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-14.0, 6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 3, "units": "mm" }, { "index": 4, - "x": -14, - "y": -2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-14.0, 2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 4, "units": "mm" }, { "index": 5, - "x": -14, - "y": 2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-14.0, -2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 5, "units": "mm" }, { "index": 6, - "x": -14, - "y": 6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-14.0, -6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 6, "units": "mm" }, { "index": 7, - "x": -14, - "y": 10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-14.0, -10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 7, "units": "mm" }, { "index": 8, - "x": -14, - "y": 14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-14.0, -14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 8, "units": "mm" }, { "index": 9, - "x": -10, - "y": -14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-10.0, 14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 9, "units": "mm" }, { "index": 10, - "x": -10, - "y": -10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-10.0, 10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 10, "units": "mm" }, { "index": 11, - "x": -10, - "y": -6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-10.0, 6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 11, "units": "mm" }, { "index": 12, - "x": -10, - "y": -2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-10.0, 2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 12, "units": "mm" }, { "index": 13, - "x": -10, - "y": 2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-10.0, -2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 13, "units": "mm" }, { "index": 14, - "x": -10, - "y": 6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-10.0, -6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 14, "units": "mm" }, { "index": 15, - "x": -10, - "y": 10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-10.0, -10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 15, "units": "mm" }, { "index": 16, - "x": -10, - "y": 14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-10.0, -14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 16, "units": "mm" }, { "index": 17, - "x": -6, - "y": -14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-6.0, 14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 17, "units": "mm" }, { "index": 18, - "x": -6, - "y": -10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-6.0, 10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 18, "units": "mm" }, { "index": 19, - "x": -6, - "y": -6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-6.0, 6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 19, "units": "mm" }, { "index": 20, - "x": -6, - "y": -2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-6.0, 2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 20, "units": "mm" }, { "index": 21, - "x": -6, - "y": 2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-6.0, -2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 21, "units": "mm" }, { "index": 22, - "x": -6, - "y": 6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-6.0, -6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 22, "units": "mm" }, { "index": 23, - "x": -6, - "y": 10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-6.0, -10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 23, "units": "mm" }, { "index": 24, - "x": -6, - "y": 14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-6.0, -14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 24, "units": "mm" }, { "index": 25, - "x": -2, - "y": -14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-2.0, 14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 25, "units": "mm" }, { "index": 26, - "x": -2, - "y": -10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-2.0, 10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 26, "units": "mm" }, { "index": 27, - "x": -2, - "y": -6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-2.0, 6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 27, "units": "mm" }, { "index": 28, - "x": -2, - "y": -2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-2.0, 2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 28, "units": "mm" }, { "index": 29, - "x": -2, - "y": 2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-2.0, -2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 29, "units": "mm" }, { "index": 30, - "x": -2, - "y": 6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-2.0, -6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 30, "units": "mm" }, { "index": 31, - "x": -2, - "y": 10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-2.0, -10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 31, "units": "mm" }, { "index": 32, - "x": -2, - "y": 14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [-2.0, -14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 32, "units": "mm" }, { "index": 33, - "x": 2, - "y": -14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [2.0, 14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 33, "units": "mm" }, { "index": 34, - "x": 2, - "y": -10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [2.0, 10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 34, "units": "mm" }, { "index": 35, - "x": 2, - "y": -6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [2.0, 6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 35, "units": "mm" }, { "index": 36, - "x": 2, - "y": -2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [2.0, 2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 36, "units": "mm" }, { "index": 37, - "x": 2, - "y": 2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [2.0, -2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 37, "units": "mm" }, { "index": 38, - "x": 2, - "y": 6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [2.0, -6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 38, "units": "mm" }, { "index": 39, - "x": 2, - "y": 10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [2.0, -10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 39, "units": "mm" }, { "index": 40, - "x": 2, - "y": 14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [2.0, -14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 40, "units": "mm" }, { "index": 41, - "x": 6, - "y": -14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [6.0, 14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 41, "units": "mm" }, { "index": 42, - "x": 6, - "y": -10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [6.0, 10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 42, "units": "mm" }, { "index": 43, - "x": 6, - "y": -6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [6.0, 6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 43, "units": "mm" }, { "index": 44, - "x": 6, - "y": -2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [6.0, 2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 44, "units": "mm" }, { "index": 45, - "x": 6, - "y": 2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [6.0, -2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 45, "units": "mm" }, { "index": 46, - "x": 6, - "y": 6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [6.0, -6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 46, "units": "mm" }, { "index": 47, - "x": 6, - "y": 10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [6.0, -10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 47, "units": "mm" }, { "index": 48, - "x": 6, - "y": 14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [6.0, -14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 48, "units": "mm" }, { "index": 49, - "x": 10, - "y": -14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [10.0, 14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 49, "units": "mm" }, { "index": 50, - "x": 10, - "y": -10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [10.0, 10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 50, "units": "mm" }, { "index": 51, - "x": 10, - "y": -6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [10.0, 6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 51, "units": "mm" }, { "index": 52, - "x": 10, - "y": -2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [10.0, 2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 52, "units": "mm" }, { "index": 53, - "x": 10, - "y": 2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [10.0, -2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 53, "units": "mm" }, { "index": 54, - "x": 10, - "y": 6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [10.0, -6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 54, "units": "mm" }, { "index": 55, - "x": 10, - "y": 10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [10.0, -10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 55, "units": "mm" }, { "index": 56, - "x": 10, - "y": 14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [10.0, -14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 56, "units": "mm" }, { "index": 57, - "x": 14, - "y": -14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [14.0, 14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 57, "units": "mm" }, { "index": 58, - "x": 14, - "y": -10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [14.0, 10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 58, "units": "mm" }, { "index": 59, - "x": 14, - "y": -6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [14.0, 6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 59, "units": "mm" }, { "index": 60, - "x": 14, - "y": -2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [14.0, 2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 60, "units": "mm" }, { "index": 61, - "x": 14, - "y": 2, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [14.0, -2.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 61, "units": "mm" }, { "index": 62, - "x": 14, - "y": 6, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [14.0, -6.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 62, "units": "mm" }, { "index": 63, - "x": 14, - "y": 10, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [14.0, -10.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 63, "units": "mm" }, { "index": 64, - "x": 14, - "y": 14, - "z": 0, - "az": 0, - "el": 0, - "roll": 0, - "w": 3.5, - "l": 3.5, - "impulse_response": 100000, - "impulse_dt": 1, + "position": [14.0, -14.0, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [4.0, 4.0], "pin": 64, "units": "mm" } ], - "frequency": 400600, + "frequency": 400600.0, "units": "mm", "attrs": {}, + "registration_surface_filename": null, + "transducer_body_filename": null, "standoff_transform": [ - [0.9, 0.1, 0, 0], - [-0.1, 0.9, 0, 0], - [0, 0, 1, 2.6], - [0, 0, 0, 1] - ] + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], + "impulse_response": [100000.0], + "impulse_dt": 1, + "module_invert": [false] } diff --git a/tests/test_solution.py b/tests/test_solution.py index d0f56b80..911d5d6b 100644 --- a/tests/test_solution.py +++ b/tests/test_solution.py @@ -21,13 +21,13 @@ def example_transducer() -> Transducer: id="trans_456", name="Test Transducer", elements=[ - Element(index=1, x=-14, y=-14, units="m"), - Element(index=2, x=-2, y=-2, units="m"), - Element(index=3, x=2, y=2, units="m"), - Element(index=4, x=14, y=14, units="m") + Element(index=1, position=[-14, -14, 0], units="mm"), + Element(index=2, position=[-2, -2, 0], units="mm"), + Element(index=3, position=[2, 2, 0], units="mm"), + Element(index=4, position=[14, 14, 0], units="mm") ], frequency=1e6, - units="m" + units="mm" ) @@ -43,7 +43,18 @@ def example_solution() -> Solution: id="sol_001", name="Test Solution", protocol_id="prot_123", - transducer_id="trans_456", + transducer=Transducer( + id="trans_456", + name="Test Transducer", + elements=[ + Element(index=1, position=[-14, -14, 0], units="m"), + Element(index=2, position=[-2, -2, 0], units="m"), + Element(index=3, position=[2, 2, 0], units="m"), + Element(index=4, position=[14, 14, 0], units="m") + ], + frequency=1e6, + units="m" + ), date_created=datetime(2024, 1, 1, 12, 0), description="This is a test solution for a unit test.", delays=np.array([[0.0, 1.0, 2.0, 3.0]]), @@ -137,9 +148,9 @@ def test_json_serialize_deserialize_solution_analysis(compact_representation: bo analysis_reconstructed = SolutionAnalysis.from_json(analysis_json) assert dataclasses_are_equal(analysis_reconstructed, analysis) -def test_solution_analyze_data_types(example_solution:Solution, example_transducer:Transducer): +def test_solution_analyze_data_types(example_solution:Solution): """Test that solution analysis field are all floats or lists of floats as expected""" - analysis = example_solution.analyze(example_transducer) + analysis = example_solution.analyze() for f in fields(analysis): value = getattr(analysis, f.name) if f.name == "param_constraints": @@ -159,7 +170,7 @@ def test_solution_created_date(): assert(now - tolerance <= solution.date_created <= now + tolerance) -def test_solution_analyze_ratios(example_solution: Solution, example_transducer: Transducer): +def test_solution_analyze_ratios(example_solution: Solution): """Test the calculation of mainlobe to sidelobe ratios in Solution.analyze()""" solution = example_solution # Use only one focus point for simplicity @@ -215,7 +226,7 @@ def test_solution_analyze_ratios(example_solution: Solution, example_transducer: # For example_transducer, with all elements at Z=0 and centered apodization, origin is approx (0,0,0) # Let's assume default transducer origin is [0,0,0] for get_mask calculations. - analysis = solution.analyze(example_transducer, options=options) + analysis = solution.analyze(options=options) # Check Case 1 # Expected mainlobe pnp = 1.0 MPa (from p_min_data[0,1,0,1] = 1e6 Pa) @@ -248,7 +259,7 @@ def test_solution_analyze_ratios(example_solution: Solution, example_transducer: solution.simulation_result['p_min'].data = p_min_data_case2 solution.simulation_result['intensity'].data = intensity_data # Keep intensity same - analysis_case2 = solution.analyze(example_transducer, options=options) + analysis_case2 = solution.analyze(options=options) assert analysis_case2.mainlobe_pnp_MPa[0] == 1.0 assert analysis_case2.sidelobe_pnp_MPa[0] == 0.0 assert analysis_case2.sidelobe_to_mainlobe_pressure_ratio[0] == 0.0 # 0 / 1.0 == 0.0 @@ -260,7 +271,7 @@ def test_solution_analyze_ratios(example_solution: Solution, example_transducer: solution.simulation_result['p_min'].data = p_min_data # Reset p_min solution.simulation_result['intensity'].data = intensity_data_case3 - analysis_case3 = solution.analyze(example_transducer, options=options) + analysis_case3 = solution.analyze(options=options) assert analysis_case3.mainlobe_isppa_Wcm2[0] == 10.0 assert analysis_case3.sidelobe_isppa_Wcm2[0] == 0.0 assert analysis_case3.sidelobe_to_mainlobe_intensity_ratio[0] == 0.0 # 0 / 10.0 == 0.0 @@ -272,7 +283,7 @@ def test_solution_analyze_ratios(example_solution: Solution, example_transducer: solution.simulation_result['p_min'].data = p_min_data_case4 solution.simulation_result['intensity'].data = intensity_data # Keep intensity same - analysis_case4 = solution.analyze(example_transducer, options=options) + analysis_case4 = solution.analyze(options=options) assert analysis_case4.mainlobe_pnp_MPa[0] == 0.0 # Ensure sidelobe is picked up as non-zero assert analysis_case4.sidelobe_pnp_MPa[0] > 0, "Sidelobe PNP was zero for Case 4, expected non-zero." @@ -284,7 +295,7 @@ def test_solution_analyze_ratios(example_solution: Solution, example_transducer: intensity_data_case5[0, 2, 0, 2] = 2.0 # Sidelobe intensity = 2 W/cm^2 solution.simulation_result['p_min'].data = p_min_data # Reset p_min solution.simulation_result['intensity'].data = intensity_data_case5 - analysis_case5 = solution.analyze(example_transducer, options=options) + analysis_case5 = solution.analyze(options=options) assert analysis_case5.mainlobe_isppa_Wcm2[0] == 0.0 assert analysis_case5.sidelobe_isppa_Wcm2[0] > 0, "Sidelobe ISPPA was zero for Case 5, expected non-zero." assert analysis_case5.sidelobe_to_mainlobe_intensity_ratio[0] == np.inf # 2.0 / 0 == inf @@ -294,7 +305,7 @@ def test_solution_analyze_ratios(example_solution: Solution, example_transducer: solution.simulation_result['p_min'].data = p_min_data_case6 solution.simulation_result['intensity'].data = intensity_data # Keep intensity same - analysis_case6 = solution.analyze(example_transducer, options=options) + analysis_case6 = solution.analyze(options=options) assert analysis_case6.mainlobe_pnp_MPa[0] == 0.0 assert analysis_case6.sidelobe_pnp_MPa[0] == 0.0 assert np.isnan(analysis_case6.sidelobe_to_mainlobe_pressure_ratio[0]) # 0 / 0 == nan @@ -303,7 +314,7 @@ def test_solution_analyze_ratios(example_solution: Solution, example_transducer: intensity_data_case7 = np.zeros((1,3,1,3)) solution.simulation_result['p_min'].data = p_min_data # Reset p_min solution.simulation_result['intensity'].data = intensity_data_case7 - analysis_case7 = solution.analyze(example_transducer, options=options) + analysis_case7 = solution.analyze(options=options) assert analysis_case7.mainlobe_isppa_Wcm2[0] == 0.0 assert analysis_case7.sidelobe_isppa_Wcm2[0] == 0.0 assert np.isnan(analysis_case7.sidelobe_to_mainlobe_intensity_ratio[0]) # 0 / 0 == nan diff --git a/tests/test_sonication_control_mock.py b/tests/test_sonication_control_mock.py index 3b774259..dd88b07d 100644 --- a/tests/test_sonication_control_mock.py +++ b/tests/test_sonication_control_mock.py @@ -16,7 +16,7 @@ def example_solution() -> Solution: id="solution", name="Solution", protocol_id="example_protocol", - transducer_id="example_transducer", + transducer=None, delays = np.zeros((1,64)), apodizations = np.ones((1,64)), pulse = Pulse(frequency=500e3, duration=2e-5), From 0fac9a0ae3957b08d676045a415c1777ddad0bbf Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Thu, 4 Sep 2025 11:31:50 -0400 Subject: [PATCH 02/14] Fixes for pre-commit and pytest #380 and #381 --- src/openlifu/__init__.py | 3 +-- src/openlifu/plan/solution.py | 2 +- src/openlifu/xdc/transducer.py | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/openlifu/__init__.py b/src/openlifu/__init__.py index f936d65b..d9520560 100644 --- a/src/openlifu/__init__.py +++ b/src/openlifu/__init__.py @@ -35,14 +35,13 @@ ) from openlifu.sim import SimSetup from openlifu.virtual_fit import VirtualFitOptions, run_virtual_fit -from openlifu.xdc import Transducer +from openlifu.xdc import Transducer, TransducerArray from ._version import version as __version__ __all__ = [ "Point", "Transducer", - "TransformedTransducer", "TransducerArray", "Protocol", "Solution", diff --git a/src/openlifu/plan/solution.py b/src/openlifu/plan/solution.py index 27b573d4..5c224667 100644 --- a/src/openlifu/plan/solution.py +++ b/src/openlifu/plan/solution.py @@ -473,7 +473,7 @@ def from_dict(solution_dict: dict) -> Solution: base64.b64decode( solution_dict["simulation_result"].encode('utf-8') ), - engine='h5netcdf', + engine='scipy', ) return Solution(**solution_dict) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index a60ebf14..b326f841 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -461,10 +461,10 @@ def to_dict(self): return tdict @staticmethod - def from_dict(data): + def from_dict(data, **kwargs): d = data.copy() transform = np.array(d.pop("transform")) - t = Transducer.from_dict(d) + t = Transducer.from_dict(d, **kwargs) return TransformedTransducer.from_transducer(t, transform) @staticmethod From 4fb5294f8105a71a9095e6e8cada8f5424c2c419 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Thu, 4 Sep 2025 15:20:44 -0400 Subject: [PATCH 03/14] Add sensitivity attribute #380, #381 The `impulse_response` attribute was getting a little complicated whether or not it was None, a single value, or an array, so I split it into a scaling `sensitivity` attribute and a unitless `impulse_response`. This also comes with improvements to merging arrays with different sensitivities. --- notebooks/test_first.py | 2 +- notebooks/test_nifti.py | 2 +- notebooks/test_solution.py | 3 +- notebooks/test_solution_analysis.py | 2 +- src/openlifu/xdc/element.py | 25 +++--- src/openlifu/xdc/transducer.py | 81 ++++++++++++------- .../example_transducer.json | 3 +- tests/test_sim.py | 2 +- 8 files changed, 75 insertions(+), 45 deletions(-) diff --git a/notebooks/test_first.py b/notebooks/test_first.py index 87b7a12f..6c230084 100644 --- a/notebooks/test_first.py +++ b/notebooks/test_first.py @@ -42,7 +42,7 @@ # We'll start by generating a transducer and drawing it using some vtk-based methods # %% -arr = openlifu.Transducer.gen_matrix_array(nx=8, ny=8, pitch=4, kerf=.5, units="mm", impulse_response=1e5) +arr = openlifu.Transducer.gen_matrix_array(nx=8, ny=8, pitch=4, kerf=.5, units="mm", sensitivity=1e5) arr.draw() # %% [markdown] diff --git a/notebooks/test_nifti.py b/notebooks/test_nifti.py index d0ab81bb..0a1e3a46 100644 --- a/notebooks/test_nifti.py +++ b/notebooks/test_nifti.py @@ -37,7 +37,7 @@ # %% -arr = openlifu.Transducer.gen_matrix_array(nx=8, ny=8, pitch=4, kerf=.5, units="mm", impulse_response=1e5) +arr = openlifu.Transducer.gen_matrix_array(nx=8, ny=8, pitch=4, kerf=.5, units="mm", sensitivity=1e5) trans_matrix = np.array( [[-1, 0, 0, 0], [0, .05, np.sqrt(1-.05**2), -105], diff --git a/notebooks/test_solution.py b/notebooks/test_solution.py index 79617df4..ed9777cc 100644 --- a/notebooks/test_solution.py +++ b/notebooks/test_solution.py @@ -22,6 +22,7 @@ pulse = openlifu.Pulse(frequency=500e3, duration=2e-5) pt = openlifu.Point(position=(0,0,30), units="mm") +example_transducer = openlifu.Transducer.gen_matrix_array(nx=8, ny=8, pitch=4, kerf=0.5, id="example_transducer") sequence = openlifu.Sequence( pulse_interval=0.1, pulse_count=10, @@ -32,7 +33,7 @@ id="solution", name="Solution", protocol_id="example_protocol", - transducer_id="example_transducer", + transducer=example_transducer, delays = np.zeros((1,64)), apodizations = np.ones((1,64)), pulse = pulse, diff --git a/notebooks/test_solution_analysis.py b/notebooks/test_solution_analysis.py index 2ce8d2d6..8f587d26 100644 --- a/notebooks/test_solution_analysis.py +++ b/notebooks/test_solution_analysis.py @@ -40,7 +40,7 @@ sim_setup=sim_setup) target = Point(position=np.array([0, 0, 50]), units="mm", radius=2) -trans = Transducer.gen_matrix_array(nx=8, ny=8, pitch=4, kerf=0.5, id="m3", name="openlifu", impulse_response=1e6/10) +trans = Transducer.gen_matrix_array(nx=8, ny=8, pitch=4, kerf=0.5, id="m3", name="openlifu", sensitivity=1e6/10) # - solution, sim_res, scaled_analysis = protocol.calc_solution( diff --git a/src/openlifu/xdc/element.py b/src/openlifu/xdc/element.py index 44e7332d..488a30d4 100644 --- a/src/openlifu/xdc/element.py +++ b/src/openlifu/xdc/element.py @@ -43,6 +43,9 @@ class Element: size: Annotated[np.ndarray, OpenLIFUFieldData("Size", "Size of the element in 2D")] = field(default_factory=lambda: np.array([1., 1.])) """ Size of the element in 2D as a numpy array [width, length].""" + sensitivity: Annotated[float | None, OpenLIFUFieldData("Sensitivity", "Sensitivity of the element (Pa/V)")] = None + """Sensitivity of the element (Pa/V)""" + impulse_response: Annotated[np.ndarray | None, OpenLIFUFieldData("Impulse response", "Impulse response of the element")] = None """Impulse response of the element, can be a single value or an array of values. If an array, `impulse_dt` must be set to the time step of the impulse response. Is convolved with the input signal.""" @@ -140,12 +143,13 @@ def length(self, value): def calc_output(self, input_signal, dt): if self.impulse_response is None: - return input_signal - elif len(self.impulse_response) == 1: - return input_signal * self.impulse_response + filtered_signal = input_signal else: impulse = self.interp_impulse_response(dt) - return np.convolve(input_signal, impulse, mode='full') + filtered_signal = np.convolve(input_signal, impulse, mode='full') + if self.sensitivity is not None: + filtered_signal *= self.sensitivity + return filtered_signal def copy(self): return copy.deepcopy(self) @@ -223,12 +227,9 @@ def interp_impulse_response(self, dt=None): if dt is None: dt = self.impulse_dt n0 = len(self.impulse_response) - if n0 == 1: - impulse_response= self.impulse_response - else: - t0 = self.impulse_dt * np.arange(n0) - t1 = np.arange(0, t0[-1] + dt, dt) - impulse_response = np.interp(t1, t0, self.impulse_response) + t0 = self.impulse_dt * np.arange(n0) + t1 = np.arange(0, t0[-1] + dt, dt) + impulse_response = np.interp(t1, t0, self.impulse_response) impulse_t = np.arange(len(impulse_response)) * dt impulse_t = impulse_t - np.mean(impulse_t) return impulse_response, impulse_t @@ -283,4 +284,8 @@ def from_dict(d): d["position"] = np.array([d.pop('x'), d.pop('y'), d.pop('z')]) d["orientation"] = np.array([d.pop('az'), d.pop('el'), d.pop('roll')]) d["size"] = np.array([d.pop('w'), d.pop('l')]) + if "impulse_response" in d and d["impulse_response"] is not None: + d["impulse_response"] = np.array(d["impulse_response"]) + if "impulse_dt" in d and d["impulse_dt"] is not None: + d["impulse_dt"] = float(d["impulse_dt"]) return Element(**d) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index b326f841..cdf5c52b 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -55,10 +55,13 @@ class Transducer: The units of this transform are assumed to be the native units of the transducer, the `Transducer.units` field. """ - impulse_response: Annotated[np.ndarray, OpenLIFUFieldData("Impulse response", "Impulse response of the elements")] = field(repr=False, default_factory=lambda: np.array([1])) - """Impulse response of the element, can be a single value or an array of values. If an array, `impulse_dt` must be set to the time step of the impulse response. Is convolved with the input voltage signal with a time base in seconds to get pressure in Pa. If an impulse response is specified per element, those will also be applied.""" + sensitivity: Annotated[float | None, OpenLIFUFieldData("Sensitivity", "Sensitivity of the element (Pa/V)")] = None + """Sensitivity of the element (Pa/V)""" - impulse_dt: Annotated[float, OpenLIFUFieldData("Impulse response timestep", """Impulse response timestep""")] = field(repr=False, default=1) + impulse_response: Annotated[np.ndarray | None, OpenLIFUFieldData("Impulse response", "Impulse response of the element")] = None + """Impulse response of the element, can be a single value or an array of values. If an array, `impulse_dt` must be set to the time step of the impulse response. Is convolved with the input signal.""" + + impulse_dt: Annotated[float | None, OpenLIFUFieldData("Impulse response timestep", """Impulse response timestep""")] = None """Impulse response timestep. If `impulse_response` is an array, this is the time step of the impulse response.""" module_invert: Annotated[List[bool], OpenLIFUFieldData("Invert polarity", "Whether to invert the polarity of the transducer output, per module")] = field(default_factory=lambda: [False]) @@ -71,26 +74,20 @@ def __post_init__(self): for element in self.elements: element.rescale(self.units) if self.impulse_response is not None: - if isinstance(self.impulse_response, (int, float)): - self.impulse_response = np.array([self.impulse_response]) self.impulse_response = np.array(self.impulse_response, dtype=np.float64) - if self.impulse_response.ndim != 1: + if self.impulse_response.ndim != 1 or len(self.impulse_response)<2: raise ValueError("Impulse response must be a 1-dimensional array.") - if len(self.impulse_response)>1 and self.impulse_dt is None: - raise ValueError("Impulse response timestep must be set if impulse response is an array.") - + if self.impulse_dt is None: + raise ValueError("Impulse response timestep must be set if impulse response is set.") def interp_impulse_response(self, dt=None): if dt is None: dt = self.impulse_dt n0 = len(self.impulse_response) - if n0 == 1: - impulse_response= self.impulse_response - else: - t0 = self.impulse_dt * np.arange(n0) - t1 = np.arange(0, t0[-1] + dt, dt) - impulse_response = np.interp(t1, t0, self.impulse_response) + t0 = self.impulse_dt * np.arange(n0) + t1 = np.arange(0, t0[-1] + dt, dt) + impulse_response = np.interp(t1, t0, self.impulse_response) impulse_t = np.arange(len(impulse_response)) * dt impulse_t = impulse_t - np.mean(impulse_t) return impulse_response, impulse_t @@ -100,12 +97,13 @@ def calc_output(self, input_signal, dt, delays: np.ndarray = None, apod: np.ndar delays = np.zeros(self.numelements()) if apod is None: apod = np.ones(self.numelements()) - - if len(self.impulse_response) == 1: - filtered_input_signal = input_signal * self.impulse_response + if self.impulse_response is None: + filtered_input_signal = input_signal else: impulse = self.interp_impulse_response(dt) filtered_input_signal = np.convolve(input_signal, impulse, mode='full') + if self.sensitivity is not None: + filtered_input_signal *= self.sensitivity outputs = [np.concatenate([np.zeros(int(delay/dt)), a*element.calc_output(filtered_input_signal, dt)],axis=0) for element, delay, a, in zip(self.elements, delays, apod)] max_len = max([len(o) for o in outputs]) output_signal = np.zeros([self.numelements(), max_len]) @@ -229,10 +227,26 @@ def get_standoff_transform_in_units(self, units:str) -> np.ndarray: return matrix @staticmethod - def merge(list_of_transducers:List[Transducer], offset_pins:bool=False, offset_indices:bool=False, merged_attrs:dict={}) -> Transducer: - merged_array = list_of_transducers[0].copy() - for arr in list_of_transducers[1:]: - xform_array = arr.copy() + def merge(list_of_transducers:List[Transducer], offset_pins:bool=False, offset_indices:bool=False, merge_mismatched_sensitivity=True, merged_attrs:dict={}) -> Transducer: + array_copies = [arr.copy() for arr in list_of_transducers] + sensitivities = np.array([arr.sensitivity for arr in array_copies if arr.sensitivity is not None]) + if len(sensitivities) > 0 and len(sensitivities) < len(array_copies): + raise ValueError("If one transducer has a sensitivity, all must have a sensitivity.") + if len(set(sensitivities)) > 1: + if not merge_mismatched_sensitivity: + raise ValueError("Transducers have different sensitivities. Use merge_mismatched_sensitivity=True to merge the relative sensitivities into the merged elements") + else: + max_sensitivity = sensitivities.max() + relative_sensitivities = sensitivities/max_sensitivity + for array, relative_sensitivity in zip(array_copies, relative_sensitivities): + for el in array.elements: + if el.sensitivity is not None: + el.sensitivity = el.sensitivity * relative_sensitivity + else: + el.sensitivity = relative_sensitivity + array.sensitivity = max_sensitivity + merged_array = array_copies[0] + for xform_array in array_copies[1:]: if offset_pins: for el in xform_array.elements: el.pin = el.pin + merged_array.numelements() @@ -267,7 +281,14 @@ def sort_by_pin(self): def to_dict(self): d = self.__dict__.copy() d["elements"] = [element.to_dict() for element in d["elements"]] - d["impulse_response"] = d["impulse_response"].tolist() + if self.impulse_response is None: + del d["impulse_response"] + else: + d["impulse_response"] = d["impulse_response"].tolist() + if self.impulse_dt is None: + del d["impulse_dt"] + else: + d["impulse_dt"] = d["impulse_dt"].tolist() d["standoff_transform"] = d["standoff_transform"].tolist() return d @@ -322,9 +343,13 @@ def from_file(filename): def from_dict(d, **kwargs): d = d.copy() d["elements"] = [Element.from_dict(element) for element in d["elements"]] - if "impulse_response" in d: - d["impulse_response"] = np.array(d["impulse_response"]) - if "standoff_transform" in d: + if "impulse_response" in d and d["impulse_response"] is not None: + if len(d["impulse_response"]) == 1 and "sensitivity" not in d: + d["sensitivity"] = d["impulse_response"][0] + del d["impulse_response"] + else: + d["impulse_response"] = np.array(d["impulse_response"]) + if "standoff_transform" in d and d["standoff_transform"] is not None: d["standoff_transform"] = np.array(d["standoff_transform"]) return Transducer(**d, **kwargs) @@ -347,7 +372,7 @@ def to_json(self, compact:bool=False) -> str: return json.dumps(self.to_dict(), indent=4) @staticmethod - def gen_matrix_array(nx=2, ny=2, pitch=1, kerf=0, units="mm", impulse_response=[1], impulse_dt=1, id='array', name='Array', attrs={}): + def gen_matrix_array(nx=2, ny=2, pitch=1, kerf=0, units="mm", **kwargs): """Generate a 2D flat matrix array Args: @@ -379,7 +404,7 @@ def gen_matrix_array(nx=2, ny=2, pitch=1, kerf=0, units="mm", impulse_response=[ size = np.array([pitch - kerf, pitch - kerf]), units=units )) - arr = Transducer(elements=elements, id=id, name=name, attrs=attrs, impulse_response=impulse_response, impulse_dt=impulse_dt, units=units) + arr = Transducer(elements=elements, units=units, **kwargs) return arr @dataclass diff --git a/tests/resources/example_db/transducers/example_transducer/example_transducer.json b/tests/resources/example_db/transducers/example_transducer/example_transducer.json index 36be5969..56704f79 100644 --- a/tests/resources/example_db/transducers/example_transducer/example_transducer.json +++ b/tests/resources/example_db/transducers/example_transducer/example_transducer.json @@ -526,7 +526,6 @@ [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0] ], - "impulse_response": [100000.0], - "impulse_dt": 1, + "sensitivity": 1000, "module_invert": [false] } diff --git a/tests/test_sim.py b/tests/test_sim.py index 92e20b16..98fbfc07 100644 --- a/tests/test_sim.py +++ b/tests/test_sim.py @@ -18,7 +18,7 @@ def test_run_simulation_runs(): """Test that run_simulation can run and outputs something of the correct type.""" - transducer = openlifu.Transducer.gen_matrix_array(nx=2, ny=2, pitch=2, kerf=.5, units="mm", impulse_response=1e5) + transducer = openlifu.Transducer.gen_matrix_array(nx=2, ny=2, pitch=2, kerf=.5, units="mm", sensitivity=1e5) dt = 2e-7 sim_setup = openlifu.SimSetup( dt=dt, From c378f8f1def0fc438ea0653a87814ec20f8de183 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Thu, 4 Sep 2025 15:32:40 -0400 Subject: [PATCH 04/14] update DVC for #380 and #381 --- db_dvc.dvc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/db_dvc.dvc b/db_dvc.dvc index 45abb20f..7c28f6a0 100644 --- a/db_dvc.dvc +++ b/db_dvc.dvc @@ -1,5 +1,6 @@ outs: -- md5: 8c01cefb88391f95752dcc83deccc514.dir - nfiles: 438 +- md5: afc205317827bd203fc98b9a9990d47a.dir + nfiles: 444 hash: md5 path: db_dvc + size: 1522865817 From 634b321a3c679cf6f274ce6cdc5df389babac3ca Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Thu, 4 Sep 2025 15:51:09 -0400 Subject: [PATCH 05/14] Undo hard coded inversion test #380 #381 --- src/openlifu/io/LIFUTXDevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openlifu/io/LIFUTXDevice.py b/src/openlifu/io/LIFUTXDevice.py index 062a2b38..5a38bc2f 100644 --- a/src/openlifu/io/LIFUTXDevice.py +++ b/src/openlifu/io/LIFUTXDevice.py @@ -1809,7 +1809,7 @@ def get_pulse_control_registers(self, profile: int | None=None, pulse_invert: bo elastic_mode = 0 y = pattern['y']*(repeat+1) y = np.array(y + [0]*pulse_profile.tail_count) - reg_mode = 0x02000043 + reg_mode = 0x02000003 reg_mode = set_register_value(reg_mode, clk_div_n, lsb=3, width=3) reg_mode = set_register_value(reg_mode, int(pulse_profile.invert^pulse_invert), lsb=6, width=1) reg_repeat = 0 From e7467450c55f14af3a877d4dd08f61977776ef86 Mon Sep 17 00:00:00 2001 From: Ebrahim Ebrahim Date: Tue, 9 Sep 2025 11:25:26 -0400 Subject: [PATCH 06/14] clean up DVC data after #380 and #381 changes - remove extraneous users - add missing transducer id to transducers.json --- db_dvc.dvc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db_dvc.dvc b/db_dvc.dvc index 7c28f6a0..49564229 100644 --- a/db_dvc.dvc +++ b/db_dvc.dvc @@ -1,6 +1,6 @@ outs: -- md5: afc205317827bd203fc98b9a9990d47a.dir - nfiles: 444 +- md5: e6d758f5bc6a461969c92a52cd689f1a.dir + nfiles: 438 hash: md5 path: db_dvc - size: 1522865817 + size: 1522864054 From 2522ae478d15dda776e3821b98ed32141a85ee8f Mon Sep 17 00:00:00 2001 From: Ebrahim Ebrahim Date: Tue, 9 Sep 2025 12:58:15 -0400 Subject: [PATCH 07/14] Fix standoff transform reading for TransducerArray #380 #381 It was not being converted to a numpy array on read --- src/openlifu/db/database.py | 3 +- src/openlifu/xdc/transducerarray.py | 6 ++ .../example_transducer_array.json | 99 +++++++++++++++++++ .../example_db/transducers/transducers.json | 2 +- tests/test_database.py | 4 +- tests/test_transducer.py | 12 ++- 6 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json diff --git a/src/openlifu/db/database.py b/src/openlifu/db/database.py index 532236da..f7273dd8 100644 --- a/src/openlifu/db/database.py +++ b/src/openlifu/db/database.py @@ -851,12 +851,13 @@ def load_system(self, sys_id=None): sys = UltrasoundSystem.from_file(sys_filename) return sys - def load_transducer(self, transducer_id, convert_array:bool = True) -> Transducer: + def load_transducer(self, transducer_id, convert_array:bool = True) -> Transducer|TransducerArray: """Given a transducer_id, reads the corresponding transducer file from database and returns a transducer object. Note: the transducer object includes the relative path to the affiliated transducer model data. `get_transducer_absolute_filepaths`, should be used to obtain the absolute data filepaths based on the Database directory path. Args: transducer_id: Transducer ID + convert_array: When enabled, if a TransducerArray is encountered then it is converted to a Transducer. Returns: Corresponding Transducer object """ diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index e08dc420..4fb1759e 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -42,6 +42,12 @@ def from_dict(data: dict): if "type" in d: d.pop("type") d["modules"] = [TransformedTransducer.from_dict(t) for t in data["modules"]] + if ( + "attrs" in d + and "standoff_transform" in d["attrs"] + and d["attrs"]["standoff_transform"] is not None + ): + d["attrs"]["standoff_transform"] = np.array(d["attrs"]["standoff_transform"]) return TransducerArray(**d) diff --git a/tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json b/tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json new file mode 100644 index 00000000..7cee9aa7 --- /dev/null +++ b/tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json @@ -0,0 +1,99 @@ +{ + "type": "TransducerArray", + "id": "example_transducer_array", + "name": "test example of an *array* of *transformed* transducers", + "modules": [ + { + "id": "module1", + "name": "Module 1", + "elements": [ + { + "index": 1, + "position": [-17.5, 17.5, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [3.7, 3.7], + "pin": 1, + "units": "mm" + }, + { + "index": 2, + "position": [-17.5, 13.5, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [3.7, 3.7], + "pin": 2, + "units": "mm" + } + ], + "frequency": 175000.0, + "units": "mm", + "attrs": {}, + "registration_surface_filename": null, + "transducer_body_filename": null, + "standoff_transform": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], + "sensitivity": 1200, + "module_invert": [false], + "transform": [ + [0.9, 0.0, -0.2, 22.8], + [0.0, 1.0, 0.0, 0.0], + [0.2, 0.0, 0.9, 3.3], + [0.0, 0.0, 0.0, 1.0] + ] + }, + { + "id": "module2", + "name": "Module 2", + "elements": [ + { + "index": 1, + "position": [-17.5, 17.5, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [3.7, 3.7], + "pin": 1, + "units": "mm" + }, + { + "index": 2, + "position": [-17.5, 13.5, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [3.7, 3.7], + "pin": 2, + "units": "mm" + } + ], + "frequency": 180000.0, + "units": "mm", + "attrs": {}, + "registration_surface_filename": null, + "transducer_body_filename": null, + "standoff_transform": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], + "sensitivity": 1000, + "module_invert": [false], + "transform": [ + [0.9, 0.0, 0.2, -22.0], + [0.0, 1.0, 0.0, 0.0], + [-0.2, 0.0, 0.9, 4.2], + [0.0, 0.0, 0.0, 1.0] + ] + } + ], + "attrs": { + "standoff_transform": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.9, -0.02, 0.0], + [0.0, 0.02, 0.9, -4.0], + [0.0, 0.0, 0.0, 1.0] + ], + "registration_surface_filename": null, + "transducer_body_filename": null + } +} diff --git a/tests/resources/example_db/transducers/transducers.json b/tests/resources/example_db/transducers/transducers.json index 64057b53..2fd1d54f 100644 --- a/tests/resources/example_db/transducers/transducers.json +++ b/tests/resources/example_db/transducers/transducers.json @@ -1 +1 @@ -{ "transducer_ids": ["example_transducer"] } +{ "transducer_ids": ["example_transducer", "example_transducer_array"] } diff --git a/tests/test_database.py b/tests/test_database.py index c9a3e9c2..0ea3b18c 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -666,13 +666,13 @@ def test_session_date_modified_updates_on_write(example_database:Database, examp assert session.date_modified > initial_modified_time - tolerance def test_get_transducer_ids(example_database:Database): - assert(example_database.get_transducer_ids() == ["example_transducer"]) + assert(example_database.get_transducer_ids() == ["example_transducer", "example_transducer_array"]) def test_write_transducer_nodata(example_database:Database, example_transducer: Transducer): example_transducer.id = "example_transducer_2" example_database.write_transducer(example_transducer) - assert(len(example_database.get_transducer_ids()) == 2) + assert(len(example_database.get_transducer_ids()) == 3) assert("example_transducer" in example_database.get_transducer_ids()) assert("example_transducer_2" in example_database.get_transducer_ids()) diff --git a/tests/test_transducer.py b/tests/test_transducer.py index 8263a0e0..a25c799c 100644 --- a/tests/test_transducer.py +++ b/tests/test_transducer.py @@ -6,13 +6,17 @@ import pytest from helpers import dataclasses_are_equal -from openlifu.xdc import Element, Transducer +from openlifu.xdc import Element, Transducer, TransducerArray @pytest.fixture() def example_transducer() -> Transducer: return Transducer.from_file(Path(__file__).parent/'resources/example_db/transducers/example_transducer/example_transducer.json') +@pytest.fixture() +def example_transducer_array() -> TransducerArray: + return TransducerArray.from_file(Path(__file__).parent/'resources/example_db/transducers/example_transducer_array/example_transducer_array.json') + @pytest.mark.parametrize("compact_representation", [True, False]) def test_serialize_deserialize_transducer(example_transducer : Transducer, compact_representation: bool): reconstructed_transducer = example_transducer.from_json(example_transducer.to_json(compact_representation)) @@ -89,3 +93,9 @@ def test_read_data_types(example_transducer:Transducer): assert isinstance(example_transducer.standoff_transform, np.ndarray) if len(example_transducer.elements) > 0: assert isinstance(example_transducer.elements[0], Element) + +def test_transducer_array_to_transducer_data_types(example_transducer_array:TransducerArray): + transducer = example_transducer_array.to_transducer() + assert isinstance(transducer.standoff_transform, np.ndarray) + if len(transducer.elements) > 0: + assert isinstance(transducer.elements[0], Element) From ddf6a31c8ff50cc4f457a6642fa25f344de3ea33 Mon Sep 17 00:00:00 2001 From: Ebrahim Ebrahim Date: Tue, 9 Sep 2025 15:45:04 -0400 Subject: [PATCH 08/14] clean up DVC data after #380 and #381 changes - remove misplaced transducer folder and json --- db_dvc.dvc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db_dvc.dvc b/db_dvc.dvc index 49564229..fb473923 100644 --- a/db_dvc.dvc +++ b/db_dvc.dvc @@ -1,6 +1,6 @@ outs: -- md5: e6d758f5bc6a461969c92a52cd689f1a.dir - nfiles: 438 +- md5: 67e3e857253eb8f72c6914dc91c4afe5.dir + nfiles: 437 hash: md5 path: db_dvc - size: 1522864054 + size: 1522837809 From a445a546ed7dda472dff784e11c288eeaf045fcc Mon Sep 17 00:00:00 2001 From: Ebrahim Ebrahim Date: Tue, 9 Sep 2025 16:20:43 -0400 Subject: [PATCH 09/14] Fix reading of Transducer impulse_dt #380 #381 It was wrongly being converted to list --- src/openlifu/xdc/transducer.py | 2 -- .../example_transducer_array/example_transducer_array.json | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/openlifu/xdc/transducer.py b/src/openlifu/xdc/transducer.py index cdf5c52b..b1827726 100644 --- a/src/openlifu/xdc/transducer.py +++ b/src/openlifu/xdc/transducer.py @@ -287,8 +287,6 @@ def to_dict(self): d["impulse_response"] = d["impulse_response"].tolist() if self.impulse_dt is None: del d["impulse_dt"] - else: - d["impulse_dt"] = d["impulse_dt"].tolist() d["standoff_transform"] = d["standoff_transform"].tolist() return d diff --git a/tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json b/tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json index 7cee9aa7..5e59aba3 100644 --- a/tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json +++ b/tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json @@ -35,6 +35,8 @@ [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0] ], + "impulse_response": [1000.0, 1000.0], + "impulse_dt": 1, "sensitivity": 1200, "module_invert": [false], "transform": [ @@ -76,6 +78,8 @@ [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0] ], + "impulse_response": [1000.0, 1000.0], + "impulse_dt": 1, "sensitivity": 1000, "module_invert": [false], "transform": [ From af8d8f66bf6eda31edcd4a30c0111e746be8c791 Mon Sep 17 00:00:00 2001 From: Ebrahim Ebrahim Date: Tue, 9 Sep 2025 16:44:15 -0400 Subject: [PATCH 10/14] Fix TransducerArray reading of impulse_response #380 #381 When the impulse response is global to the transducer array, rather than per module, it is not converted to a numpy array when it should be. --- src/openlifu/xdc/transducerarray.py | 11 +- .../example_transducer_array2.json | 101 ++++++++++++++++++ .../example_db/transducers/transducers.json | 8 +- tests/test_database.py | 4 +- tests/test_transducer.py | 19 +++- 5 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 tests/resources/example_db/transducers/example_transducer_array2/example_transducer_array2.json diff --git a/src/openlifu/xdc/transducerarray.py b/src/openlifu/xdc/transducerarray.py index 4fb1759e..40e61b9c 100644 --- a/src/openlifu/xdc/transducerarray.py +++ b/src/openlifu/xdc/transducerarray.py @@ -42,12 +42,11 @@ def from_dict(data: dict): if "type" in d: d.pop("type") d["modules"] = [TransformedTransducer.from_dict(t) for t in data["modules"]] - if ( - "attrs" in d - and "standoff_transform" in d["attrs"] - and d["attrs"]["standoff_transform"] is not None - ): - d["attrs"]["standoff_transform"] = np.array(d["attrs"]["standoff_transform"]) + if "attrs" in d: + if "standoff_transform" in d["attrs"] and d["attrs"]["standoff_transform"] is not None: + d["attrs"]["standoff_transform"] = np.array(d["attrs"]["standoff_transform"]) + if "impulse_response" in d["attrs"] and d["attrs"]["impulse_response"] is not None: + d["attrs"]["impulse_response"] = np.array(d["attrs"]["impulse_response"]) return TransducerArray(**d) diff --git a/tests/resources/example_db/transducers/example_transducer_array2/example_transducer_array2.json b/tests/resources/example_db/transducers/example_transducer_array2/example_transducer_array2.json new file mode 100644 index 00000000..dbd75fa9 --- /dev/null +++ b/tests/resources/example_db/transducers/example_transducer_array2/example_transducer_array2.json @@ -0,0 +1,101 @@ +{ + "type": "TransducerArray", + "id": "example_transducer_array", + "name": "test example of an *array* of *transformed* transducers -- this time with certain attributes moved to attrs rather than individual modules", + "modules": [ + { + "id": "module1", + "name": "Module 1", + "elements": [ + { + "index": 1, + "position": [-17.5, 17.5, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [3.7, 3.7], + "pin": 1, + "units": "mm" + }, + { + "index": 2, + "position": [-17.5, 13.5, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [3.7, 3.7], + "pin": 2, + "units": "mm" + } + ], + "frequency": 175000.0, + "units": "mm", + "attrs": {}, + "registration_surface_filename": null, + "transducer_body_filename": null, + "standoff_transform": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], + "sensitivity": 1200, + "module_invert": [false], + "transform": [ + [0.9, 0.0, -0.2, 22.8], + [0.0, 1.0, 0.0, 0.0], + [0.2, 0.0, 0.9, 3.3], + [0.0, 0.0, 0.0, 1.0] + ] + }, + { + "id": "module2", + "name": "Module 2", + "elements": [ + { + "index": 1, + "position": [-17.5, 17.5, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [3.7, 3.7], + "pin": 1, + "units": "mm" + }, + { + "index": 2, + "position": [-17.5, 13.5, 0.0], + "orientation": [0.0, 0.0, 0.0], + "size": [3.7, 3.7], + "pin": 2, + "units": "mm" + } + ], + "frequency": 180000.0, + "units": "mm", + "attrs": {}, + "registration_surface_filename": null, + "transducer_body_filename": null, + "standoff_transform": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], + "sensitivity": 1000, + "module_invert": [false], + "transform": [ + [0.9, 0.0, 0.2, -22.0], + [0.0, 1.0, 0.0, 0.0], + [-0.2, 0.0, 0.9, 4.2], + [0.0, 0.0, 0.0, 1.0] + ] + } + ], + "attrs": { + "standoff_transform": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.9, -0.02, 0.0], + [0.0, 0.02, 0.9, -4.0], + [0.0, 0.0, 0.0, 1.0] + ], + "impulse_response": [1000.0, 1000.0], + "impulse_dt": 1, + "registration_surface_filename": null, + "transducer_body_filename": null + } +} diff --git a/tests/resources/example_db/transducers/transducers.json b/tests/resources/example_db/transducers/transducers.json index 2fd1d54f..147844af 100644 --- a/tests/resources/example_db/transducers/transducers.json +++ b/tests/resources/example_db/transducers/transducers.json @@ -1 +1,7 @@ -{ "transducer_ids": ["example_transducer", "example_transducer_array"] } +{ + "transducer_ids": [ + "example_transducer", + "example_transducer_array", + "example_transducer_array2" + ] +} diff --git a/tests/test_database.py b/tests/test_database.py index 0ea3b18c..4ec6acc7 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -666,13 +666,13 @@ def test_session_date_modified_updates_on_write(example_database:Database, examp assert session.date_modified > initial_modified_time - tolerance def test_get_transducer_ids(example_database:Database): - assert(example_database.get_transducer_ids() == ["example_transducer", "example_transducer_array"]) + assert("example_transducer" in example_database.get_transducer_ids()) def test_write_transducer_nodata(example_database:Database, example_transducer: Transducer): example_transducer.id = "example_transducer_2" example_database.write_transducer(example_transducer) - assert(len(example_database.get_transducer_ids()) == 3) + assert(len(example_database.get_transducer_ids()) == 4) assert("example_transducer" in example_database.get_transducer_ids()) assert("example_transducer_2" in example_database.get_transducer_ids()) diff --git a/tests/test_transducer.py b/tests/test_transducer.py index a25c799c..526dfc58 100644 --- a/tests/test_transducer.py +++ b/tests/test_transducer.py @@ -13,9 +13,9 @@ def example_transducer() -> Transducer: return Transducer.from_file(Path(__file__).parent/'resources/example_db/transducers/example_transducer/example_transducer.json') -@pytest.fixture() -def example_transducer_array() -> TransducerArray: - return TransducerArray.from_file(Path(__file__).parent/'resources/example_db/transducers/example_transducer_array/example_transducer_array.json') +def load_transducer_array(transducer_array_id : str) -> TransducerArray: + """Load an example TransducerArray given the transducer ID.""" + return TransducerArray.from_file(Path(__file__).parent/f'resources/example_db/transducers/{transducer_array_id}/{transducer_array_id}.json') @pytest.mark.parametrize("compact_representation", [True, False]) def test_serialize_deserialize_transducer(example_transducer : Transducer, compact_representation: bool): @@ -94,8 +94,17 @@ def test_read_data_types(example_transducer:Transducer): if len(example_transducer.elements) > 0: assert isinstance(example_transducer.elements[0], Element) -def test_transducer_array_to_transducer_data_types(example_transducer_array:TransducerArray): - transducer = example_transducer_array.to_transducer() +@pytest.mark.parametrize( + "transducer_array_id", + [ + "example_transducer_array", + "example_transducer_array2", + ] +) +def test_transducer_array_to_transducer_data_types(transducer_array_id): + transducer_array : TransducerArray = load_transducer_array(transducer_array_id) + transducer = transducer_array.to_transducer() assert isinstance(transducer.standoff_transform, np.ndarray) + assert isinstance(transducer.impulse_response, np.ndarray) if len(transducer.elements) > 0: assert isinstance(transducer.elements[0], Element) From e1c26c627a2e86740286acfdfdc78fe00a78a0fd Mon Sep 17 00:00:00 2001 From: Ebrahim Ebrahim Date: Tue, 9 Sep 2025 20:41:19 -0400 Subject: [PATCH 11/14] Standardize DVC data for #380 and #381 changes - Update standardization script, noting that writing of TransducerArray is not supported now. - Fix Solutions in DVC data -- they had transducer_id rather than Transducer attribute in them - Run standardization script on DVC data --- db_dvc.dvc | 4 ++-- scripts/standardize_database.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/db_dvc.dvc b/db_dvc.dvc index fb473923..45ae8241 100644 --- a/db_dvc.dvc +++ b/db_dvc.dvc @@ -1,6 +1,6 @@ outs: -- md5: 67e3e857253eb8f72c6914dc91c4afe5.dir +- md5: 223b1f68581e98c6eca9fd45b5fb099c.dir nfiles: 437 hash: md5 path: db_dvc - size: 1522837809 + size: 1523256583 diff --git a/scripts/standardize_database.py b/scripts/standardize_database.py index 7f75c685..c5b2fc85 100644 --- a/scripts/standardize_database.py +++ b/scripts/standardize_database.py @@ -15,6 +15,7 @@ """ from __future__ import annotations +import logging import pathlib import shutil import sys @@ -22,6 +23,7 @@ from openlifu.db import Database from openlifu.db.database import OnConflictOpts +from openlifu.xdc import Transducer if len(sys.argv) != 2: raise RuntimeError("Provide exactly one argument: the path to the database folder.") @@ -35,7 +37,10 @@ db.write_transducer_ids(db.get_transducer_ids()) for transducer_id in db.get_transducer_ids(): - transducer = db.load_transducer(transducer_id) + transducer = db.load_transducer(transducer_id, convert_array=False) + if not isinstance(transducer, Transducer): + logging.warning(f"Skipping {transducer_id} because TransducerArray writing is not supported.") + continue assert transducer_id == transducer.id db.write_transducer(transducer, on_conflict=OnConflictOpts.OVERWRITE) From 72eaa5a95a1dbc2d9db3724129f71005c410a5fb Mon Sep 17 00:00:00 2001 From: Ebrahim Ebrahim Date: Tue, 9 Sep 2025 21:14:43 -0400 Subject: [PATCH 12/14] Expose smart transducer loading as util #380 #381 "Smart transducer loading" means reading via Transducer or TransducerArray's serializer depending on the "type" field in the json file. This functionality was stuck inside of Databse, but we expose it as a general utility to support its use in SlicerOpenLIFU. --- src/openlifu/db/database.py | 17 +++++------------ src/openlifu/xdc/util.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 src/openlifu/xdc/util.py diff --git a/src/openlifu/db/database.py b/src/openlifu/db/database.py index f7273dd8..7578bd06 100644 --- a/src/openlifu/db/database.py +++ b/src/openlifu/db/database.py @@ -15,6 +15,7 @@ from openlifu.plan import Protocol, Run, Solution from openlifu.util.json import PYFUSEncoder from openlifu.xdc import Transducer, TransducerArray +from openlifu.xdc.util import load_transducer_from_file from .session import Session from .subject import Subject @@ -861,18 +862,10 @@ def load_transducer(self, transducer_id, convert_array:bool = True) -> Transduce Returns: Corresponding Transducer object """ - transducer_filename = self.get_transducer_filename(transducer_id) - with open(transducer_filename) as f: - if not f: - raise FileNotFoundError(f"Transducer file not found for ID: {transducer_id}") - d = json.load(f) - if "type" in d and d["type"] == "TransducerArray": - transducer = TransducerArray.from_dict(d) - if convert_array: - transducer = transducer.to_transducer() - else: - transducer = Transducer.from_file(transducer_filename) - return transducer + return load_transducer_from_file( + transducer_filepath=self.get_transducer_filename(transducer_id), + convert_array=convert_array + ) def load_transducer_standoff(self, trans, coords, options=None): raise NotImplementedError("Standoff is not yet implemented") diff --git a/src/openlifu/xdc/util.py b/src/openlifu/xdc/util.py new file mode 100644 index 00000000..524464d7 --- /dev/null +++ b/src/openlifu/xdc/util.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import json +import os +from typing import Union + +from openlifu.xdc.transducer import Transducer +from openlifu.xdc.transducerarray import TransducerArray + +PathLike = Union[str,os.PathLike] + +def load_transducer_from_file(transducer_filepath : PathLike, convert_array:bool = True) -> Transducer|TransducerArray: + """Load a Transducer or TransducerArray from file, depending on the "type" field in the file. + Note: the transducer object includes the relative path to the affiliated transducer model data. `get_transducer_absolute_filepaths`, should + be used to obtain the absolute data filepaths based on the Database directory path. + Args: + transducer_filepath: path to the transducer json file + convert_array: When enabled, if a TransducerArray is encountered then it is converted to a Transducer. + Returns: a Transducer if the json file defines a Transducer, or if the json file defines a TransducerArray and convert_array is enabled. + Otherwise a TransducerArray. + """ + with open(transducer_filepath) as f: + if not f: + raise FileNotFoundError(f"Transducer file not found at: {transducer_filepath}") + d = json.load(f) + if "type" in d and d["type"] == "TransducerArray": + transducer = TransducerArray.from_dict(d) + if convert_array: + transducer = transducer.to_transducer() + else: + transducer = Transducer.from_file(transducer_filepath) + return transducer From d54143daa771aa1b67516628c31c1f249248447d Mon Sep 17 00:00:00 2001 From: Ebrahim Ebrahim Date: Tue, 9 Sep 2025 21:38:42 -0400 Subject: [PATCH 13/14] Update Transducer asset path getter to handle TransducerArray For the changes towards #380 and #381 --- src/openlifu/db/database.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/openlifu/db/database.py b/src/openlifu/db/database.py index 7578bd06..748c65a1 100644 --- a/src/openlifu/db/database.py +++ b/src/openlifu/db/database.py @@ -819,25 +819,24 @@ def get_transducer_absolute_filepaths(self, transducer_id:str) -> Dict[str,str | None if no transducer body is available. """ transducer_metadata_filepath = self.get_transducer_filename(transducer_id) - with open(transducer_metadata_filepath) as f: - transducer = json.load(f) - transducer_filepaths_dict = { - "id": transducer["id"], - "name": transducer["name"], - } - if "registration_surface_filename" in transducer and transducer["registration_surface_filename"] is not None: - transducer_filepaths_dict["registration_surface_abspath"] = str( - Path(transducer_metadata_filepath).parent/transducer["registration_surface_filename"] - ) - else: - transducer_filepaths_dict["registration_surface_abspath"] = None - if "transducer_body_filename" in transducer and transducer["transducer_body_filename"] is not None: - transducer_filepaths_dict["transducer_body_abspath"] = str( - Path(transducer_metadata_filepath).parent/transducer["transducer_body_filename"] - ) - else: - transducer_filepaths_dict["transducer_body_abspath"] = None - return transducer_filepaths_dict + transducer = self.load_transducer(transducer_id, convert_array=True) + + transducer_filepaths_dict = { + "id": transducer.id, + "name": transducer.name, + "registration_surface_abspath" : None, + "transducer_body_abspath" : None, + } + + if transducer.registration_surface_filename is not None: + transducer_filepaths_dict["registration_surface_abspath"] = str( + Path(transducer_metadata_filepath).parent/(transducer.registration_surface_filename) + ) + if transducer.transducer_body_filename is not None: + transducer_filepaths_dict["transducer_body_abspath"] = str( + Path(transducer_metadata_filepath).parent/(transducer.transducer_body_filename) + ) + return transducer_filepaths_dict def load_standoff(self, transducer_id, standoff_id="standoff"): raise NotImplementedError("Standoff is not yet implemented") From ff812fd68ca15b9fd28f1b79062c590b738c7708 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Wed, 10 Sep 2025 10:17:47 +0800 Subject: [PATCH 14/14] update DVC for _005 for #380 and #381 --- db_dvc.dvc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db_dvc.dvc b/db_dvc.dvc index 45ae8241..d10861c4 100644 --- a/db_dvc.dvc +++ b/db_dvc.dvc @@ -1,6 +1,6 @@ outs: -- md5: 223b1f68581e98c6eca9fd45b5fb099c.dir - nfiles: 437 +- md5: e128f63a052c85b2fce480a4e9f81e5e.dir + nfiles: 441 hash: md5 path: db_dvc - size: 1523256583 + size: 1540218836