diff --git a/db_dvc.dvc b/db_dvc.dvc index d38682d1..d10861c4 100644 --- a/db_dvc.dvc +++ b/db_dvc.dvc @@ -1,6 +1,6 @@ outs: -- md5: 107d8598bb23a151f184ead9c8593d4a.dir - nfiles: 435 +- md5: e128f63a052c85b2fce480a4e9f81e5e.dir + nfiles: 441 hash: md5 path: db_dvc - size: 1500527483 + size: 1540218836 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_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/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/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) diff --git a/src/openlifu/__init__.py b/src/openlifu/__init__.py index 542fffe6..d9520560 100644 --- a/src/openlifu/__init__.py +++ b/src/openlifu/__init__.py @@ -35,13 +35,14 @@ ) 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", + "TransducerArray", "Protocol", "Solution", "Material", diff --git a/src/openlifu/db/database.py b/src/openlifu/db/database.py index accf8d4d..748c65a1 100644 --- a/src/openlifu/db/database.py +++ b/src/openlifu/db/database.py @@ -14,7 +14,8 @@ 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 openlifu.xdc.util import load_transducer_from_file from .session import Session from .subject import Subject @@ -818,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") @@ -851,18 +851,20 @@ 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|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 """ - transducer_filename = self.get_transducer_filename(transducer_id) - 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") @@ -966,6 +968,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..5a38bc2f 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) + y = np.array(y + [0]*pulse_profile.tail_count) 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(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..5c224667 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,15 +468,7 @@ 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 is not None: - if "simulation_result" in solution_dict: - raise ValueError( - "A simulation result was provided while the json string already contains `simulation_result`. " - "Unclear which to use!" - ) - solution_dict["simulation_result"] = simulation_result - elif "simulation_result" in solution_dict: - # Deserialize xarray dataset from string + 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') @@ -494,6 +478,29 @@ def from_json(json_string : str, simulation_result: xa.Dataset | None=None) -> S 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( + "A simulation result was provided while the json string already contains `simulation_result`. " + "Unclear which to use!" + ) + solution_dict["simulation_result"] = simulation_result + + 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..488a30d4 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,22 @@ 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)""" + sensitivity: Annotated[float | None, OpenLIFUFieldData("Sensitivity", "Sensitivity of the element (Pa/V)")] = None + """Sensitivity of the element (Pa/V)""" - 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,17 +59,97 @@ 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: - return input_signal * self.impulse_response + if self.impulse_response is None: + 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) @@ -90,17 +157,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 +172,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 +184,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 +193,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 @@ -163,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 @@ -200,28 +261,31 @@ 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')]) + 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 14e9c5a2..b1827726 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,56 @@ class Transducer: The units of this transform are assumed to be the native units of the transducer, the `Transducer.units` field. """ + 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.""" + + 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]) + """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: + self.impulse_response = np.array(self.impulse_response, dtype=np.float64) + if self.impulse_response.ndim != 1 or len(self.impulse_response)<2: + raise ValueError("Impulse response must be a 1-dimensional 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) + 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 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]) for i, o in enumerate(outputs): @@ -189,11 +227,36 @@ def get_standoff_transform_in_units(self, units:str) -> np.ndarray: return matrix @staticmethod - def merge(list_of_transducers:List[Transducer]) -> 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() + 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 +281,12 @@ def sort_by_pin(self): def to_dict(self): d = self.__dict__.copy() d["elements"] = [element.to_dict() for element in d["elements"]] + 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"] d["standoff_transform"] = d["standoff_transform"].tolist() return d @@ -231,6 +300,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,7 +341,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 "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) @@ -250,7 +356,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 +370,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", **kwargs): """Generate a 2D flat matrix array Args: @@ -292,24 +389,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, units=units, **kwargs) + 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, **kwargs): + d = data.copy() + transform = np.array(d.pop("transform")) + t = Transducer.from_dict(d, **kwargs) + 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..40e61b9c --- /dev/null +++ b/src/openlifu/xdc/transducerarray.py @@ -0,0 +1,141 @@ +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"]] + 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) + + + 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/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 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..56704f79 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,528 @@ "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] + ], + "sensitivity": 1000, + "module_invert": [false] } 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..5e59aba3 --- /dev/null +++ b/tests/resources/example_db/transducers/example_transducer_array/example_transducer_array.json @@ -0,0 +1,103 @@ +{ + "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] + ], + "impulse_response": [1000.0, 1000.0], + "impulse_dt": 1, + "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] + ], + "impulse_response": [1000.0, 1000.0], + "impulse_dt": 1, + "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/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 64057b53..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"] } +{ + "transducer_ids": [ + "example_transducer", + "example_transducer_array", + "example_transducer_array2" + ] +} diff --git a/tests/test_database.py b/tests/test_database.py index c9a3e9c2..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"]) + 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()) == 2) + 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_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, 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), diff --git a/tests/test_transducer.py b/tests/test_transducer.py index 8263a0e0..526dfc58 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') +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): reconstructed_transducer = example_transducer.from_json(example_transducer.to_json(compact_representation)) @@ -89,3 +93,18 @@ 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) + +@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)