diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml new file mode 100644 index 00000000..4cdff645 --- /dev/null +++ b/.github/workflows/mkdocs.yml @@ -0,0 +1,44 @@ +name: Deploy PRESTUS Docs +on: + push: + branches: [ development, main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + + - name: Install dependencies + run: | + pip install -U pip + pip install mkdocs mkdocs-material pymdown-extensions + + - name: Build + run: mkdocs build --clean + + - uses: actions/upload-pages-artifact@v3 + with: + path: ./site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7d2e7c52..14556549 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,12 @@ *.o[0-9][0-9][0-9][0-9]* +###################### +# Ignore mkdocs site # +###################### + +site/ + ################### # Matlab specific # ################### @@ -80,4 +86,5 @@ $RECYCLE.BIN/ .directory .Trash-* core.* -.Rhistory \ No newline at end of file +.Rhistory +.claude \ No newline at end of file diff --git a/README.md b/README.md index 2430e034..9d2b6c27 100755 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ PRESTUS (PREprocessing & Simulations for Transcranial Ultrasound Stimulation) is an open-source MATLAB toolbox that aims to streamline imaging-informed simulations of Transcanial Ultrasound Stimulation (TUS): from the segmentation of T1-weighted MRI head scans and mapping of medium tissue properties (possibly informed for skull via (pseudo-)CT images) in a simulation grid, to the execution of acoustic and thermal simulations using the widely adopted k-Wave engine. High-performance computing (HPC) support via SLURM and CentOS enables efficient parallelization, and large-scale analyses. Output 3D NifTI images are automatically mapped to a standard template (MNI) space to facilitate group reporting. Key features include: + - Automated MRI segmentation (using SimNIBS 4 charm) and preprocessing. - 2D / 3D grid setup. - Multi-layer medium property mapping (water, skin, multi-layer skull, and brain). @@ -17,55 +18,36 @@ Key features include: - 3D NifTI outputs for major reporting metrics (in subject- & MNI-space). These features make PRESTUS an accessible tool for researchers to plan, validate, and report transcranial ultrasound targeting. -The ```documentation``` folder contains evolving documentation of the main workflows and parameters, alongside a tutorial on how to run the integrated pipeline. - A recent overview poster can be found [here](https://jkosciessa.github.io/downloads/2025-FUN25-PRESTUS.pdf). -If you would like to assist the community development of this tool, please consider [CONTRIBUTING](CONTRIBUTING.md). - -# Installation - -## Donders Institute HPC Cluster - -When working on the Donders High-Performance-Computing cluster, PRESTUS and its dependencies (SimNIBS, k-Wave) are already installed. - -Type ``module load simnibs/4.0.0`` (or add the command to your .bashrc so that it is executed automatically once you login) and add ``addpath('/opt/prestus/dev')`` to your matlab path. This will use the most up-to-date version of PRESTUS (i.e., the current development branch). If you want to use an older version you can also use ``addpath('/opt/prestus/0.2.0')``, or older versions. Now you can start matlab R2022b. +# Documentation -For more information on HPC usage, see [doc_hpc](documentation/doc_hpc.md) - -If you want to get started with simulations, you can use the PRESTUS example dataset. This command will copy the dataset to your home directory: - -``` -cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} -``` - -## Outside the Donders HPC - -Follow the [installation guide](documentation/doc_installation.md). +Please see the [PRESTUS documentation](https://donders-institute.github.io/PRESTUS/). # Reference -Chetverikov, A.\*, Kosciessa, J. Q.\*, Cornelissen, M., Carpino, E., van der Zee, K., & Verhagen, L. (2025). PRESTUS (0.4.0). Zenodo. https://doi.org/10.5281/zenodo.15965832 +If you use this tool for publications, please cite: +Kosciessa, J. Q.\*, Chetverikov, A.\*, Cornelissen, M., van der Zee, K., Carpino, E., Wimmers, M., AdelhΓΆfer, N., Meijer, S., & Verhagen, L. (2026). PRESTUS (0.5.0). Zenodo.Β https://doi.org/10.5281/zenodo.15095860 # Contributors [![Contributors](https://img.shields.io/github/contributors/Donders-Institute/PRESTUS.svg?color=00B4D8&style=flat-square)](https://github.com/Donders-Institute/PRESTUS/graphs/contributors) -[![All Contributors](https://img.shields.io/github/all-contributors/Donders-Institute/PRESTUS?color=00B4D8&style=flat-square)](#contributors) - - - - - - - - - + + + + + + + + + +
Julian Kosciessa
Julian Kosciessa

πŸ’» πŸ€” βœ… 🚧 πŸ› πŸ“–
Andrey Chetverikov
Andrey Chetverikov

πŸ’» πŸ€” βœ…
Kenneth van der Zee
Kenneth van der Zee

πŸ’» πŸ€” 🚧 πŸ›
Margely Cornelissen
Margely Cornelissen

πŸ’» 🚧 βœ… πŸ›
Eleonora Carpino
Eleonora Carpino

πŸ’» πŸ€”
Matthias Ekman
Matthias Ekman

πŸ›
Martin Wimmers
Martin Wimmers

βœ… πŸ›
Nico AdelhΓΆfer
Nico AdelhΓΆfer

πŸ€”
Lennart Verhagen
Lennart Verhagen

πŸ€”
Julian Kosciessa
Julian Kosciessa

πŸ’» πŸ€” βœ… 🚧 πŸ› πŸ“–
Andrey Chetverikov
Andrey Chetverikov

πŸ’» πŸ€” βœ…
Kenneth van der Zee
Kenneth van der Zee

πŸ’» πŸ€” 🚧 πŸ›
Margely Cornelissen
Margely Cornelissen

πŸ’» 🚧 βœ… πŸ›
Eleonora Carpino
Eleonora Carpino

πŸ’» πŸ€”
Martin Wimmers
Martin Wimmers

πŸ’» βœ… πŸ›
Nico AdelhΓΆfer
Nico AdelhΓΆfer

πŸ’» πŸ€”
meijer-s
meijer-s

πŸ’» πŸ€”
Matthias Ekman
Matthias Ekman

πŸ›
Lennart Verhagen
Lennart Verhagen

πŸ€”
@@ -75,6 +57,8 @@ Chetverikov, A.\*, Kosciessa, J. Q.\*, Cornelissen, M., Carpino, E., van der Zee +If you would like to assist the community development of this tool, please consider [CONTRIBUTING](CONTRIBUTING.md). + # License Released under GNU General Public License v3.0 (see LICENSE). diff --git a/configs/calibration_config.yaml b/configs/calibration_config.yaml index bf9d445c..c46de919 100644 --- a/configs/calibration_config.yaml +++ b/configs/calibration_config.yaml @@ -1,26 +1,12 @@ # The configuration is structured using YAML (https://yaml.org/). Note that YAML uses spaces, not tabs, for identation. - # Simulation paths (note: specific for calibration) [Change to your directories...] + ## Parameters for standalone calibration (using Donders equipment) + + # Simulation paths (no defaults; change to your directories...) path_input_axial: '//home/common/matlab/PRESTUS/Axial_profiles/' # Base path for axial profiles - path_input_phase: '//home/common/matlab/PRESTUS/Phase_tables/' # Base path for phase data - path_output: '/project/2425122.01/calibration/data/calibration/' # Folder path for saving simulation results - path_output_profiles: '/project/2425122.01/calibration/data/PRESTUS_virtual_parameters/' # Folder for saving optimized profile data - filename_calibrated_CSV: '' # Filename of calibrated CSV data (default: standalone script generates name based on equipment) + path_input_phase: '//home/common/matlab/PRESTUS/Phase_tables/' # Base path for phase data - # Internal settings - - submit_medium: 'slurm' # Simulation submit mode: matlab (debug; doesn't overwrite; force cpu mode), slurm (recommended), or qsub - save_in_calibration_folder: true # Boolean flag to save outputs in general output folder or simulation-specific folder. If TRUE, simulations are run in calibration output directory; results are appended to existing calibration for this equipment instead of overwritten. - skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts - optmethod: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) - weights: 1 # Weighting of the original profile during fitting (towards 0 = narrower Gaussian; 1 = equal weighting) - seed: 251024 # Random seed for optimization - addEPdistance: 1 # Append distance from transducer bowl to exit plane (if zero point in provided profiles = exit plane, != transducer bowl) - interpolateToEP: 1 # Interpolate values between bowl and exit plane [default: add initial value in empirical measurements] - force_kwavearray: 1 # Force to run free-water simulations with kwavearray? - axisymmetric2D: 0 # [EXPERIMENTAL; default: 0] Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency - - # List combinations of setups (e.g., transducer-TPO) and calibration targets. + # List combinations of setups (e.g., transducer-TPO) and calibration targets (no defaults) # Every [] specification will be performed for the corresponding (ordered) index equipment combination. # Example: all foci within the first [] will be executed for the first equipment combination. # If array is empty ([]), all focal depths of available characterization data will be used. @@ -35,12 +21,27 @@ - [35, 83] - [35, 83] - # Example for multiple equipment-depth-intensity combinations: - # combinations: - # - IS_PCD15287_01001_IGT_128_ch_comb_10_ch - # - IS_PCD15287_01002_IGT_128_ch_comb_10_ch - # focal_depths_wrt_exit_plane: - # - [85] - # - [30, 50] - # desired_intensities: - # - [30] \ No newline at end of file + # For curved transducers: are distances in empirical profiles provided from the exit plane? + add_FDO: 1 # Append Focal Distance Offset (distance from transducer bowl to exit plane). Use if zero point in provided profiles = exit plane, != transducer bowl. + + ## Parameters for calibration function + + # I/O + path_output: '/project/2425122.01/calibration/data/calibration/' # Folder for saving simulation results [Change to your directories...] + path_output_profiles: '/project/2425122.01/calibration/data/PRESTUS_virtual_parameters/' # Folder for saving optimized profile data [Change to your directories...] + save_in_calibration_folder: true # Boolean flag to save outputs in general output folder or simulation-specific folder (`parameters.calibration.path_output`). If TRUE, simulations are run in calibration output directory; results are appended to existing calibration for this equipment instead of overwritten. + filename_calibrated_CSV: '' # Filename of calibrated CSV data (default: standalone script generates name based on equipment) + + # Simulation submission overrrides + force_kwavearray: 1 # Force to run free-water simulations with kwavearray? + axisymmetric2D: 0 # Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency + + # Optimization + opt_method: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) + opt_weights: 0 # Weighting of the original profile during fitting (0 = uniform weighting, >1 increasingly narrow Gaussian FWHM) + opt_seed: 251024 # Random seed for optimization + opt_upper_velocity: 0.2 # Upper velocity in global search + fit_velocity_to_intensity: true # Correct velocity after global search to match desired peak intensity + + # Other + skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts in reporting \ No newline at end of file diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 5a77f41e..0c1e7654 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -8,102 +8,188 @@ # Defaults should be overwritten (where sensible or necessary) in a separate config.yaml file. #------------------------------------------------ -#------------ I/O management - -# Paths to the data location (please use full paths) -data_path: NA - -# Paths to T1 and T2 relative to data_path; all string substitutions will be done using subject_id -# t1_path_template: sub-%1$03d_orig_T1w.nii.gz -# t2_path_template: sub-%1$03d_orig_T2w.nii.gz - -# Should output be ordered based on subject id? -subject_subfolder: 1 - -# Optional affix for result files names -results_filename_affix: '' - -# This allows you to add additional paths and subpaths while running the pipeline -paths_to_add: [] -subpaths_to_add: [] -simnibs_bin_path: '' # Path to the SimNIBS environment (please use full paths) - -# In interactive mode (1), the figures are shown and confirmation dialogs appear (the latter of which will halt the pipeline until confirmation) -# In non-interactive mode (0), no plots are shown and no confirmation dialogs will -interactive: 0 -# Needs to be included if running in a non-interactive mode -overwrite_files: always # 'never' to never overwrite, 'always' to always overwrite, any other value to ask for confirmation -# By default, SimNIBS segmentation results are not overwritten, unless enabled here -overwrite_simnibs: 0 - -#------------ Simulation type +#------------ General + +subject_id: 0 # Participant ID (integer, used for path substitution in path.t1_pattern and path.t2_pattern) +platform: 'auto' # Execution platform: auto (recommended) | slurm | qsub | matlab (recommended for debugging) + +#------------ Simulation type & execution + +simulation: + medium: layered # select which medium setup to use: 'water' or 'layered' (a layered medium consists of several layers defined below) + code_type: matlab_gpu # Select which k-Wave code to run: + # MATLAB CPU code 'matlab_cpu' + # MATLAB GPU code 'matlab_gpu' + # C++ CPU code 'cpp_cpu' + # C++ GPU code 'cpp_gpu' + precision: 'single' # Computational precision for both acoustic and thermal simulations + interactive: 0 # Run in interactive mode (ask for user input when necessary, plot evolving figures) + debug: 1 # Activate debug mode with additional intermediate outputs? + +#------------ Paths + +path: + anat: '' # Location of the structural input data [full path] + sim: '' # Location of the simulation outputs [full path or folder name in path.anat] + seg: '' # Location of the segmentation directories (containing m2m folders) [full path] + t1_pattern: 'sub-%1$03d_T1w.nii*' # T1 image path template relative to path.anat; supports %03d-style subject ID substitution + t2_pattern: 'sub-%1$03d_T2w.nii*' # T2 image path template relative to path.anat + localite: '' # Path to Localite neuronavigation output folder + subject_subfolder: 1 # Should each participant's output be stored in a separate folder? + +#------------ Startup (environment & toolbox paths) + +startup: + simnibs_bin_path: '' # Path to the SimNIBS environment (please use full paths) + paths_to_add: [] # Additional paths to add with addpath() + subpaths_to_add: [] # Paths to add recursively with addpath(genpath()); relative to config file location -# select which medium setup to use: 'water' or 'layered' (a layered medium consists of several layers defined below) -simulation_medium: layered - -usepseudoCT: 0 # [Optional] use (p)CT (if available) to inform skull medium properties -pseudoCT_variant: kosciessa # pCT mapping algorithms (yaakub/carpino/k-plan/marquet/kosciessa [default]) - -# Only run specific components of the pipeline? -run_source_setup: 1 # Set up acoustic source? (switch off to look at initial segmentation results only) -run_acoustic_sims: 1 # Run the acoustic simulations? -run_heating_sims: 1 # Run the heating simulations? Run once acoustic simulation provides sensible results. -run_posthoc_water_sims: 1 # Run the simulations in free water with exactly the same parameter? Verify that free-water intensity matches target. -generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? - -# Select which k-Wave code to run (see 'run_simulations' for more documentation) -# MATLAB CPU code 'matlab_cpu' -# MATLAB GPU code 'matlab_gpu' -# C++ CPU code 'cpp_cpu' -# GPU code 'cpp_gpu' -code_type: matlab_gpu +#------------ I/O management -savemat: 0 # Save output matrices? -debug: 1 # Activate a debug mode in which more intermediate outputs are provided? +io: + output_affix: '' # Optional affix for output file names; differentiates outputs for the same subject/transducer + overwrite_files: always # 'never' to never overwrite, 'always' to always overwrite, any other value to ask for confirmation + overwrite_simnibs: 0 # By default, SimNIBS segmentation results are not overwritten, unless enabled here + save_matrices: 0 # Save output matrices? + save_heatingvideo: 0 # Save a video of incremental heating? + +#------------ Module execution + +modules: + run_grid_setup: 1 # Setup grid and run head processing? Mandatory for simulations. + run_medium_setup: 1 # Map medium acoustic properties? Mandatory for simulations. + run_source_setup: 1 # Set up acoustic source? Mandatory for simulations. + run_acoustic_sims: 1 # Run acoustic simulations? + run_acoustic_analysis: 1 # Run acoustic analysis? + run_heating_sims: 0 # Run thermal simulations? Default: off; run once acoustic simulation provides sensible results. + run_thermal_analysis: 1 # Run thermal analysis? + run_nifti_creation: 1 # Run nifti creation? + run_posthoc_water_sims: 1 # Run simulations in free water with identical setup? + generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? + segmentation_only: 0 # Stop after segmentation (skip grid setup and all simulations)? 1=yes, 0=no #------------ High-performance computing -hpc_submit_medium: 'matlab' # slurm (recommended) | qsub | matlab (default) -hpc_gpu: '' -hpc_partition: '' -hpc_reservation: '' +hpc: + gpu: '' + partition: '' + reservation: '' + wait_for_job: false + timelimit: '04:00:00' + memorylimit: 20 + ld_library_path: '' # [qsub] /opt/gcc/7.2.0/lib64 | [slurm]: /home/'group'/'user'/.conda/envs/simnibs_env/lib/python3.9/site-packages/simnibs/mesh_tools/cgal/../../external/lib/linux + job_prefix: 'PRESTUS' # Prefix for HPC job names + max_wait_checks: 540 # Maximum number of status checks when waiting for a job (1 check/20s β‰ˆ 3 hours) -# An optional path to LD_LIBRARY used during SimNIBS installation -# If you experience an `undefined symbol` error in `create_mesh_surf.cpython-39-x86_64-linux-gnu.so`, set either of the next lines to your LD_LIBRARY location -ld_library_path: '' # [qsub] /opt/gcc/7.2.0/lib64 | [slurm]: /home/'group'/'user'/.conda/envs/simnibs_env/lib/python3.9/site-packages/simnibs/mesh_tools/cgal/../../external/lib/linux +#------------ Target analysis -#------------ Target specification - -focus_area_radius: 5 # Radius of the target area around the focus in which the ISPPA is averaged for the outputs [mm] +analysis: + focus_area_radius: 5 # Radius of the target area around the focus in which the ISPPA is averaged for the outputs [mm] #------------ Transducer specification (no defaults) + transducer: n_elements: [] # Number of elements in the transducer [#] Elements_ID_mm: [] # Inner diameter of each element [mm] Elements_OD_mm: [] # Outer diameter of each element [mm] curv_radius_mm: [] # Radius of curvature of the bowl [mm] dist_to_plane_mm: [] # Distance to the transducer plane from the geometric focus [mm] - source_amp: [] # Pressure amplitude [Pa] - source_phase_deg: [] # Source phase [deg] + source_amp: [] # Pressure amplitude [Pa] + source_phase_deg: [] # Source phase [deg] source_freq_hz: [] # Fundamental frequency [Hz] trans_pos: [] # Transducer position [T1 grid space] focus_pos: [] # Stimulation target position[T1 grid space] -# Use the kwaveArray class for transducer modeling? -use_kWaveArray: 1 +#------------ Transducer Placement + +placement: + localite: + enabled: 0 # Should the script position_transducer_localite be used? + reference_distance_mm: 15 # Allows one to correct for varying distances between the infrared trackers attached to the transducer and the exit plane (can only be used with the function 'transducer_from_localite') + heuristic: + dist_close: [] + ear_radius: 35 + save_localite_t1: false # Save Localite-aligned T1 output for header correction? + left_ear_center: [] + right_ear_center: [] + criterion_intersection: 0.05 + criterion_skin_mean: [] + criterion_skull_mean: [] + criterion_skin_var: [] + criterion_skull_var: [] + expand_step: 0.01 + +#------------ Simulation grid + +grid: + resolution_mm: 0.5 # Grid resolution (must be isotropic) [mm] + default_dims: [144, 144, 400] # [points per dimension] [used for setting up free-water grid] + pml_size: 10 # Perfectly Matched Layer (PML) size [3D recommendation: 10] + source_ppw: [] # Points Per Wavelength [default: calculated internally] + source_cfl: 0.15 # Courant-Friedrichs-Lewy fraction + source_limit_fraction: 0.9 # Fraction of the stability limit to use for time step (0 = do not use stability limit) + max_expand: 40 # Prime number grid expansion size to (potentially) speed up computations + use_kWaveArray: 1 # Use the kwaveArray class for transducer modeling? + axisymmetric: 0 # Use 2D axisymmetric simulation mode? + +#------------ Segmentation + +segmentation: + use_qform: 0 # Force qform reorientation before charm segmentation? Set to 1 if charm reports qform/sform mismatch. + debug: 0 # Pass --debug flag to charm segmentation for verbose output? + +#------------ Head model processing + +headmodel: + head_pad_mm: 0 # Symmetric padding applied to the cropped head grid prior to transducer + PML setup [mm] + csf_expansion: 40 # Dilation of the CSF brain mask into surrounding head regions [grid voxels] + # Thresholds for smoothing masks (and acoustic properties) + smooth_method: 'gaussian' # Filter type ['gaussian' (default)|'box'] + smooth_threshold_skull: 0.5 # (higher threshold -> thinner mask) + smooth_threshold_other: 0.5 # (higher threshold -> thinner mask) + smooth_fwhm_mm: 1 # Smoothing FWHM [mm] + smooth_properties: false # Apply smoothing with parameters above to acoustic property maps? + # Settings for skull hole-filling + skull_fill_method: 'rubberwrap' # Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] + skull_wrap_radius: 10 # Rubber-wrap radius [grid voxels]; larger = tighter rubber ignoring bigger dents (recommendation: 2–10) + skull_wrap_visualize: 0 # Visualize rubber-wrap result? (Currently causes issues on HPC) + +#------------ (pseudo-)CT skull property mapping + +pct: + enabled: 0 # Use (p)CT (if available) to inform skull medium properties + mapping_density: 'k-plan' # pCT mapping algorithm for density: k-plan/k-wave/marsac/aubry + mapping_soundspeed: 'k-plan' # pCT mapping algorithm for sound speed: k-plan/marsac/aubry + mapping_attenuation: 'k-plan' # pCT mapping algorithm for attenuation: k-plan/mueller + +#------------ Simulation layers +# Assign tissue compartments to model, with their SimNIBS segmentation label indices. +# You can remove or add layers. If you want a skull-only simulation, remove brain and skin for example. + +layers: + water: [0, 3, 6, 9, 10] # this isn't really neccessary as water is a default layer - all that has a number not used for other layers will be assigned water properties + brain: [1, 2] + skin: [5] + skull: [4] + skull_cortical: [7] + skull_trabecular: [8] + +#------------ Sonication timing protocol (no defaults - must be specified for thermal simulations) + +timing: + pd: 0 # Pulse Duration (PD) [s] + pri: 0 # Pulse Repetition Interval (PRI) [s]; PRF = 1/pri + ptd: 0 # Pulse Train Duration (PTD) [s] + pt_timestep: 0.02 # Modelling time step within a pulse train [s] + ptri: 0 # Pulse Train Repetition Interval (PTRI) [s] + ptrd: 0 # Pulse Train Repetition Duration (PTRD) [s] + post_ptri_dur: 0 # Post-PTRI steady-state duration [s] + post_pt_timestep: 1 # Modelling time step following PT & PTRI [s] + equal_step_duration: 0 # Equal step durations for on and off cycles? 1=yes, 0=no + +#------------ Thermal parameters -#------------ Thermal parameters (no defaults for temporal protocol) thermal: - pd: 0 - pri: 0 - ptd: 0 - pt_timestep: 0.02 - ptri: 0 - ptrd: 0 - post_ptri_dur: 0 - post_pt_timestep: 1 - equal_step_duration: 0 - # Optional parameters: cem43_iso: 0 temp_0: #[deg C] initial temperature water: 37 @@ -115,50 +201,10 @@ thermal: sensor_xy_halfsize: 100 # maximum size of the sensor window in which the temperature is recorded (in grid units) record_t_at_every_step: 0 # should we record temperature for every time step _for the whole sensor window_ (memory intensive, turn off if you experience 'out of memory' issues) -heatingvideo: 0 # Save a video of incremental heating? - -#------------------------------------------------ -#------------ INTERNAL -------------------------- -#------------------------------------------------ - -#------------ Head segmentation - -# Segmentation software ('charm' is recommended, 'headreco' is deprecated) -segmentation_software: charm - -# Labels for layered simulation, assuming for now the SimNIBS masks indices; -# Note that the labels are applied in the order specified here, so the skull should normally be the last as it matters the most -# This labeling scheme assumes the outputs of charm in SimNIBS -# You can remove or add layers. If you want a skull-only simulation, you can remove brain and skin for example. - -# Assign multi-layer compartments to model, and indicate SimNIBS segmentation ids -layers: - water: [0, 3, 6, 9, 10] # this isn't really neccessary as water is a default layer - all that has a number not used for other layers will be assigned water properties - brain: [1, 2] - skin: [5] - skull: [4] - skull_cortical: [7] - skull_trabecular: [8] - -# Provide SimNIBS segmentation labels -seg_labels: - bonemask: [1,2,3,4,7,8,9] # include all media within the skull mask - external: [0] - wm: [1] - gm: [2] - csf: [3] - skull: [4] - skin: [5] - eye: [6] - skull_cortical: [7] - skull_trabecular: [8] - blood: [9] - muscle: [10] - #------------ Medium properties # References: ITRUSST benchmarks (https://arxiv.org/abs/2202.04552); Tissue Properties DB (https://itis.swiss/virtual-population/tissue-properties/overview/); # Note: skull attenuation and absorption values remain uncertain, use defaults with caution! -medium: +medium_properties: water: sound_speed: 1500 density: 994 @@ -212,50 +258,4 @@ medium: thermal_conductivity: 0.32 # [W/m/Β°C] specific_heat_capacity: 1313 # [J/kg/Β°C] perfusion: 10 - absorption_fraction: 0.28 - -#------------ Simulation grid - -grid_step_mm: 0.5 # Grid resolution (must be isotropic) [mm] - -# These only need to be set manually if the simulations are done for water only -default_grid_dims: [144, 144, 400] # [points per dimension] - -# select PML size, the default one for 3D is 10 (PML is the layer used in -# kWaves to absorb waves reaching the end of the computational grid, -# http://www.k-wave.org/documentation/example_na_controlling_the_pml.php) -pml_size: 10 - -# To what extent the computational grid can be expanded in order to get lower prime numbers to (potentially) speed up computations -grid_max_expand: 40 - -source_ppw: [] # Points Per Wavelength [default: calculated internally] -source_cfl: 0.15 # Courant-Friedrichs-Lewy fraction -source_limit_fraction: 0.9 # Fraction of the stability limit to use for time step (0 = do not use stability limit) - -precision: 'single' # Computational precision for both acoustic and thermal simulations - -#------------ Segmentation / Head preprocessing - -# Expansion factor for the cerebro-spinal fluid brain mask; -# if the mask ends up being too big, try reducing it -# (40 is the conservative default; the mask is dilated with csf_mask_expansion_factor/voxel_size_mm cubic kernel) -csf_mask_expansion_factor: 40 - -# Thresholds for smoothing the skull and other masks -smooth_method: 'gaussian' # Filter type ['gaussian' (default)|'box'] -smooth_threshold_skull: 0.5 # (higher threshold -> thinner mask) -smooth_threshold_other: 0.5 # (higher threshold -> thinner mask) -smooth_window: 5 # Number of voxels for smoothing - -# Settings for shrinkwrap (if requested) -skullfillmethod: 'imclose' # Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] -wrapradius: 10 # skull rubber wrap radius [grid voxels]; Bigger = tighter rubber that ignores bigger dents (recommendaton: 2 to 10) -skullwrap_visualize: 0 # Visualize results of skullwrap? (Currently causes issues on HPC) - -#------------ Transducer placement (WIP) - -# Should the script position_transducer_localite be used? -transducer_from_localite: 0 -# Allows one to correct for varying distances between the infrared trackers attached to the transducer and the exit plane -reference_transducer_distance_mm: 15 # (can only be used with the function 'transducer_from_localite') + absorption_fraction: 0.28 \ No newline at end of file diff --git a/configs/tmp_test_n-transducers.yaml b/configs/tmp_test_n-transducers.yaml deleted file mode 100644 index 36539989..00000000 --- a/configs/tmp_test_n-transducers.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# The configuration is structured using YAML (https://yaml.org/). -# Note that YAML uses spaces, not tabs, for indentation. - -# !!! Important !!! -# This file is a user config derived from default_config.yaml + base_config_BFTUS.yaml - -# Paths to the data location and SimNIBS environment (please use full paths) -data_path: /project/2425076.01/sims/data/ -seg_path: /project/2425076.01/mri/seg/ -simnibs_bin_path: /opt/simnibs/4.0.0/simnibs_env/bin/ - -# Paths to T1 and T2 relative to data_path; all string substitutions will be done using subject_id -t1_path_template: sub-%1$03d_T1.nii.gz -t2_path_template: sub-%1$03d_T2.nii.gz -# default examples in dev: -# t1_path_template: sub-%1$03d_orig_T1w.nii.gz -# t2_path_template: sub-%1$03d_orig_T2w.nii.gz - -simulation_medium: layered - -# Parameters differ for each transducer and can be obtained from the manufacturer -transducer: - - name: right - n_elements: 10 - Elements_ID_mm: [10.0, 22.1, 29.8, 36.0, 41.4, 46.3, 50.7, 54.9, 58.7, 62.4] - Elements_OD_mm: [21.1, 28.8, 35.0, 40.4, 45.3, 49.7, 53.9, 57.8, 61.5, 65.0] - curv_radius_mm: 100 - dist_to_plane_mm: 92.7 - source_amp: 200608 - source_phase_deg: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - source_freq_hz: 300e3 - trans_pos: [186, 209, 189] - focus_pos: [131, 185, 171] - - - name: left - n_elements: 10 - Elements_ID_mm: [10.0, 22.1, 29.8, 36.0, 41.4, 46.3, 50.7, 54.9, 58.7, 62.4] - Elements_OD_mm: [21.1, 28.8, 35.0, 40.4, 45.3, 49.7, 53.9, 57.8, 61.5, 65.0] - curv_radius_mm: 100 - dist_to_plane_mm: 92.7 - source_amp: 200608 - source_phase_deg: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - source_freq_hz: 300e3 - trans_pos: [42, 213, 189] - focus_pos: [131, 185, 171] - -# Should output be ordered based on subject id? -subject_subfolder: 1 - -# Optional affix for result files names -results_filename_affix: 'tmp_test_n-transducers' - -# Grid resolution (must be isotropic) -grid_step_mm: 0.9 # [mm] diff --git a/configs/tutorial_config.yaml b/configs/tutorial_config.yaml index 28b9c77b..49e3774a 100755 --- a/configs/tutorial_config.yaml +++ b/configs/tutorial_config.yaml @@ -1,13 +1,16 @@ -data_path: /home/.../Ernie_T1_T2/ # the folder with either a) t1 & t2 images, or b) t1 image and the segmentation results, e.g., Ernie subject from SimNIBS -simnibs_bin_path: /home/.../SimNIBS-4.0/bin/ # change to simnibs install +path: + anat: /home/.../Ernie_T1_T2/ # the folder with either a) t1 & t2 images, or b) t1 image and the segmentation results, e.g., Ernie subject from SimNIBS + t1_pattern: ernie_T1.nii.gz # path to T1 relative to path.anat; all string substitutions will be done using subject_id + t2_pattern: ernie_T2.nii.gz # path to T2 relative to path.anat -t1_path_template: ernie_T1.nii.gz # path to T1 relative to data_path; all string substitutions will be done using subject_id -t2_path_template: ernie_T2.nii.gz # path to T2 relative to data_path; all string substitutions will be done using subject_id +startup: + simnibs_bin_path: /home/.../SimNIBS-4.0/bin/ # change to simnibs install -# If you encounter the error "The qform and sform of do not match. Please run charm with the --forceqform option" when running charm, you can set this to 1 to do it for all subjects -use_forceqform: 0 +segmentation: + # If you encounter the error "The qform and sform do not match. Please run charm with the --forceqform option" when running charm, you can set this to 1 to do it for all subjects + use_qform: 0 -transducer: +transducer: n_elements: 4 # number of elements in the transducer Elements_ID_mm: [0, 32.9184, 46.1264, 56.0324] Elements_OD_mm: [32.3596, 45.5676, 55.5244, 64.008] @@ -19,11 +22,12 @@ transducer: trans_pos: [128, 139, 15] # position on T1 grid focus_pos: [128, 142, 75] # stimulation target on T1 grid -default_grid_dims: [144, 144, 256] +grid: + default_dims: [144, 144, 256] -thermal: +timing: pd: 0.3 - pri: 0.6 + pri: 0.6 ptd: 0.6 pt_timestep: 0.1 ptri: 1.2 diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md new file mode 100644 index 00000000..fbe33e28 --- /dev/null +++ b/documentation/CHANGELOG.md @@ -0,0 +1,122 @@ +# Changelog + +Notable changes to this project will be documented here. + +## `development` v0.5.0 [*unreleased*] + +Note: While intended to be largely backwards compatible, parameters, their naming, and default values have changed. Please consult the [documentation](https://donders-institute.github.io/PRESTUS/) for the current parameter specification. + +#### Added + +- [**feature**] C++ support by @jkosciessa +- [**feature**] Advanced logging & benchmarking + - Key parameters are displayed at onset of simulation + - PRESTUS and k-Wave commit version logged (git-only) +- [**feature**] html summary output by @sirmrmarty + - Acoustic properties will only be printed for modeled layers @jkosciessa +- [**feature**] Parallel multi-transducer support for layered simulation by @dreamstimlab (see [PR](https://github.com/Donders-Institute/PRESTUS/pull/100)) +- [**skull**] new option `parameters.rubberwrap` by @meijer-s (see [PR](https://github.com/Donders-Institute/PRESTUS/pull/103)) + - Locally inflate the skull mask. + - New default for the layered simulation and only option during pCT creation. The sequence of where this is implemented currently varies between layered/pCT. For pCT, the rubber expansion is implemented following segmentation. For layered, it follows medium map creation (i.e., following the smoothing of masks) to catch potential issues where smoothing and binarization may (re-)introduce holes. +- [**doc**] GitHub pages [documentation](https://donders-institute.github.io/PRESTUS/) + - Full parameter & function overview + - Installation Guide + - Quick Start Guide + - Example demos + - Draft of processing steps +- [**thermal**] More flexible and standardised protocol timing specification +- [**thermal**] Output plot of requested timing protocol +- [**thermal**] Additional thermal timeseries of max. in tissue and heating at focus +- [**thermal**] Heating timeseries are saved as .mat +- [**parameter**] I/O handling of intermediate outputs. `savemat` can be set to 0 to not save processing matrices (save disk space) +- [**parameter**] Dedicated `debug` parameter + - saves plots of postprocessing etc in debug folder of sim directory + - parameter overview when loading config (cf. pipeline onset) + - saves nifti images of raw acoustic properties + - currectly active by default + +#### Changed + +- [**refactor**] Pipeline and main processing steps refactored + - Functions organized by relevant processing stage + - main pipeline: 1150 lines -> 450 lines (incl. updated logging) +- [**calibration**] Dedicated config and unified pipeline for transducer calibration, new parameters. Function for calibration. [@MaCuinea, @jkosciessa] +- [**parameter**] Minor parameters changes: some were dropped, some renamed, others added, check the function doc if in doubt. default_config and documentation updated accordingly. Behaviour should be comparable to v0.4.0 +- [**pCT**] pCT creation updated to new folder structure and simplified based on @meijer-s implementation. +- [**savemat**] kwave source matrix saving now can be deactivated (as intended) +- [**feature**] updates to axisymmetry support +- [**advanced**] Updates to the sequential simulations with different transducer-target specs @Kenneth van der Zee +- [**preproc**] segmentation smoothing choices changed @jkosciessa +- [**preproc**] grid interpolation for continuous data changed @jkosciessa + +#### Fixed + +Not all (hot-)fixes are reported here. + +- [**calibration**] potential bugs with distance calculation +- [**savemat**] kwave source matrix saving now can be deactivated (as intended) +- [**savemat**] water simulations will force-save matrix outputs + +#### Deprecated/Removed + +- [**feature**] Removed explicit β€˜skull’-only simulations. These can be run by removing layers in the simulation setup. + +## v0.4.0 + +This release features major updates. Please consult the documentation for current parameter choices. Some features remain unstable and new bugs may pop up. Let's remain vigilant and try to quickly introduce hotfixes in those cases. + +- [**Feature**] Acoustic profiling (beta) @MaCuinea +- [**Feature**] pCT->acoustic skull medium incl. multiple mapping algorithms (beta, doc) @eleonoracarpino @jkosciessa +- [**Feature**] Run consecutive simulations @KTZ228 +- [**Feature**] GPU updates (doc) @jkosciessa +- [**Feature**] CEM43 ISO definition, see doc @jkosciessa +- [**Feature**] Modeling of absorption (as fraction of attenuation) and perfusion @jkosciessa. Be advised that this renders the heating estimates more liberal than in the past. See the documentation. Also see some comments in the issue. +- [**Feature**] axisymmetry simulations with 2D input grids (doc) @jkosciessa +- [**Feature**] simulations with computational phantoms (doc) @jkosciessa +- [**Documentation**] Initial draft of documentation (e.g., parameters) (see links above and here) @jkosciessa +- Many smaller bug fixes and QOL improvements (e.g., ability to disable .mat saving, more debugging outputs) +- DOC: minor typo in README.md by @mekman in #58 +- Run simnibs segmentation code also supports SLURM by @MaCuinea in #62 +- Prepare acoustic_profiling for merge by @MaCuinea in #69 +- Semi-automated acoustic profiling by @MaCuinea in #71 +- Added note to default config regarding ld_library_path in slurm by @KTZ228 in #70 +- Merge pull request #70 from KTZ228/development by @MaCuinea in #77 +- Fixed tutorial by @MaCuinea in #73 +- Feature: consecutive simulations by @KTZ228 in #79 +- Documentation, pCT, HCP updates & more, see above by @jkosciessa in #59 +- The last one summarizes more than half a year of updates to code and documentation. + +## v0.3.0 + +- PseudoCT feature by @eleonoracarpino and @jkosciessa +- Clean-up repository by @jkosciessa +- Incorrect allocation of attenuation values bug fix by @jkosciessa +- Improve correct labeling of tissue by @jkosciessa +- Improvement of 3D plots by @jkosciessa +- Check simulation stability and adjust time step accordingly by @MaCuinea and @jkosciessa +- Enable tissue-specific temp_0 specification by @jkosciessa +- Fix heating plot bug by @jkosciessa + +## v0.2.0 + +- DOC: add GNU license by @mekman in #17 +- Documentation changes and bug fixes by @KTZ228 in #14 +- Disabled masking of neural tissue in skin in smooth_and_crop_layered by @KTZ228 in #19 +- Adjusted create_Group_MNI_plots by @KTZ228 in #22 +- Documentation changes by @KTZ228 in #24 +- Minor changes to comments for heating setup by @jkosciessa in #29 +- Figure saving updates, some bug fixes by @jkosciessa in #8 +- Kenneth master by @KTZ228 in #33 +- Group plots fixes and improvements by @KTZ228 in #26 +- FIX: revert to working version by @mekman in #35 +- DOC: reworked installation instructions by @mekman in #36 + +## v0.1.0 + +Initial in-house development version by @achetverikov + +- Dedicated segmentation path option, minor bug fixes, updated simnibs call by @jkosciessa in #2 +- fix small errors by @jkosciessa in #3 +- adjusted skull values by @eleonoracarpino in #4 +- Bug fixes and documentation changes by @KTZ228 in #6 +- DOC: simplify readme.md wording regarding requirements by @mekman in #7 \ No newline at end of file diff --git a/documentation/CODE_OF_CONDUCT.md b/documentation/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d61d5ce3 --- /dev/null +++ b/documentation/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Contributor Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others’ private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces and also applies when an individual is officially representing the project in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers. All complaints will be reviewed and investigated promptly and fairly. + +All project maintainers are obligated to respect the privacy and security of the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html + +[homepage]: https://www.contributor-covenant.org diff --git a/documentation/CONTRIBUTING.md b/documentation/CONTRIBUTING.md new file mode 100644 index 00000000..1477d3be --- /dev/null +++ b/documentation/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to PRESTUS + +Thank you for your interest in contributing to the open-source development of PRESTUS! + +We welcome all types of contributions, including code submissions, documentation, tests, and feedback. + +PRESTUS emerged from the need for tailored simulation solutions in the realm of transcranial ultrasound stimulation (TUS) research. As TUS research advances, new equipment and experimental paradigm requirements continually arise, often outpacing the capabilities of existing simulation tools. PRESTUS was developed to address these challenges by providing a flexible set of functions for ultrasonic simulations. This adaptability allows researchers to efficiently prototype, test, and refine simulation workflows that are responsive to the unique and evolving demands of their studies. + +Your contributions help maintain and usefully expand this evolving tool for the non-invasive brain stimulation (NIBS) community. + +## Getting Started +- Fork the repository and clone it locally. +- Install dependencies as described in [README.md](README.md). +- Please review open issues and discussions to avoid duplicating work and to align with ongoing development. + +## Coding Standards + +PRESTUS is a research tool, and we strive for transparency, reproducibility, and clarityβ€”even if the workflows are evolving and at times built for specific use cases in scientific work. Please consider the following guiding principles: + +- **Motivation:** When proposing changes, open an issue or comment on an existing one to describe the scientific or technical motivation behind your contribution. +- **Documentation:** Clearly document any new features, changes, or known limitations directly in the code and in your pull request description. If your contribution addresses messy or ambiguous data, describe your approach and any assumptions made. +- **Reproducibility:** Strive to make your changes as reproducible as possible. If you use external data, scripts, or parameters, provide instructions or references so others can replicate your results. +- **Transparency:** If your solution involves workarounds, non-standard methods, or "messy" scientific practices, explain these choices in your code comments and pull request. This helps future users and contributors understand the context and rationale. +- **Code Quality:** Write clear, descriptive commit messages. Limit the impact of your updates to the target features/fixes, and minimize unintended effects on other workflows or users. +- **Backward Compatibility:** Remain backward-compatible where possible. If not feasible, document the necessary changes and impacts in your code, issue, and pull request. +- **Data Handling:** If your contribution involves new datasets or changes to data processing, include metadata and a description of data provenance, quality, and any preprocessing steps. + +## How to Submit Changes +- Create a new branch for your feature or fix. +- Ensure (to the best of your ability) that your features work as expected and the pipeline remains functional. Unit tests have not yet been implemented, so please verify your changes manually and describe your testing process. +- Submit a pull request to the `development` branch with a clear description. Before this pull request can be accepted, it will need to be reviewed by another contributor. +- [Optional] Attach a log or summary to the pull request, stating that the "pipeline has finished successfully" or describing any issues encountered. +- [Optional] Add yourself to the contributor list in the readme (you will automatically be recognized by GitHub's contribution tracker). Follow [these instructions](https://allcontributors.org/docs/en/bot/usage). + +## Code of Conduct +Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. + +## Contact +If you have questions, please [open an issue](https://github.com/Donders-Institute/PRESTUS/issues). diff --git a/documentation/PRESTUS_intro_tutorial.md b/documentation/PRESTUS_intro_tutorial.md deleted file mode 100644 index 932d187b..00000000 --- a/documentation/PRESTUS_intro_tutorial.md +++ /dev/null @@ -1,496 +0,0 @@ -# Table of contents -- [Simulations in free water](#simulations-in-free-water) -- [Optimize for a given distance and pressure](#optimize-for-a-given-distance-and-pressure) -- [Simulate again in water to check the optimization results](#simulate-again-in-water-to-check-the-optimization-results) -- [Simulations using skull and brain](#simulations-using-skull-and-brain) - -# A short introduction to PRESTUS - -PRESTUS is a package for acoustic and heating simulations built to simplify ultrasound neuromodulation studies, a wrapper for SimNIBS and k-Wave. This short demo shows how to use the package with the example subject data from SimNIBS. - -The example dataset that is referred to are the T1 and T2 images of 'Ernie' (m2m_Ernie > T1.nii.gz \& T2_reg.nii.gz) which can be found on the SimNIBS website ([https://simnibs.github.io/simnibs/build/html/dataset.html](https://simnibs.github.io/simnibs/build/html/dataset.html)). This tutorial can access them by specifying it's location, leaving them zipped and renaming it to sub-001_T1 and T2 respectively. - -Let's start with setting the environment. You'll need to change the path here to the directory you have PRESTUS in, and **also don't forget to change the paths in the config file (****`tutorial_config.yaml`****)**. - -```matlab:Code -clear -cd /home/neuromod/marcorn/Documents/PRESTUS % change path to demo data here - -submit_medium = 'slurm'; % run scripts via 'matlab' (debugging) or via a job using 'slurm' (recommended) or 'qsub' - -% add paths -addpath('functions') -addpath(genpath('toolboxes')) - -if strcmp(submit_medium, 'qsub') - addpath('/home/common/matlab/fieldtrip/qsub') % uncomment if you are using Donders HPC -end - -use_cpu = false; -if strcmp(submit_medium, 'matlab') - use_cpu = true; -end - -if gpuDeviceCount==0 && ~exist('/home/common/matlab/fieldtrip/qsub','dir') - warning('Many of the examples in this tutorial assume that you have a GPU available for computations or that you''re using the Donders HPC cluster. It looks like this is not the case. You can still run the tutorial but you''ll be automatically switched to using CPU which result in slow simulations.') - use_cpu = true; -end -``` - -# Simulations in free water - -Now, we start with the actual simulations. The first thing with the simulations is to ensure that you have the right settings. For the real US transducers, the settings are calibrated by the manufacturer. The company we buy the transducers from calibrates them to have a given maximum intensity and a given location of the acoustic peak (specifically, the center of its half-maximum range). - -Below, we read in two columns from the calibration sheet provided by the manufacturer. Specifically, we take the distance from the transducer axis and the intensity measured at each of these points. When using transducers with multiple elements, the depth of the acoustic peak (and thus the focus) can be steered by changing the set of phases for the individual transducer elements with respect to one-another. In this example, the intensity is taken from the column that set the phase angles to achieve a focus of 53mm in free water. - -```matlab:Code -real_profile = readmatrix('examples/acoustic_profile_tutorial.csv'); -desired_intensity = 30; - -dist_to_exit_plane = round(63.2-52.38); % from the transducer specifications - -figure('Position', [10 10 900 500]); -real_profile(:,1) = dist_to_exit_plane + real_profile(:,1); -plot(real_profile(:,1),real_profile(:,2)) -``` - -```text:Output -Warning: MATLAB has disabled some advanced graphics rendering features by switching to software OpenGL. For more information, click here. -``` - -```matlab:Code - -halfMax = (min(real_profile(:,2)) + max(real_profile(:,2))) / 2; -% Find where the data first drops below half the max. -index1 = find(real_profile(:,2) >= halfMax, 1, 'first'); -% Find where the data last rises above half the max. -index2 = find(real_profile(:,2) >= halfMax, 1, 'last'); - -flhm = real_profile(index2,1) - real_profile(index1,1); -flhm_center_x = (real_profile(index2,1) - real_profile(index1,1))/2+real_profile(index1,1); -flhm_center_intensity = real_profile(real_profile(:,1)==flhm_center_x, 2); -xlabel('Axial Position [mm]'); -ylabel('Intensity [W/cm^2]'); -yline(desired_intensity, '--'); -yline(halfMax, '--'); -xline(real_profile(index1,1), '--'); -xline(real_profile(index2,1), '--'); -xline(flhm_center_x,'r--'); -text(flhm_center_x+0.5, flhm_center_intensity+3, sprintf('FLHM center intensity %.2f [W/cm^2] at %i mm',flhm_center_intensity,flhm_center_x), "Color",'r'); -expected_focus = 60; -intensity_at_expected_focus = mean(real_profile(real_profile(:,1)>=59&real_profile(:,1)<=61,2)); -xline(expected_focus,'b--'); -text(expected_focus+0.5, intensity_at_expected_focus+4, sprintf('Expected focus intensity %.2f [W/cm^2] at %i mm',intensity_at_expected_focus,expected_focus),"Color",'b'); -[max_intensity, max_x] = max(real_profile(:,2)); -xline(real_profile(max_x,1),'--','Color','#7E2F8E'); -text(real_profile(max_x,1)+0.5, max_intensity+4, sprintf('Max intensity %.2f [W/cm^2] at %i mm',max_intensity,real_profile(max_x,1)),"Color",'#7E2F8E'); -``` - -![image](img/figure_0.png) - - -These settings are transformed into a set of phases for the individual transducer elements. Unfortunately, each transducer is unique, so the manufacturer's settings are not necessarily the ones you want to use in your simulations. The approach used here is to have an acoustic profile (i.e., the intensity along the beam axis of the transducer) so that the maximum point is at the expected distance and has the expected pressure. To have that, we start with the simulations in the water medium to find the phase-angles for the transducer elements needed for the simulation. - -The first step is to load the parameters. PRESTUS aims to separate the analysis and the configuration as much as possible, so most settings are kept in YAML format (https://en.wikipedia.org/wiki/YAML), a very simple markup language. The default settings are loaded from the `default_config.yaml` (all configs are kept in the `configs` folder), on top of which you need to load your own study-specific settings. For this tutorial, the settings are kept in `tutorial_config.yaml`. You also need to set a subject number, and for this tutorial there is only one subject. And that's it! You can then start the simulations by running the `single_subject_pipeline()` function.` ` - -```matlab:Code -parameters = load_parameters('tutorial_config.yaml'); % load the configuration file - -parameters.simulation_medium = 'water'; % indicate that we only want the simulation in the water medium for now -if use_cpu - parameters.code_type = 'matlab_cpu'; -end - -subject_id = 1; % subject id doesn't matter here as we use the brain of Ernie from SimNIBS example dataset and the paths to T1/T2 files are hardcoded in the tutorial config; for the real analysis one usually uses templates based on subject ID, see example in the default config. -% Start the simulations - uncomment the next line if you haven't run them yet - -if strcmp(submit_medium, 'qsub') - % If you are using the Donders HPC cluster, you can do the simulations in - % a non-interactive session with a qsub. To do so, set the interactive flag - % to zero, set overwrite_files to 'always' (if you already have the results and want to recompute them), and run single_subject_pipeline_with_qsub. - - parameters.interactive = 0; - parameters.overwrite_files = 'always'; - - single_subject_pipeline_with_qsub(subject_id, parameters); -elseif strcmp(submit_medium, 'slurm') - parameters.interactive = 0; - parameters.overwrite_files = 'always'; - - single_subject_pipeline_with_slurm(subject_id, parameters); -else - single_subject_pipeline(subject_id, parameters); -end -``` - -```text:Output -Submitted the job to the cluster with a command -cd /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs; sbatch /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs/tp29b25b1c_d955_4269_a33a_dfd1b0f9c0d8.sh -See logs in /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs in case there are errors. -Job name: PRESTUS_sub-001; job ID: 46914976 -Job submitted successfully -Continuing with the MATLAB script... -``` - -```matlab:Code - -``` - -After the simulations have finished, you can load the results. The results are saved under `sim_outputs` subdirectory of the data path that is defined in the config file along with some plots and summary statistics. - -The data on pressure is saved in the `sensor_data `structure. First, we will plot a 2d pressure map by slicing the 3d pressure matrix at the focal plane. - -```matlab:Code -% load results -outputs_folder = sprintf('%s/sim_outputs/sub-%03d', parameters.data_path, subject_id); - -load(sprintf('%s/sub-%03d_water_results%s.mat', outputs_folder, subject_id, parameters.results_filename_affix),'sensor_data','parameters'); -``` - -```text:Output -Warning: Unable to load gpuArray data onto a GPU. Computations cannot be performed on this limited gpuArray. Use GATHER to retrieve the contents. -Warning: Unable to load gpuArray data onto a GPU. Computations cannot be performed on this limited gpuArray. Use GATHER to retrieve the contents. -``` - -```matlab:Code - -% get maximum pressure -p_max = gather(sensor_data.p_max_all); % transform from GPU array to normal array - -% plot 2d intensity map -imagesc((1:size(p_max,1))*parameters.grid_step_mm, ... - (1:size(p_max,3))*parameters.grid_step_mm , ... - squeeze(p_max(:,parameters.transducer.trans_pos(2),:))') -axis image; -colormap(getColorMap); -xlabel('Lateral Position [mm]'); -ylabel('Axial Position [mm]'); -axis image; -cb = colorbar; -title('Pressure for the focal plane') -``` - -![image](img/figure_1.png) - -Then, we can compare the simulated pressure along the focal axis and the pressure estimated with an analytic solution based on the equations provided by O'Neil (O'Neil, H. Theory of focusing radiators. J. Acoust. Soc. Am., 21(5), 516-526, 1949) and implemented in k-wave `focusedAnnulusONeil()` function. - -```matlab:Code -% simulated pressure along the focal axis -pred_axial_pressure = squeeze(p_max(parameters.transducer.trans_pos(1),parameters.transducer.trans_pos(2),:)); % get the values at the focal axis - -% compute O'Neil solution and plot it along with comparisons -% define transducer parameters - -velocity = parameters.transducer.source_amp(1)/(parameters.medium.water.density*parameters.medium.water.sound_speed); % [m/s] - -% define position vectors -axial_position = (1:parameters.default_grid_dims(3))*0.5; % [mm] - -% evaluate pressure analytically -% focusedAnnulusONeil provides an analytic solution for the pressure at the -% focal (beam) axis -[p_axial_oneil] = focusedAnnulusONeil(parameters.transducer.curv_radius_mm/1e3, ... - [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm]/1e3, repmat(velocity,1,parameters.transducer.n_elements), ... - parameters.transducer.source_phase_rad, parameters.transducer.source_freq_hz, parameters.medium.water.sound_speed, ... - parameters.medium.water.density, (axial_position-0.5)*1e-3); - -% plot focal axis pressure -figure('Position', [10 10 900 500]); - -plot(axial_position, p_axial_oneil .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -xlabel('Axial Position [mm]'); -ylabel('Intensity [W/cm^2]'); -hold on -plot(axial_position-(parameters.transducer.trans_pos(3)-1)*0.5, pred_axial_pressure.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4,'--'); -plot(real_profile(:,1),real_profile(:,2)) -hold off -xline(parameters.expected_focal_distance_mm, '--'); -legend('Analytic solution','Simulated results','Real profile') -title('Pressure along the beam axis') -``` - -![image](img/figure_2.png) - -```matlab:Code -% what is distance to the maximum pressure? -fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n',axial_position(p_axial_oneil==max(p_axial_oneil))) -``` - -```text:Output -Estimated distance to the point of maximum pressure: 66.50 mm -``` - -We want the simulated results to match the real profile as closely as possible, but as you can see that the two lines do not match exactly. There are two ways to deal with it, either you can use a newer kwaveArray class from k-wave (by setting `use_kWaveArray` flag in the config to 1) or you can introduce a correction factor as we do here. The kwaveArray solution is in theory better, but in practice it requires more time and memory. So for this tutorial, we will use a correction factor. - -```matlab:Code -% compute the approximate adjustment from simulated (on a grid) to analytic solution -simulated_grid_adj_factor = max(pred_axial_pressure(:))/max(p_axial_oneil(:)); -``` - -# Optimize for a given distance and pressure - -So how to find the settings for the simulations that match the desired pressure and distance? It is easy to do, given that there is an analytic solution. For our subject, we assume that we know where the transducer is positioned and where we want to have the maximum pressure, so we know the distance at which the pressure should be maximal. We need to find the set of phases for transducer elements that would give the maximum pressure at the distance given in the manufacturers calibration sheet. We do so by searching through the parameter space (that is, varying the phases) as to minimize the error in distance to maximum pressure point. - -```matlab:Code -gs = GlobalSearch; -%opt_velocity = desired_pressure/max_pressure*velocity; - -%optimize_phases = @(phases) phase_optimization_annulus(phases, parameters, velocity, axial_position, parameters.expected_focal_distance_mm); -optimize_phases = @(phases_and_velocity) phase_optimization_annulus_full_curve(phases_and_velocity(1:(parameters.transducer.n_elements - 1)), parameters, phases_and_velocity(parameters.transducer.n_elements),... - real_profile(:,1), real_profile(:,2)); - -rng(195,'twister') % setting seed for consistency -problem = createOptimProblem('fmincon','x0', [randi(360, [1 parameters.transducer.n_elements - 1])/180*pi velocity],... - 'objective',optimize_phases,'lb',zeros(1,parameters.transducer.n_elements),'ub',[2*pi*ones(1,parameters.transducer.n_elements - 1) 0.2], 'options', optimoptions('fmincon','OptimalityTolerance', 1e-8)); - -[opt_phases_and_velocity, min_err] = run(gs,problem); -``` - -```text:Output -GlobalSearch stopped because it analyzed all the trial points. - -The local solver ran once and it converged with a positive local solver exitflag. -``` - -```matlab:Code -% plot optimization results -phase_optimization_annulus_full_curve(opt_phases_and_velocity(1:(parameters.transducer.n_elements - 1)), parameters, opt_phases_and_velocity(parameters.transducer.n_elements),... - real_profile(:,1), real_profile(:,2), 1); -``` - -```text:Output -Warning: Ignoring extra legend entries. -``` - -![image](img/figure_3.png) - - -```matlab:Code - -fprintf('Optimal phases: %s deg.; velocity: %.2f; optimization error: %.2f', mat2str(round(opt_phases_and_velocity(1:(parameters.transducer.n_elements - 1))/pi*180)), opt_phases_and_velocity(parameters.transducer.n_elements), min_err) -``` - -```text:Output -Optimal phases: [293 259 251 312 0 360 0 227 151] deg.; velocity: 0.08; optimization error: 0.04 -``` - -The left plot above shows the real and the fitted profiles along with the cost function used for fitting, while the right shows the error in fitting (the squared difference between the real and the fitted profile weighted by the cost function). - -The phase-angles given above should be copied into the config under the parameter "source_phase_deg". Keep in mind that different settings should be calculated and used for every transducer and every focus distance. - -We also need to know the pressure for the simulated transducer so that in the water, the intensity (ISPPA) would be 30 W/cm^2. To do so, we first compute the analytic solution with the new phases but the original pressure, and then use it to adjust the simulated pressure and recompute the analytic solution (the latter is just for plotting). - -```matlab:Code -opt_phases = opt_phases_and_velocity(1:(parameters.transducer.n_elements - 1)); -opt_velocity = opt_phases_and_velocity(parameters.transducer.n_elements); - -[p_axial_oneil_opt] = focusedAnnulusONeil(parameters.transducer.curv_radius_mm/1e3, ... - [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm]/1e3, repmat(opt_velocity,1,parameters.transducer.n_elements), ... - [0 opt_phases], parameters.transducer.source_freq_hz, parameters.medium.water.sound_speed, ... - parameters.medium.water.density, (axial_position-0.5)*1e-3); - -figure('Position', [10 10 900 500]); -plot(axial_position, p_axial_oneil.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -xlabel('Axial Position [mm]'); -ylabel('Intensity [W/cm^2]'); -hold on -plot(axial_position, p_axial_oneil_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -plot(real_profile(:,1),real_profile(:,2)) -hold off -xline(parameters.expected_focal_distance_mm, '--'); -yline(30, '--'); -legend('Original simulation', sprintf('Optimized to match the real profile'),'Real profile') -title('Pressure along the beam axis') -``` - -![image](img/figure_4.png) - - -```matlab:Code - -fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n',axial_position(p_axial_oneil_opt==max(p_axial_oneil_opt))) -``` - -```text:Output -Estimated distance to the point of maximum pressure: 56.00 mm -``` - -```matlab:Code -fprintf('Estimated distance to the center of half-maximum range: %.2f mm\n', get_flhm_center_position(axial_position, p_axial_oneil_opt)) -``` - -```text:Output -Estimated distance to the center of half-maximum range: 68.25 mm -``` - -# Simulate again in water to check the optimization results - -Now we will redo the simulations with new parameters. We copy the configuration, update the settings with the optimized parameters, and rerun the simulations. - -```matlab:Code -opt_source_amp = round(opt_velocity/velocity*parameters.transducer.source_amp/simulated_grid_adj_factor); - -opt_parameters = load_parameters('tutorial_config.yaml'); -opt_parameters.transducer.source_amp = opt_source_amp; -opt_parameters.transducer.source_phase_rad = [0 opt_phases]; -opt_parameters.transducer.source_phase_deg = [0 opt_phases]/pi*180; -opt_parameters.results_filename_affix = '_optimized'; -opt_parameters.simulation_medium = 'water'; - -if use_cpu - opt_parameters.code_type = 'matlab_cpu' -end - -if strcmp(submit_medium, 'qsub') - % If you are using the Donders HPC cluster, you can do the simulations in - % a non-interactive session with a qsub. To do so, set the interactive flag - % to zero, set overwrite_files to 'always' (if you already have the results and want to recompute them), and run single_subject_pipeline_with_qsub. - opt_parameters.interactive = 0; - opt_parameters.overwrite_files = 'always'; - single_subject_pipeline_with_qsub(subject_id, opt_parameters); -elseif strcmp(submit_medium, 'slurm') - opt_parameters.interactive = 0; - opt_parameters.overwrite_files = 'always'; - single_subject_pipeline_with_slurm(subject_id, opt_parameters); -else - single_subject_pipeline(subject_id, opt_parameters) -end -``` - -```text:Output -Submitted the job to the cluster with a command -cd /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs; sbatch /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs/tpad6599f1_223c_486a_ba8e_813abe67d692.sh -See logs in /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs in case there are errors. -Job name: PRESTUS_sub-001; job ID: 46914978 -Job submitted successfully -Continuing with the MATLAB script... -``` - -After the simulations are complete, we plot them alongside the analytic solution. - -```matlab:Code -opt_res = load(sprintf('%s/sub-%03d_water_results%s.mat', outputs_folder, subject_id, opt_parameters.results_filename_affix),'sensor_data','parameters'); -``` - -```text:Output -Warning: Unable to load gpuArray data onto a GPU. Computations cannot be performed on this limited gpuArray. Use GATHER to retrieve the contents. -Warning: Unable to load gpuArray data onto a GPU. Computations cannot be performed on this limited gpuArray. Use GATHER to retrieve the contents. -``` - -```matlab:Code - -% get maximum pressure -p_max = gather(opt_res.sensor_data.p_max_all); -pred_axial_pressure_opt = squeeze(p_max(opt_res.parameters.transducer.trans_pos(1), opt_res.parameters.transducer.trans_pos(2),:)); - -figure('Position', [10 10 900 500]); -hold on -plot(axial_position, p_axial_oneil.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -xlabel('Axial Position [mm]'); -ylabel('Intensity [W/cm^2]'); -plot(axial_position, p_axial_oneil_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); - -sim_res_axial_position = axial_position-(opt_res.parameters.transducer.trans_pos(3)-1)*0.5; % axial position for the simulated results, relative to transducer position -plot(sim_res_axial_position, ... - pred_axial_pressure_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -plot(real_profile(:,1),real_profile(:,2)) -hold off -xline(opt_res.parameters.expected_focal_distance_mm, '--'); -yline(desired_intensity, '--'); -legend('Original simulation', sprintf('Optimized for %2.f mm distance, analytical', opt_res.parameters.expected_focal_distance_mm), ... - sprintf('Optimized for %2.f mm distance, simulated', opt_res.parameters.expected_focal_distance_mm),'Real profile','Location', 'best') -``` - -![image](img/figure_5.png) - - -```matlab:Code -fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n',sim_res_axial_position(pred_axial_pressure_opt==max(pred_axial_pressure_opt))) -``` - -```text:Output -Estimated distance to the point of maximum pressure: 67.00 mm -``` - -As you can see, now the simulated acoustic profile has the desired distance to maximum and intensity, which means we can start with the simulations for the actual brain. - -# Simulations using skull and brain - -The first part repeats what we have done before: the parameters are loaded and the settings are updated. - -```matlab:Code -opt_parameters = load_parameters('tutorial_config.yaml'); - -opt_parameters.transducer.source_amp = opt_source_amp; -opt_parameters.transducer.source_phase_rad = [0 opt_phases]; -opt_parameters.transducer.source_phase_deg = [0 opt_phases]/pi*180; -opt_parameters.results_filename_affix = '_optimized'; -``` - -Now, at the next step we set the simulation medium to `"layered"` to indicate that we would use the actual brain image and we want to have different layers, such as skin, skull, and brain. These layers will be extracted from the T1 and T2 scans by SimNIBS during the initial processing, and then they will be used to create a heterogeneous simulations medium. - -```matlab:Code -opt_parameters.simulation_medium = 'layered'; % see default config for the list of mediums possible - -opt_parameters.run_heating_sims = 1; % this indicates that we want the heating simulations as well - -if use_cpu - opt_parameters.code_type = 'matlab_cpu' -end - -% Again, if you want to rerun the simulations, you can do it in an -% interactive mode (useful when running on your laptop) or -% non-interactively (useful at the cluster) with - -if strcmp(submit_medium, 'qsub') - opt_parameters.interactive = 0; - opt_parameters.overwrite_files = 'always'; - - single_subject_pipeline_with_qsub(subject_id, opt_parameters); -elseif strcmp(submit_medium, 'slurm') - opt_parameters.interactive = 0; - opt_parameters.overwrite_files = 'always'; - single_subject_pipeline_with_slurm(subject_id, opt_parameters); -else - % or with - single_subject_pipeline(subject_id, opt_parameters); -end -``` - -```text:Output -Submitted the job to the cluster with a command -cd /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs; sbatch /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs/tp78ca0690_6e35_47f1_a777_9a01e40991b3.sh -See logs in /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs in case there are errors. -Job name: PRESTUS_sub-001; job ID: 46915015 -Job submitted successfully -Continuing with the MATLAB script... -``` - -The simulations will take a long time. As a result, there will be some plots and matlab outputs in the `sim_outputs` folder. Let's look at some of them. - -This is the original T1 file superimposed with the predicted intensity. The transducer is shown in green and the intended stimulation location in red. Note that this is the slice in the plane along the focal axis. We are slightly undershooting the target here. - -```matlab:Code -imshow(imread(sprintf('%s/sub-%03d/sub-%03d_layered_isppa%s.png', opt_parameters.temp_output_dir , subject_id, subject_id, '_optimized'))) -``` - -![image](img/figure_6.png) - -A slight undershoot is also visible in free water. In this image, the yellow outline shows the transducer, the red point is the expected focus position and the blue one is the maximum focus position in the simulation. - -```matlab:Code -imshow(imread(sprintf('%s/sub-%03d/sub-%03d_water_isppa%s.png', opt_parameters.temp_output_dir , subject_id, subject_id, '_optimized'))) -``` - -![image](img/figure_7.png) - - -You can also look at the heating simulations results: - -```matlab:Code -imshow(imread(sprintf('%s/sub-%03d/sub-%03d_layered_heating_by_time%s.png',opt_parameters.temp_output_dir , subject_id, subject_id, '_optimized'))) -``` - -![image](img/figure_8.png) - -Here the lines show different voxels along the focal axis. Importantly, we see that with the current design, the temperature rises continuously for some voxels (check the video file in the `sim_outputs` folder as well), suggesting that one might need to introduce longer breaks to avoid excessive heating. diff --git a/documentation/README.md b/documentation/README.md index d6ace34a..f5d1a478 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -1,37 +1,43 @@ -# How to run a simulation +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.15095860.svg)](https://doi.org/10.5281/zenodo.15095860) -The main function is single_subject_pipeline, which takes the subject ID and parameter structure as an input. The parameters are taken from the config files in the configs directory, see the configs in this folder for the information on configurable fields. Parameters include `results_filename_affix` field that can define a condition (e.g., stimulation site, protocol, or something else) in case several simulations are to be started for a given subject. +![PRESTUS logo](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/logo_PRESTUS.png) -For each subject, two files are needed to start, T1 and T2 scans. These files should allow identifying a subject based on the T1 & T2 filename templates set in the config files. These can include wildcards (\* - stands for any symbols repeated 0 or more times, like in bash or MATLAB dir command to be precise) and string substitution patterns (only `subject_id` is used for them for now). Put these files in the folder defined in data_path field in the configuration. If you want to use Localite data, you can either use an instrument marker file (put it in the same folder, add the subject id to it so that the `localite_instr_file_template` from the config file could be parsed) or trigger markers file, which you would need to preprocess for each subject (see example in `example_pipeline_sjoerd.m`). +# PRESTUS: PREprocessing & Simulations for Transcranial Ultrasound Stimulation -On the first run, it is recommended to start the scripts on a local computing node (i.e., without qsub) and without a GPU. Then (if things are good), the segmentation will be started for each subject. They take ~4 hours. After they are completed, start the jobs again, now remotely and with the GPU. This should ideally result in multiple files for each subject/condition in your `sim_outputs` folder. Intermediate plots and files are also created there. You can then run extra post-processing steps if needed. +PRESTUS (PREprocessing & Simulations for Transcranial Ultrasound Stimulation) is an open-source MATLAB toolbox that aims to streamline imaging-informed simulations of Transcanial Ultrasound Stimulation (TUS): from the segmentation of T1-weighted MRI head scans, [preprocessing](doc_preproc.md) of their tissue layers, and mapping of [acoustic tissue properties](doc_medium.md) (possibly informed for skull via [(pseudo-)CT](doc_pseudoCT.md) images) in a simulation grid, to the execution of [acoustic](doc_simulations-acoustic.md) and [thermal](doc_simulations-thermal.md) simulations using the widely adopted [k-Wave engine](doc_backend.md). High-performance computing ([HPC](doc_hpc.md)) support via SLURM and CentOS enables efficient parallelization, and large-scale analyses. Output 3D NifTI images are automatically mapped to a standard template (MNI) space to facilitate [group](doc_group.md) reporting. -# Processing steps +Key features of the modular end-to-end pipeline include: -The main pipeline depends on whether you set `water` or `layered` as the simulations medium. +- Automated MRI segmentation (using SimNIBS 4 charm) and [preprocessing](doc_preproc.md). +- 2D / 3D grid setup. +- Multi-layer [medium property mapping](doc_medium.md) (water, skin, multi-layer skull, and brain). +- [(pseudo-)CT](doc_pseudoCT.md)-informed continuous skull mapping. +- Virtual multi-element [transducer calibration](doc_calibration.md) (free-water profile emulation). +- Estimation of entry-target [coordinates](doc_placement.md) incl. [heuristic placement](doc_placement_heuristic.md) and [neuronavigation](doc_placement_neuronav.md) read-in. +- Flexible [temporal protocol specification](doc_simulations-thermal.md) (e.g., including breaks). +- k-Wave integration for robust [acoustic](doc_simulations-acoustic.md) and [thermal](doc_simulations-thermal.md) simulations. +- Support for [high-performance computing](doc_hpc.md). +- 3D NifTI [outputs](doc_outputs.md) for major reporting metrics (in subject- & MNI-space). -The first big part is to get the transducer and the focus positions and a segmentated head image if needed. +These features make PRESTUS an accessible tool for researchers to plan, validate, and report transcranial ultrasound targeting. PRESTUS is intended solely for basic research use in non-clinical applications. -For `layered`: -1) the brain is segmented with SimNIBS -2) the segmented image is rotated so that the focal axis is aligned with the z-axis -3) the segmented image is upsampled to match the simulations grid voxel size -4) the layers in the segmented image are smoothed, the gaps between skin and skull filled, the holes in the skull closed -5) the segmented image is cropped (based on the expanded cerebro-spinal fluid mask to get rid of part of the neck bones) +A recent overview poster can be found [here](https://jkosciessa.github.io/downloads/2025-FUN25-PRESTUS.pdf). -Steps 2&3 are done simultaneously to avoid the need to interpolate the image twice. -The simulation grid is then created based on the segmented image size (with padding), and the transducer and the focus positions are computed based on the transformed coordinate system. +# Installation -For water-only simulations: -The simulation grid dimensions are taken from the parameter structure. The transducer and the focus positions are either taken from the same structure or, if they are missing there, computed so that transducer is at the z-axis and the focus is on the same axis with the distance based on the expected_focal_distance_mm field in the parameters. +Follow the [Installation Guide](doc_installation.md) and continue with the [Quick Start Guide](doc_getting-started.md). -After that things are straightforward: k-Wave medium, source, grid, and sensor are set up and the simulations are started. When they are completed, the maximum ISPPA map is computed and the maximum pressure and intensity points are estimated for different masks. The heating simulations can be enabled by setting `thermal_simulation` to 1. If `run_posthoc_water_simulations` is set in parameters, then after the layered simulations, the same simulation parameters are used to run the simulations for water only. +### Versions -## Supported simulation setups +PRESTUS is under active development. The default `main` branch is considered more stable. The [`development` branch](https://github.com/Donders-Institute/PRESTUS/tree/development) may contain both new features and stability updates, but is subject to more dynamic changes. [Releases](https://github.com/Donders-Institute/PRESTUS/releases) bundle major updates and are associated with a version number. Starting in PRESTUS v0.5 logs will print the git hash when PRESTS has been cloned as a git repository. -- `layered`: the simulation medium varies in terms of water, brain, skin and skull, with potential subdivision into cortical bone (`skull_cortical`) and trabecular bone (`skull_trabecular`) -- `layered`+`pseudoCT`: acoustic properties of bone are based on pseudo-Hounsfield units -- `water`: homogeneous water medium -- `brain`: homogeneous brain medium -- `water_and_skull`: homogeneous water medium + skull -- `brain_and_skull`: homogeneous water medium + brain; note that brain medium is also assumed outside the skull \ No newline at end of file +# License + +Released under GNU General Public License v3.0 (see LICENSE). + +> **Disclaimer** +> This software is currently under development and is provided β€œas is” without warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. This tool is intended for research purposes only and is not designed, intended, or approved for medical or clinical use, diagnosis, or treatment of patients. The contributors accept no liability for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) arising in any way out of the use of this software. Use at your own risk. + +# Development + +If you would like to assist the community development of this tool, please consider [CONTRIBUTING](CONTRIBUTING.md). \ No newline at end of file diff --git a/documentation/doc_advanced.md b/documentation/doc_advanced.md index 5d72a9f3..1177a234 100644 --- a/documentation/doc_advanced.md +++ b/documentation/doc_advanced.md @@ -1,32 +1,32 @@ -## Advanced workflows +## Advanced Workflows -#### Iterating input parameters +#### Iterating Parameters The base configuration of PRESTUS allows to specify a single setup, encompassing settings such as transducer specification, entry-target points, and temporal sequence characteristics. It may also be of interest to explore the effects of a range of different parameter settings on an outcome, for instance for benchamrking, but also for choosing a suitable stimulation sequence. -A general strategy toward such parameter iteration is to work on the basus of a single study setup configuration (as described in doc_config), whose values are iteratively overwritten in a MATLAB script. +A general strategy toward such parameter iteration is to work on the basus of a single study setup configuration (as described in the [Quick Start Guide](doc_getting-started.md)), whose values are iteratively overwritten in a MATLAB script. -For many parameters, this can simply be done by specifying different values for ```parameters.<>```. One exception is the intensity and depth setting of the transducer output, as these depend on the internal transducer calibration. PRESTUS by default provides a transducer calibration ("profiling"). This should in general be used for a given depth and free water intensity. However, this may not provide sufficient flexibility for exploring different output amplitudes. For that reason, PRERSTUS also provides the function ```transducer_calibration```, which can be used within an interative MATLAB loop to find suitable transducer phase and amplitude settings that closely replicate the desired free water output profile. +For many parameters, this can simply be done by specifying different values for `parameters.<>`. One exception is the intensity and depth setting of the transducer output, as these depend on the internal transducer calibration. PRESTUS by default provides a transducer calibration ("profiling"). This should in general be used for a given depth and free water intensity. However, this may not provide sufficient flexibility for exploring different output amplitudes. For that reason, PRERSTUS also provides the function `transducer_calibration`, which can be used within an interative MATLAB loop to find suitable transducer phase and amplitude settings that closely replicate the desired free water output profile ([see transducer calibration](doc_calibration.md)). -#### Sequential simulations +#### Sequential Simulations -Applicable when you stimulate from different coordinates in sequence (for stimulation in parallel, see 'Modeling multiple transducers'). Instead of starting each heating simulation with the default starting temperatures, you can start your nth stimulation with the temperature and CEM43 maps from the previous simulation. To do this, you only need to feed the pipeline your other configs. +Applicable when you stimulate from different coordinates in sequence (for stimulation in parallel, see [multi-transducer modeling](#multi-transducer-modeling)). Instead of starting each heating simulation with the default starting temperatures, you can start your nth stimulation with the temperature and CEM43 maps from the previous simulation. To do this, you need to feed the pipeline your other configurations. Example: Since each config is a structure, you can easily place multiple configs in one structure without any converting. Let's say you have config_1, config_2 and config_3 and you want to run them in sequence. If you want the heatmaps to not carry over, you would run your pipeline like this: -`single_subject_pipeline(subject_id, config_1)` -`single_subject_pipeline(subject_id, config_2)` -`single_subject_pipeline(subject_id, config_3)` +`prestus_pipeline_start(subject_id, config_1)` +`prestus_pipeline_start(subject_id, config_2)` +`prestus_pipeline_start(subject_id, config_3)` But now, you will also feed it the configs for each subsequent simulation: -`sequential_configs.config_2 = config_2` -`sequential_configs.config_3 = config_3` -`single_subject_pipeline(subject_id, config_1, 'sequential_configs', sequential_configs)` +`sequential_configs.config_2 = config_2` +`sequential_configs.config_3 = config_3` +`prestus_pipeline_start(subject_id, config_1, 'sequential_configs', sequential_configs)` -Please note that you have to use the names `config_x` in the sequential_configs, and that you have to use integers. So names like 'config_-5', 'config_0', 'config_1234' and 'config_007'. +Please note that you have to use the names `config_x` in the sequential_configs, and that you have to use integers. So names like `config_-5`, `config_0`, `config_1234` and `config_007`. ``` parameters.adopted_heatmap | path to nifti file @@ -34,7 +34,9 @@ parameters.adopted_cumulative_heat | path to nifti file options.sequential_configs ``` -#### Modeling multiple transducers (experimental support) +#### Multi-Transducer Modeling + +Status: *experimental support* Multiple transducers can be specified in layered simulations. See [this pull request](https://github.com/Donders-Institute/PRESTUS/pull/100) for examples. @@ -52,6 +54,6 @@ transducer: Limitations: - simulations that do not inclue a skull or layered setup (e.g., 'water') only model the first transducer - setup with kWaveArray not supported -- different source frequencies not supported (see ```source_sensor_setup.m```) +- different source frequencies not supported (see `source_sensor_setup.m`) - exit-plane related metrics refer to the first transducer - Thermal diffusion is simulated for the COMBINED field, but ALL focal-plane time-course heating plots reflect ONLY the focal plane of the first transducer and MAY MISS HOTSPOTS near other beams. DO NOT use these 1D/2D plots as an exhaustive safety check, but ALWAYS inspect 3D maxT and CEM43 volumes (NIfTIs). \ No newline at end of file diff --git a/documentation/doc_backend.md b/documentation/doc_backend.md index 05ac4040..88b3ef3d 100644 --- a/documentation/doc_backend.md +++ b/documentation/doc_backend.md @@ -1,6 +1,6 @@ -# Backend code implementation +# Simulation backend -PRESTUS allows code deployment using different computing setups (`parameters.code_type`). This will primarily affect acoustic simulations. With either of the below GPU variants, thermal simulations will also use GPU acceleration (if kWave 1.4.1 is provided). +PRESTUS allows k-Wave deployment using different computing setups (`parameters.code_type`). This will primarily affect acoustic simulations. With either of the GPU variants, thermal simulations will also use GPU acceleration (if k-Wave 1.4.1 is provided). - `matlab_cpu` - `kspaceFirstOrder3D` | `kspaceFirstOrder2D` | `kspaceFirstOrderAS` @@ -16,7 +16,8 @@ PRESTUS allows code deployment using different computing setups (`parameters.cod To use C++ with GPU support you may need to recompile the binary for your GPU version (e.g., for the A100 GPUs on the Donders HPC). -- Download the [LINUX source files for kWave 1.3](http://www.k-wave.org/download.php). This includes the Makefile to compile the binary. +- Download the [LINUX source files for k-Wave 1.3](http://www.k-wave.org/download.php). This includes the Makefile to compile the binary. - To compile the binaries, you can follow the instruction by [TU Delft](https://qiweb.tudelft.nl/sysman/kwave_hpc.html). Note that you need to include your GPU type in the Makefile. - Example scripts for compiling binaries on the Donders HPC can be found in `PRESTUS/examples/hpc_compile...` +- For the Donders HPC, you can download a precompiled binary [here](https://github.com/jkosciessa/PRESTUS_bin/raw/refs/heads/main/donders_kwave_bin/kspaceFirstOrder-CUDA). - Place the updated binary into `PRESTUS/toolboxes/k-Wave/binaries/` \ No newline at end of file diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index a63bf727..1b59a968 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -1,49 +1,142 @@ -## Calibration of multi-element transducers +## Transducer Calibration -To emulate the actual transducers for the use in simulations, we initially have to optimise the virtual transducers such that they match the output we obtain in free water. The acoustic profile will vary between different transducers, as well as for different focal depth settings for any given transducer. Additionally, the amplitude of stimulation that yields the desired output intensity needs to be estimated. As such, calibration of amplitude will also be specific to the desired free water ISPPA. +To emulate transducers, PRESTUS aims to optimise the velocity and phase settings of virtual transducers such that they produce focal axis profiles similar to those measured with real driving system - transducer setups in free water (measured either in-house via hydrophones or provided by device manufacturers). This calibrated emulation will vary between different transducers, for different focal depth settings, and free-water pressures. -The calibration relies on additional config (`calibration_config`) that should be loaded as `parameters.calibration` (see the example script below). +Transducer calibration relies on an additional config (`calibration_config`) that should be loaded as `parameters.calibration` (see the example `calibration_standalone`). -### Workflow 1: Find calibration settings for one (or multiple) transducer-depth settings [standalone] +### Standalone calibration setup -Script: `acoustic_profiling_standalone` +**Script: `examples/calibration_standalone`** -The above script provides a one-click solution to generate calibrated profiles when using the transducer equipment at the Donders Institute. +#### Use cases -- Prerequisites: - - Manufacturer-provided phase tables - - Measured/estimated axial profiles - - For the Donders, the empirical profiles (steering tables) can be found on the [FUSInitiative OneDrive](https://radbouduniversiteit.sharepoint.com/:f:/r/sites/FUSInitiative-SHAREDResearchinformation/Shared%20Documents/Software/PRESTUS/Acoustic%20profiling/) - -- Features: - - automatically load Donders-specific transducer information - - set initial simulation to manufacturer data - - interpolate requested distance from multiple empirically measured distances +- Create a library of calibration settings for one (or multiple) transducer-depth settings +- Incorporate manufacturer information +- Template to set up and generate calibrated profiles for transducer equipment at the Donders Institute. + +#### Prerequisites + +- Measured/estimated axial profiles (`path_input_axial`) +- Manufacturer-provided phase tables (`path_input_phase`) +- Entries in the equipment configuration (`PRESTUS/configs/equipment/equipment_config.yaml`) -### Workflow 2: Dynamically update calibration settings (e.g., when iterating across settings) +For the Donders, the empirical profiles (steering tables) can be found on the [FUSInitiative OneDrive](https://radbouduniversiteit.sharepoint.com/:f:/r/sites/FUSInitiative-SHAREDResearchinformation/Shared%20Documents/Software/PRESTUS/Acoustic%20profiling/) -Function: `acoustic_profiling` +Requested calibrations for unique TPO-transducer & focal depth & intensity combinations can be specified in `calibration_config.yaml` via `combinations`, `focal_depths_wrt_exit_plane`, and `desired_intensities`. -- Use cases: - - More limited information is available (e.g., only the axial profile is known) - - Calibration should be performed dynamically as part of a simulation loop (e.g., for sweeping an amplitude-distance parameter space) +Each line in the configuration corresponds to a unique TPO-transducer setup. In the following example specification, an IGT transducer with ID `PCD15287_01001` would be emulated for two focal depths of 40 and 50 mm from the exit plane, each for free-water intensities of 30 and 60 W/cm2 respectively. For a second transducer (`PCD15473_01001`), emulated phases and amplitudes would be provided for a depth of 40 mm at an intensity of 30 W/cm2. -- Prerequisites: - - `profile_empirical.profile_focus` - Measured or theoretical intensity profile along beam axis - - `profile_empirical.dist_from_tran` - Distance (mm from transducer reference point) - - `profile_empirical.focus_wrt_exit_plane` - Corresponding focal distance (mm from transducer exit plane) +> This example fits a 32-channel transducer with 10 artificial channels. The number of emulated channels can impact the stability of the fitting solution. It is governed by the setup in `equipment_config.yaml`. -### Steps: +Transducer-TPO setups to be characterized: +``` + combinations: + - IS_PCD15287_01001_IGT_32_ch_comb_10_ch + - IS_PCD15473_01001_IGT_32_ch_comb_10_ch +``` +List of focal depths (in mm) to be characterized: +``` + focal_depths_wrt_exit_plane: + - [40, 50] + - [40] +``` +List of intensities (in free-water W/cm2) to be characterized: + +``` + desired_intensities: + - [30, 60] + - [30] +``` + +#### Steps -**Script (`acoustic_profiling_standalone`):** - Define and initialize the simulation environment by setting paths and loading configuration files with equipment and user calibration data. -- Identify specific equipment combinations and extract associated transducer and driving system parameters, setting default initial amplitudes and phases. -- Load measured characterization data of the actual transducer’s acoustic field including axial intensity profiles and phase information provided by manufacturers. -- Translate experimentally measured focal depths relative to the transducer exit plane into simulation-relevant coordinates centered on the transducer’s mid-bowl to align measurement and simulation references. +- Automatically load Donders-specific transducer information: Identify specific equipment combinations and extract associated transducer and driving system parameters, setting default initial amplitudes and phases. +- Set initial simulation to manufacturer data: Load measured characterization data of the actual transducer’s acoustic field including axial intensity profiles and phase information provided by manufacturers. +- Interpolate requested distance from multiple empirically measured distances +- Add Focal Distance Offset (FDO; via `add_FDO`): Translate measured focal depths relative to the transducer exit plane into simulation-relevant coordinates centered on the transducer’s mid-bowl. Missing distances are zero-interpolated. - Select or interpolate the axial intensity profiles for the specified focal depth. +- Call `calibration_transducer` + +### Calibrate phase and amplitude settings + +**Function: `calibration_transducer`** + +#### Use cases + +- Flexibility: only need to specify the desired axial profile +- Dynamic integration into end-to-end simulation loops (e.g., iterate across a amplitude-distance parameter space) + +#### Prerequisites + +- `profile_empirical.axial_intensity` + Desired intensity profile along focal beam axis +- `profile_empirical.axial_distance_bowl` + Distance (mm from transducer bowl) +- `desired_focal_distance_ep` + Requested focal distance (mm from transducer exit plane) + +#### Steps + +1. Scale the requested profile to the desired intensity +2. Run free-water simulation + + How simulations will be run will largely be determined by the main `parameters` (e.g., `parameters.code_type`). + However, additional settings in `parameters.calibration` can overwrite default behaviour: + + - `axisymmetric2D` + Overwrite default 3D simulation to perform axisymmetric 2D water simulations (`1` = yes, `0` = no (default)). + - `force_kwavearray` + Force run free-water simulations with kwavearray (recommended)? If set to `0`, simulations use the setting in the default or study-specific config. + +3. Extract simulated intensity along the focal axis +4. Compute analytical O'Neil solution + + Also computes `simulated_analytical_scaling` factor from analytical to simulated intensity + +5. Optimize the virtual transducer’s element velocity and phases + + Goal: match settings such that analytical profile corresponds to scaled empirical profile + + Multiple parameters configure the calibration: + + - `opt_method` + Optimization backend to use: `FEXminimize` (open source subtoolbox, default) | `GlobalSearch` (MATLAB's Global Optimization Toolbox) + - `opt_weights + Weighting of the original profile during fitting (0 = uniform weighting, >1 increasingly narrow Gaussian FWHM). + - Uniform (`opt_weights == 0`): Equal weighting across entire profileβ€”optimizes global shape. + - Gaussian (`opt_weights >= 1`): FWHM-centered Gaussian peaking at focal maximum to emphasize near-focus optimization. HigherΒ weights yield narrower Gaussians (sigma = focus_pos / weights). + - `opt_limits` + Distance limits for optimization [mm] + - `opt_seed` + Random seed for optimization. Specifying a seed increases reproducibility. + - `opt_upper_velocity` + Upper velocity to use in global search. + +
+ + > Note: `skip_front_peak_mm` specifies the distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts when calculating the peak distance and FWHM. It does not impact fitting. If the fit is intended over a narower range, define `opt_limits`. + + ![calibration_fitting](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_fitting.png) + The above figure shows an example profile fit. Here, uniform `opt_weights` are used. +
+ +6. Recalculate analytical solution with optimized phases and velocity +7. Calculate optimized source amplitude: + + $$ \mathrm{amplitude\_optimized} = \left( \frac{\mathrm{velocity\_optimized}}{\mathrm{velocity\_original}} \right) \cdot \left( \frac{\mathrm{amplitude\_original}}{\mathrm{simulated\_analytical\_scaling}} \right) $$ + +8. Rerun water simulation with optimized phases and source amplitude +9. Extract simulated optimized intensity along the focal axis + + | Initial simulation | Optimized simulation | + |--------------------|----------------------| + | ![calibration_initial_intensity](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_initial_intensity.png) | ![calibration_opt_intensity](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_opt_intensity.png) | + | Visualized in Step 3. | Visualized in Step 9. | + + Note: The black line indicates the position of the **transducer bowl**, the red line indicated the position of the **transducer exit plane**, the white line indicates the maximum estimated **(focal) intensity** (see [distance definitions](doc_transducer.md#target-distance-parameters)). +
+ +10. Plot comparison between original and optimized results (analytical and simulated) -**Function (`acoustic_profiling`):** -- Run free-water simulation. -- Optimize the virtual transducer’s element amplitudes and phases so that its simulated acoustic field matches the measured and scaled transducer profile. -- Confirm via free-water simulation with optimized virtual transducer settings. \ No newline at end of file + ![calibration_optimized_analytical](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_optimized_analytical.png) \ No newline at end of file diff --git a/documentation/doc_config.md b/documentation/doc_config.md deleted file mode 100644 index ced3309f..00000000 --- a/documentation/doc_config.md +++ /dev/null @@ -1,215 +0,0 @@ -## PRESTUS configuration documentation - -The following documents the parameters used in PRESTUS. The specification philosophy is the following: `default_config.yaml` provides a list of all parameters with default settings and will be read in first by the function `load_parameters`. This default configuration file should not be changed in standard applications to ensure that necessary fields are provided. - -To set up a specific application, an additional `config_.yaml` should be provided. This file should contain exclusively the fields where defaults should be overwritten (e.g., to provide specific transducer settings). Alternatively, parameters can be specified prior to calling the `single_subject_pipeline` in MATLAB. This allows dynamic iterations over parameters of interest. - -### I/O management - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `data_path` | Absolute path to structural input data location. | [string] Mandatory | -| `seg_path` | Absolute path to SimNIBS segmentations. | [string] Mandatory | -| `sim_path` | Absolute path to the simulation output. | [string] Mandatory | -| `simnibs_bin_path` | Absolute path to SimNIBS binaries. | [string] Mandatory | -| `paths_to_add` | Toolbox paths to add with addpath(). | [cell] e.g., `{"path/to/x", "path/to/Y"}` | -| `subpaths_to_add` | Toolbox paths to add with addpath(genpath()). | [cell] e.g., `{"path/to/x", "path/to/Y"}` | -| `subject_subfolder` | Manage outputs in subject-specific subdirectories? | (`1 = yes [default], 0 = no`) | -| `results_filename_affix` | Affix for result file names | [string] Can be used to differentiate simulation outputs for the same subject-transducer combination(e.g., different intensities and/or targets.) | -| `interactive` | Interactive mode (`1 = yes, 0 = no`). | (`1 = yes, 0 = no`) Asks prior to overwriting or starting long computations. If set to non-interactive, see the flags `overwrite_files` and `overwrite_simnibs`. | -| `overwrite_files` | File overwrite behavior (`ask`, `never`, or `always`). | (`ask`, `never`, or `always`) This parameter does NOT apply to SimNIBS segmentations. | -| `overwrite_simnibs` | Overwrite SimNIBS segmentation results? | (`1 = yes, 0 = no` [default]) | - -### Simulation type - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `simulation_medium` | Medium setup for simulation (`water` or `layered`). | Mandatory. | -| ` ` | Labels for layered simulation, defining mask indices for different tissue types. | Mandatory. | -| `seg_labels` | Labels for segmentations, specifying indices for CSF, bone mask, and eye regions. | Mandatory. | -| `run_source_setup` | Set up acoustic sources. | (`1 = yes, 0 = no`) | -| `run_acoustic_sims` | Run acoustic simulations. | (`1 = yes, 0 = no`) | -| `run_heating_sims` | Run heating simulations. | (`1 = yes, 0 = no`) | -| `run_posthoc_water_sims` | Run water simulations following head simulations. | (`1 = yes, 0 = no`) | -| `generate_report` | Generate a self-contained HTML simulation report at the end of the pipeline? | (`1 = yes, 0 = no`) | -| `n_sim_dims` | Simulation type (2D / 3D). | `2` = 2D (`kspaceFirstOrder2D`), `3` = 3D(`kspaceFirstOrder3D`). If not specified, it is inferred from `default_grid_dims`. For `axisymmetric` setups (see below), specify 2D. | -| `axisymmetric` | Run 2D simulations with axisymmetry (`kspaceFirstOrderAS `). | (`1 = yes, 0 = no`) see `doc_simulations-acoustic.md`. | -| `savemat` | Save outputs of acoustic and/or heating simulations as .mat files? | (`1 = yes, 0 = no`) For many parallel simulations, setting this to 0 saves disk space. | -| `debug` | Activate a more verbose debug mode in which more intermediate output plots and files are provided? | (`1 = yes, 0 = no`) | - -### Segmentation/Preprocessing - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `segmentation_software` | Segmentation software used (`headreco` or `charm`). | Use of the former `headreco` may result in errors (e.g., when creating pseudoCTs) due to the assumption of charm-based tissue labels in parts of the codebase. | -| `csf_mask_expansion_factor` | Expansion factor for cerebrospinal fluid (CSF) brain mask; controls CSF mask dilation. [mm] | | -| `smooth_method` | Smoothing filter type ['gaussian'(default)|'box'] | | -| `smooth_threshold_skull` | Threshold for smoothing the skull mask; higher values result in thinner masks. | | -| `smooth_threshold_other` | Threshold for smoothing other masks; higher values result in thinner masks. | | -| `smooth_window` | Number of voxels for smoothing (default: 3) | | -| `skullfillmethod` | Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] | | -| `wrapradius` | Skull rubber wrap radius [grid voxels] (default: 10) | Larger = tighter rubber that ignores bigger dents; recommended: 2-10 | - -### Transducer specification - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `transducer.source_freq_hz` | Central frequency of the acoustic source (in Hz). | | -| `transducer.n_elements` | Number of elements in the transducer. | | -| `transducer.Elements_ID_mm` | Inner diameter of each transducer element (in mm). | | -| `transducer.Elements_OD_mm` | Outer diameter of each transducer element (in mm). | | -| `transducer.curv_radius_mm` | Radius of curvature of the transducer bowl (in mm). | | -| `transducer.dist_to_plane_mm` | Distance from the geometric focus to the transducer plane (in mm). | | -| `transducer.source_amp` | Amplitude of the acoustic source (in Pa). | Must be calibrated. | -| `transducer.source_phase_deg` | Phase of the acoustic source (in degrees). | Must be calibrated. | -| `transducer.trans_pos` | Position of transducer bowl (XYZ, T1 grid voxel space). | | -| `transducer.focus_pos` | Position of stimulation target (XYZ, T1 grid voxel space). | | -| `expected_focal_distance_mm` | Expected distance to the stimulation focus (in mm). | Transducer depth setting | -| `transducer_from_localite` | Load transducer position from Localite files?. | (`1 = yes, 0 = no` [default]) | -| `reference_transducer_distance_mm` | Distance from tracker to transducer exit plane (in mm). | Allows to correct for varying distances between the infrared trackers attached to the transducer and the exit plane. Only applies when `transducer_from_localite=1`. | -| `use_kWaveArray` | Use the kWaveArray class for modeling transducers. | (`1 = yes, 0 = no`) see k-Wave documentation. | - -### Target specification - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `focus_area_radius` | Radius of the target area around the focus where ISPPA is averaged (in mm). | Default: 5 mm| - -### Simulation grid - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `grid_step_mm` | Resolution of the computational grid (must be isotropic, in mm). | | -| `default_grid_dims` | Default dimensions of the simulation grid `[Nx, Ny, Nz]`. | | -| `pml_size` | Size of the Perfectly Matched Layer (PML) used to absorb waves at the grid boundaries (default is 10 for 3D grids). | see k-Wave documentation.| -| `grid_max_expand` | Maximum expansion [mm] for computational grid to optimize prime numbers and speed up computations. | | -| `source_ppw` | Points Per Wavelength [default: calculated internally] | | -| `source_cfl` | Courant-Friedrichs-Lewy fraction | | -| `source_limit_fraction` | Fraction of the stability limit to use for time step (0 = do not use stability limit) | | -| `precision` | Computational precision for both acoustic and thermal simulations | 'single' (default) | 'double' (more precise, but more computationally demanding).| - -### Medium properties - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `medium.water.sound_speed` | Speed of sound in water (in m/s). | ITRUSST benchmarks. | -| `medium.water.density` | Density of water (in kg/mΒ³). | Tissue Properties DB.| -| `medium.water.alpha_coeff` | Attenuation coefficient for water (in dB/cm/MHz). | k-Plan documentation.| -| `medium.water.alpha_power` | Exponent for attenuation coefficient power law for water. | Tissue Properties DB.| -| `medium.water.thermal_conductivity` | Thermal conductivity of water (in W/m/Β°C). | Tissue Properties DB.| -| `medium.water.specific_heat_capacity` | Specific heat capacity of water [J/kg/Β°C]. | Tissue Properties DB.| -| `medium.water.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.water.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.skull.sound_speed` | Speed of sound in skull bone (in m/s). | ITRUSST benchmarks. | -| `medium.skull.density` | Density of skull bone (in kg/mΒ³). | ITRUSST benchmarks. | -| `medium.skull.alpha_coeff` | Attenuation coefficient at for skull bone (in dB/cm/MHz). | Pinton et al., 2011. | -| `medium.skull.alpha_power` | Exponent for attenuation coefficient power law for skull bone. | k-Plan documentation.| -| `medium.skull.thermal_conductivity` | Thermal conductivity of skull bone (in W/m/Β°C). | Tissue Properties DB.| -| `medium.skull.specific_heat_capacity` | Specific heat capacity of skull bone (in J/kg/Β°C). | Tissue Properties DB.| -| `medium.skull.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.skull.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.brain.sound_speed` | Speed of sound in brain tissue (in m/s). | Tissue Properties DB.| -| `medium.brain.density` | Density of brain tissue (in kg/mΒ³). | Tissue Properties DB.| -| `medium.brain.alpha_coeff` | Attenuation coefficient for brain tissue (in dB/cm/MHz). | k-Plan documentation.| -| `medium.brain.alpha_power` | Exponent for attenuation coefficient power law for brain tissue. | k-Plan documentation.| -| `medium.brain.thermal_conductivity` | Thermal conductivity of brain tissue (in W/m/Β°C). | Tissue Properties DB.| -| `medium.brain.specific_heat_capacity` | Specific heat capacity of brain tissue (in J/kg/Β°C). | Tissue Properties DB.| -| `medium.brain.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.brain.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.skin.sound_speed` | Speed of sound in skin tissue (in m/s). | ITRUSST benchmarks. | -| `medium.skin.density` | Density of skin tissue (in kg/mΒ³). | ITRUSST benchmarks. | -| `medium.skin.alpha_coeff` | Attenuation coefficient for skin tissue (in dB/cm/MHz). | ITRUSST benchmarks. | -| `medium.skin.alpha_power` | Exponent for attenuation coefficient power law for skin tissue. | ITRUSST benchmarks. | -| `medium.skin.thermal_conductivity` | Thermal conductivity of skin tissue (in W/m/Β°C). | Tissue Properties DB.| -| `medium.skin.specific_heat_capacity` | Specific heat capacity of skin tissue (in J/kg/Β°C). | Tissue Properties DB.| -| `medium.skin.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.skin.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.skull_trabecular.sound_speed` | Speed of sound in trabecular bone (in m/s). | ITRUSST benchmarks. | -| `medium.skull_trabecular.density` | Density of trabecular bone (in kg/mΒ³). | ITRUSST benchmarks. | -| `medium.skull_trabecular.alpha_coeff` | Attenuation coefficient for trabecular bone (in dB/cm/MHz). | Pinton et al., 2011. | -| `medium.skull_trabecular.alpha_power` | Exponent for attenuation coefficient power law for trabecular bone. | k-Plan documentation.| -| `medium.skull_trabecular.thermal_conductivity` | Thermal conductivity of trabecular bone (in W/m/Β°C). | Tissue Properties DB.| -| `medium.skull_trabecular.specific_heat_capacity` | Specific heat capacity of trabecular bone (in J/kg/Β°C). | Tissue Properties DB.| -| `medium.skull_trabecular.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.skull_trabecular.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.skull_cortical.sound_speed` | Speed of sound in cortical bone (in m/s). | ITRUSST benchmarks. | -| `medium.skull_cortical.density` | Density of cortical bone (in kg/mΒ³). | ITRUSST benchmarks. | -| `medium.skull_cortical.alpha_coeff` | Attenuation coefficient for cortical bone (in dB/cm/MHz). | Pinton et al., 2011. | -| `medium.skull_cortical.alpha_power` | Exponent for attenuation coefficient power law for cortical bone. | k-Plan documentation.| -| `medium.skull_cortical.thermal_conductivity` | Thermal conductivity of cortical bone (in W/m/Β°C). | Tissue Properties DB.| -| `medium.skull_cortical.specific_heat_capacity` | Specific heat capacity of cortical bone (in J/kg/Β°C). | Tissue Properties DB.| -| `medium.skull_cortical.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.skull_cortical.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | - - -### pseudoCT mapping to skull properties -see doc_pseudoCT.md - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `usepseudoCT` | Use (pseudo-)CT based mapping? (`1 = yes, 0 = no`) | Maps (pseudo-)HU to tissue properties in the trabecular and cortical skull layer. | -| `pseudoCT_variant` | Mapping algorithm (`yaakub`/`carpino`/`k-plan`/`marquet`) | see doc_pseudoCT.md | - - -### Sequence timing and baseline temperature for heating simulations -see doc_thermal-simulations.md - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `thermal.pd` | Pulse Duration (PD) [seconds] | Duty cycle (DC) inside a Pulse Train [%] is internally calculated as `pd`/`pri`. | | -| `thermal.pri` | Pulse Repetition Interval (PRI) [seconds] (i.e., an on/off cycle during stimulation). | Pulse Repetition Frequency (PRF) is internally calculated as 1/`pri`. | -| `thermal.ptd` | Pulse Train Duration (PTD) [seconds] | | -| `thermal.pt_timestep` | Modeling time steps inside a PT [seconds] | | -| `thermal.ptri` | Pulse Train Repetition Interval (PTRI) [seconds] | Duration of OFF period within a PTRI is internally calculated as `ptri`-`ptd`. | -| `thermal.ptrd` | Pulse Train Repetition Duration (PTRD) [seconds] | | -| `thermal.post_ptri_dur` | Post-PTRI Steady-state Duration [seconds] | | -| `thermal.post_pt_timestep` | Modeling time steps following PT & PTRI [seconds] | | -| `thermal.equal_step_duration` | Whether simulation step durations are equal for on and off cycles (`1 = yes, 0 = no`). | | -| `thermal.cem43_iso` | Calculate CEM43 according to ISO norm (`1`) or kWaveDiffusion (`0`). | | -| `thermal.temp_0.water` | Initial temperature of water medium before simulation (in Β°C). | | -| `thermal.temp_0.skull` | Initial temperature of skull medium before simulation (in Β°C). | | -| `thermal.temp_0.brain` | Initial temperature of brain medium before simulation (in Β°C). | | -| `thermal.temp_0.skin` | Initial temperature of skin medium before simulation (in Β°C). | | -| `thermal.temp_0.skull_trabecular` | Initial temperature of trabecular skull medium before simulation (in Β°C). | | -| `thermal.temp_0.skull_cortical` | Initial temperature of cortical skull medium before simulation (in Β°C). | | -| `thermal.sensor_xy_halfsize` | Maximum size of the sensor window for temperature recording (in grid units). | | -| `thermal.record_t_at_every_step` | Whether to record temperature at every time step for the whole sensor window (`1 = yes, 0 = no`). | | -| `heatingvideo` | Save a video of incremental heating? (`1 = yes, 0 = no`) | | - - -### GPU/HPC options -see doc_hpc.md - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `code_type` | Type of k-Wave code to run (`matlab_cpu`, `matlab_gpu`, `cpp_cpu`, or `cpp_gpu`). | | -| `hpc_submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab`, `qsub` | -| `hpc_gpu` | Request a specific GPU. [Optional] | Not recommended by default, rely on automatic GPU detection instead. May be useful when benchmarking specific GPUs. E.g.,```"nvidia_a100-sxm4-40gb:1"```. ```scontrol show nodes \| egrep -o gres/gpu:.*=[0-9] \| egrep -o 'nvidia_.*=' \| sort \| uniq \| sed 's/=//'``` lists available GPU types. | -| `hpc_partition` | Request a dedicated GPU partition. [Optional] | The Donders HCP provides a ```gpu40g``` partition that consists of nodes with GPU with vRAM > 40 GB. This is the recommended default for thermal simulations of longer protocols. | -| `hpc_reservation` | Request a reserved cue. [Optional] | | -| `ld_library_path` | Path to LD_LIBRARY used during SimNIBS installation. [Optional] | If you experience an `undefined symbol` error in `create_mesh_surf.cpython-39-x86_64-linux-gnu.so`, specify the LD_LIBRARY location e.g., `/opt/gcc/7.2.0/lib64` [torque] or ```/home/'group'/'user'/.conda/envs/simnibs_env/lib/python3.9/site-packages/simnibs/mesh_tools/cgal/../../external/lib/linux``` [slurm] | - -### Transducer calibration - -For transducer calibration, a separate `calibration_config.yaml` applies that should be loaded as `parameters.calibration`. - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `path_input_axial` | Directory containing axial profiles | | -| `path_input_phase` | Directory containing phase data | | -| `path_output` | Directory for saving free-water simulation results | | -| `path_output_profiles` | Directory for saving optimized profile data | | -| `filename_calibrated_CSV` | Filename of calibrated CSV data | Mandatory only when not generated within standalone script based on equipment name. | -| `submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab` (debug; doesn't overwrite), `qsub` | -| `axisymmetric2D` | Overwrite default 3D simulation to perform axisymmetric 2D. | (`1 = yes, 0 = no`) | -| `save_in_calibration_folder` | `TRUE` (default): save in `path_output` ; `FALSE`: save outputs in `sim_path` (see regular config). | Note: If TRUE calibration results are also appended to existing calibration for this equipment instead of overwritten. | -| `skip_front_peak_mm` | Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts. | Used only for calculating the peak distance and FWHM. | -| `optmethod` | `FEXminimize` (open source subtoolbox) or `GlobalSearch` (MATLAB's Global Optimization Toolbox) | | -| `weights` | Weighting of the original profile during fitting (towards 0 = narrower Gaussian; 1 = equal weighting) | | -| `seed` | Random seed for optimization | | -| `addEPdistance` | Append distance from transducer bowl to exit plane (set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl) | | -| `interpolateToEP` | Interpolate values between bowl and exit plane | If active, the profile is padded (between bowl and exit plane) with the initial value available in the profile. This can stabilize the fitting procedure in the near field. | -| `force_kwavearray` | Force to run free-water simulations with kwavearray? | If active, free-water simulations always use kwavearray. If set to `0`, simulations use the setting in the default or study-specific config. | -| `combinations` | Equipment combinations (must refer to equipment in `equipment_config.yaml`) | Multiple combinations can be specified. Every [] specification will be performed for the corresponding (ordered) index equipment combination. Example: all foci within the first [] will be executed for the first equipment combination. If array is empty ([]), all focal depths of available characterization data will be used. | -| `focal_depths_wrt_exit_plane` | List of focal depths (in mm) to be characterized. | Multiple combinations can be specified. | -| `desired_intensities` | Desired free-water intensities [W/cm^2] | Multiple combinations can be specified. | diff --git a/documentation/doc_coordinate_systems.md b/documentation/doc_coordinate_systems.md new file mode 100644 index 00000000..043e7f9f --- /dev/null +++ b/documentation/doc_coordinate_systems.md @@ -0,0 +1,32 @@ +# Coordinate Reference Systems + +The function `transform_coordinates` can be used to transform coordinates between coordinate reference systems. + +1. Subject Space: planning image [voxel indices] (`grid`) + - Reference: Specific image grid (subject’s T1 MRI, segmentation) + - Orientation: Based on image storage; mapping real anatomy depends on image header β€œtransform”. +2. Subject Space: localite planning image [mm] (`ras_plus` with adjusted header) + - Subject space in mm without any affine transform to world space (see [the localite planning image](doc_placement_heuristic.md#save-localite-planning-image)). + - Use the header from the adjusted localite planning image for the transform. +2. World (Scanner) Space / RAS (Right-Anterior-Superior) [mm] (`ras_plus`) + - Reference: Physical space of scanner (native) or MNI brain. + - Orientation: RAS (Right-Anterior-Superior, SPM/FreeSurfer/FSL convention); origin location varies by header (e.g., AC). +4. MNI Space RAS (Right-Anterior-Superior) [mm] (`mni`) + - Reference: Standard stereotactic space, e.g., MNI152 (often referred to as β€œworld” space in standard template). + - Note: Not usually 0-centered; (0,0,0) refers to AC in MNI, which can be off-center in template volume. +5. MNI Space [voxel indices] (`undefined`; see below) + - Reference: Specific image (often 181Γ—217Γ—181 grid), for operations like drawing spheres or extracting image data. + - Conversion: Use template’s affine matrix to convert MNI mm ↔ MNI voxel indices. + + ``` + template_affine = niftiinfo('MNI152_T1_1mm.nii.gz').Transform.T; + inv_affine = inv(template_affine); + mni_vox = (inv_affine * [mni_mm 1]')'; % yields [i j k 1], take [1:3] + ``` + +#### Available transforms + +PRESTUS reuses available nonlinear transform matrices from SimNIBS. + +- CHARM’s `Conform2MNI_nonl.nii.gz` is a nonlinear deformation field that maps points from an individual SUBJECT’S SCANNER/CONFORM (native) space to MNI (template) space, expressed in millimeters. +- `MNI2Conform_nonl.nii.gz` is the inverse of `Conform2MNI_nonl.nii.gz`. It is a 4D nonlinear deformation field created by CHARM/SimNIBS that maps coordinates from MNI (template) space back into the subject’s native millimeter (mm) space. \ No newline at end of file diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index 4dd59df5..d2e33230 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -1,116 +1,207 @@ -## PRESTUS function documentation +## PRESTUS functions The following documents the functions provided in PRESTUS. -| **Function Name** | **Category** | **Description** | -|-----------------------------------------|-----------------|------------------------------------------------------------------------------------------------| -| `acoustic_analysis` | ACOUSTIC | Compute acoustic safety metrics (Isppa, MI, max pressure) from sensor data, extract tissue-specific maxima, focal distances, and generate overlaid ISPPA plots on segmentation | -| `acoustic_convert_axisymmetry` | ACOUSTIC | Expand k-Wave axisymmetric (2D rotational) simulation outputs to full 3D (via Β convert_axisymmetric_to_3dΒ ) for heating sims or 2D Cartesian (via Β convert_axisymmetric_to_2dΒ ) otherwise, updating sensor data, parameters, masks, medium, grid, source, and labels. | -| `acoustic_simulation` | ACOUSTIC | Set up acoustic simulation | -| `acoustic_wrapper` | ACOUSTIC | Set up acoustic simulation | -| `calc_virtual_elem` | CALIBRATION | (STANDALONE) Calculate additional virtual elements | -| `calibration_transducer` | CALIBRATION | Run water simulations to optimize transducer source amplitude and element phases matching a target intensity profile via O’Neil analytical solution, global search, and re-simulation. | -| `compute_oneil_solution` | CALIBRATION | Compute analytical O’Neil pressure along the beam axis for focused annular transducers in water, derives particle velocity and grid adjustment factor, and plots comparisons with simulated/desired intensities. | -| `compute_phases` | CALIBRATION | Calculate per-element transducer phases (degrees) for electronic focusing by computing fractional wavelength delays to steer waves constructively to a target point relative to the exit plane. | -| `extract_real_intensity_profile` | CALIBRATION | Extract or spline-interpolate axial intensity profiles between available measured focal depths (wrt exit plane), aligns peaks, skips near-field for max, and plots/saves results. | -| `perform_global_search` | CALIBRATION | Globally optimize transducer element phases (rad) and particle velocity via FEXminimize or GlobalSearch to minimize error fitting O’Neil analytical intensity to measured axial profiles. | -| `phase_optimization_annulus_full_curve` | CALIBRATION | Optimizes the phase profile for an annular transducer by matching intensity curves. | -| `phase_optimization_annulus` | CALIBRATION | Optimizes the phase profile by calculating focal distance error for an annular transducer. | -| `plot_opt_sim_results` | CALIBRATION | Load optimized k-Wave results, generate 2D focal plane intensity maps and axial profile comparisons (simulated vs. analytical O’Neil vs. desired), and save annotated plots. | -| `recalculate_analytical_sol` | CALIBRATION | Recompute O’Neil analytical pressure profile using optimized phases/velocity, convert to intensity, plot comparisons with original/desired profiles (skipping near-field), and report focal metrics. | -| `save_optimized_values` | CALIBRATION | Append optimized transducer phases and amplitudes into a CSV table (indexed by intensity/focus) and save parameters as YAML for PRESTUS config integration. | -| `scale_real_intensity_profile` | CALIBRATION | Linearly scale measured focal intensity profile and update transducer source amplitude (via acoustic impedance) to match desired peak Isppa (W/cmΒ²). | -| `set_real_phases` | CALIBRATION | Load or compute manufacturer-specific (Sonic Concepts/Imasonic) transducer element phases for given focal depth (wrt exit plane), interpolate/unwrap for virtual elements, and return phase degrees. | -| `load_parameters` | CORE | Loads and merges configuration files for simulation parameters. | -| `path_log_setup` | CORE | Set up internal paths and logging, filename for output table | -| `simulation_nifti` | CORE | Save key outputs as 3D Niftis | -| `changem_vectorized` | GROUP | Replace multiple old values in array A with corresponding new values. | -| `combine_plots_by_suffix` | GROUP | Combines subject-specific plots into a single montage image. | -| `create_group_MNI_plots` | GROUP | Generate group-level plots in MNI space for multiple subjects. | -| `head_smooth_and_crop` | HEAD | Convert and smooth segmentations into medium map & crop grid for effiency | -| `preproc_medium_mask` | HEAD | Map segmentation indices onto the medium labels (in the config) [layered only] | -| `preproc_align_to_focal_axis` | HEAD | Rotate input image and its grid coordinates to the transducer-focal axis (+Z) | -| `preproc_crop_eCSF` | HEAD | Expand CSF to define outer edges of layered medium. | -| `preproc_crop_grid` | HEAD | Crop grid to head + transducer + PML. | -| `preproc_head` | HEAD | Preprocesses structural brain data for simulations (segmentation, alignment, cropping). | -| `preproc_segmentation` | HEAD | Setup SimNIBS segmentation | -| `segmentation_run` | HEAD | Submit SimNIBS segmentation | -| `skull_fill_holes` | HEAD | Fill holes in skull segmentation and between skull and skin | -| `skull_rubber_wrap_visualize` | HEAD | Visualize results of skull rubber expansion | -| `skull_rubber_wrap` | HEAD | Inflate the skull layer to locally fill potential holes | -| `smooth_img` | HEAD | Apply 3D smoothing | -| `check_availability` | HELPER | Check file availability. | -| `check_layers` | HELPER | Match requested layers to those available in the segmentation. For (p)CT, homogenize multi-skull layer into a single `skull `layer. | -| `confirm_overwriting` | HELPER | Check overwriting (manual). | -| `confirmation_dlg` | HELPER | Present confirmation dialogue. | -| `find_min_factor` | HELPER | Find the number with the smallest maximum factor in a range. | -| `get_crop_dims` | HELPER | Computes cropping dimensions for a 3D image with a margin. | -| `get_flhm_center_position` | HELPER | Calculates the center position of the full-length half-maximum (FLHM). | -| `get_slice_by_label` | HELPER | Extracts a specific slice from a 3D image based on axis label and slice number. | -| `get_xyz_mesh` | HELPER | Generates a mesh of 3D coordinates for a given image. | -| `getidx` | HELPER | Retrieves indices for requested tissues from a parameter structure. | -| `kwave_version` | HELPER | Display k-Wave version number and (if available) git hash. | -| `log_timer` | HELPER | Start or stop logs for benchmarking time, RAM, and disk space use. | -| `masked_max_3d` | HELPER | Computes the maximum intensity within a masked 3D region. | -| `mergeStructure` | HELPER | Merges multiple scalar structures into one. | -| `read_ini_file` | HELPER | Read an INI file into MATLAB | -| `round_if_integer` | HELPER | Rounds values if they are sufficiently close to integers; otherwise raises an error. | -| `subset_fields` | HELPER | Copy designated structure field to new structure. | -| `tissuemask_binary` | HELPER | Extract binary tissue segmentation masks (for indexing) | -| `upsample_to_grid` | HELPER | Upsamples a 3D image to a higher resolution grid. | -| `zip_fields` | HELPER | Convert a structure's fields and values into a cell array. | -| `single_subject_pipeline_with_qsub` | HPC | HPC call of pipeline with QSUB. | -| `single_subject_pipeline_with_slurm` | HPC | HPC call of pipeline with SLURM. | -| `transducer_positioning_with_qsub` | HPC | Submits transducer positioning jobs to Qsub cluster using batch scripts. | -| `transducer_positioning_with_slurm` | HPC | Submits transducer positioning jobs to SLURM cluster using batch scripts. | -| `fitPowerLawParamsMulti` | MEDIUM | Fit power law absorption parameters for highly absorbing media. | -| `get_alpha_coeff` | MEDIUM | Computes the amplitude attenuation coefficient for a given medium and frequency. | -| `medium_properties_nifti` | MEDIUM | Save NifTi image of the specified medium property map. | -| `medium_setup` | MEDIUM | Set up medium | -| `neuronav_compute_series_statistics` | NEURONAV | Compute mean position and variability over stimulus train | -| `neuronav_convert_MNI_to_native` | NEURONAV | Transform coordinates from MNI space to native subject space | -| `neuronav_convert_native_to_MNI` | NEURONAV | Convert native RAS coordinates to MNI space | -| `neuronav_convert_trigger_to_voxels` | NEURONAV | Convert Localite trigger positions to voxel (image) coordinates. | -| `neuronav_create_marker_average` | NEURONAV | Build an averaged Localite-trigger structure for export. | -| `neuronav_export_session_csv` | NEURONAV | Export per-session coordinate arrays to CSV with labeled voxel and RAS (mm) positions. | -| `neuronav_get_group_mean_mni` | NEURONAV | Compute group-level average MNI coordinates (both mm and voxel). | -| `neuronav_select_and_average_localite` | NEURONAV | Select most recent Localite XML for a session. | -| `position_transducer_localite` | NEURONAV | Determines transducer and focus positions in voxel space using Localite data and MRI header. | -| `pct_create_pseudoCT` | PSEUDO-CT | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | -| `pct_skullmapping` | PSEUDO-CT | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. | -| `pct_soft_tissue_peak` | PSEUDO-CT | Identifies the soft tissue peak from UTE intensity distribution histograms. | -| `fit_pairwiselinear` | PSEUDO-CT | Perform a pairwise linear fit between HU and density values with optional plot. | -| `plot_coronal_slices` | PLOT | (DEPRECATED) Visualizes coronal slices of a 3D image with optional legends for labeled images. | -| `plot_median_montage` | PLOT | (DEPRECATED) Creates a montage of central slices from a 3D T1 image. | -| `plot_overlay_2d` | PLOT | Overlays map on a background image slice with key positions highlighted. | -| `plot_overlay` | PLOT | Visualizes map overlaid on a 2D slice of a 3D background image with advanced options. | -| `plot_t1_with_transducer` | PLOT | Creates a plot of a T1 slice oriented along the transducer's axis with overlays. | -| `show_3d_head` | PLOT | Visualizes segmented brain images in 3D with transducer placement and target location. | -| `show_positioning_plots` | PLOT | Visualizes transducer positioning before and after preprocessing using segmented images. | -| `grid_axisymmetry` | SOURCE | Convert grid to axisymmetry | -| `grid_tissue_setup` | SOURCE | Set up grid dimensions, preprocess head (if modeled), and place in grid | -| `grid_transducer_location` | SOURCE | Position transducer (and target) in simulation grid | -| `source_create` | SOURCE | Creates ultrasound source signals and masks for k-Wave simulations based on transducer geometry.| -| `source_sensor_setup` | SOURCE | Configure a k-Wave simulation grid, transducer sources, and sensor mask based on input parameters for recording pressure fields in 2D or 3D ultrasonic neuromodulation setups. | -| `thermal_analysis` | THERMAL | Analyze output of thermal simulations | -| `thermal_parameters` | THERMAL | Checks and converts protocol timing setup for thermal estimation | -| `thermal_plot_protocol` | THERMAL | Visualize the requested protocol timing | -| `thermal_plot_sim` | THERMAL | Visualizes heating simulation results over time (temperature, rise, CEM43). Optional video | -| `thermal_simulation` | THERMAL | Simulate ultrasound-induced heating and thermal dose (CEM43) using k-Wave’s kWaveDiffusion from acoustic pressure data across pulsed train repetitions. | -| `focal_distance_calculation` | TRANSDUCER | Compute expected focal distances for (multi-)transducer setup. | -| `get_arc` | TRANSDUCER | Generates the coordinates of an arc in 2D space. | -| `get_trans_pos_from_trigger_markers` | TRANSDUCER | Determines transducer and target positions using Localite trigger marker files. | -| `get_transducer_box` | TRANSDUCER | Computes transducer box dimensions and positions for simulations. | -| `transducer_analyze_position_fast` | TRANSDUCER | Analyzes the transducer position relative to the skull and skin. | -| `transducer_analyze_position` | TRANSDUCER | Computes geometric and statistical measures for a transducer position. | -| `transducer_positioning` | TRANSDUCER | Determines heuristic transducer placement for a given target. | -| `transducer_setup` | TRANSDUCER | Create a transducer mask and label matrix for a computational grid. | -| `convert_2d_to_axisymmetric` | TRANSFORM | Converts 2D k-Wave simulation grid, medium properties, and source to axisymmetric form by halving the shorter dimension (right half from center). | -| `convert_axisymmetric_to_2d` | TRANSFORM | Mirrors axisymmetric simulation data (sensor, medium, masks, source) left-right to full 2D, doubles radial dimension, transposes axes, and updates kgrid/positions. | -| `convert_axisymmetric_to_3d` | TRANSFORM | Expands 2D axisymmetric data (via radialExpand2DTo3D) to full 3D grid by duplicating radial dimension, updates positions/grid/kgrid for cubic symmetry. | -| `convert_final_to_MNI_matlab` | TRANSFORM | Converts an image from subject space to MNI space using MATLAB. | -| `convert_final_to_MNI_simnibs` | TRANSFORM | Converts an image to MNI space using SimNIBS. | -| `mni2subject_coords_LDfix` | TRANSFORM | Transforms a set of coordinates in MNI space to subject space. | -| `radialExpand2DTo3D` | TRANSFORM | Radially expands 2D axisymmetric data into 3D Cartesian volume. | -| `ras_to_grid` | TRANSFORM | Converts RAS coordinates to voxel (grid) coordinates using NIfTI header transformation matrix. | -| `subject2mni_coords_LDfix` | TRANSFORM | Transforms a set of coordinates in MNI space to subject space. | \ No newline at end of file +#### ACOUSTIC + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `acoustic_analysis` | Compute acoustic safety metrics (Isppa, MI, max pressure) from sensor data, extract tissue-specific maxima, focal distances, and generate overlaid ISPPA plots on segmentation. | +| `acoustic_convert_axisymmetry` | Expand k-Wave axisymmetric (2D rotational) simulation outputs to full 3D (via Β convert_axisymmetric_to_3dΒ ) for heating sims or 2D Cartesian (via Β convert_axisymmetric_to_2dΒ ) otherwise, updating sensor data, parameters, masks, medium, grid, source, and labels. | +| `acoustic_simulation` | Set up acoustic simulation | +| `acoustic_wrapper` | Set up acoustic simulation | + +#### CALIBRATION + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `calc_virtual_elem` | (STANDALONE) Calculate additional virtual elements | +| `calibration_transducer` | Run water simulations to optimize transducer source amplitude and element phases matching a target intensity profile via O’Neil analytical solution, global search, and re-simulation. | +| `compute_oneil_solution` | Compute analytical O’Neil pressure along the beam axis for focused annular transducers in water, derives particle velocity and grid adjustment factor, and plots comparisons with simulated/desired intensities. | +| `compute_phases` | Calculate per-element transducer phases (degrees) for electronic focusing by computing fractional wavelength delays to steer waves constructively to a target point relative to the exit plane. | +| `extract_real_intensity_profile` | Extract or spline-interpolate axial intensity profiles between available measured focal depths (wrt exit plane), aligns peaks, skips near-field for max, and plots/saves results. | +| `extract_simulated_profile` | Extract and visualize simulated acoustic pressure data. | +| `perform_global_search` | Globally optimize transducer element phases (rad) and particle velocity via FEXminimize or GlobalSearch to minimize error fitting O’Neil analytical intensity to measured axial profiles. | +| `phase_optimization_annulus_full_curve` | Optimizes the phase profile for an annular transducer by matching intensity curves. | +| `phase_optimization_annulus` | Optimizes the phase profile by calculating focal distance error for an annular transducer. | +| `plot_opt_sim_results` | Load optimized k-Wave results, generate 2D focal plane intensity maps and axial profile comparisons (simulated vs. analytical O’Neil vs. desired), and save annotated plots. | +| `recompute_oneil_solution` | Recompute O’Neil analytical pressure profile using optimized phases/velocity, convert to intensity, plot comparisons with original/desired profiles (skipping near-field), and report focal metrics. | +| `save_optimized_values` | Append optimized transducer phases and amplitudes into a CSV table (indexed by intensity/focus) and save parameters as YAML for PRESTUS config integration. | +| `scale_real_intensity_profile` | Linearly scale measured focal intensity profile and update transducer source amplitude (via acoustic impedance) to match desired peak Isppa (W/cmΒ²). | +| `set_real_phases` | Load or compute manufacturer-specific (Sonic Concepts/Imasonic) transducer element phases for given focal depth (wrt exit plane), interpolate/unwrap for virtual elements, and return phase degrees. | + +#### CORE + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `load_parameters` | Loads and merges configuration files for simulation parameters. | +| `path_log_setup` | Set up internal paths and logging, filename for output table | +| `simulation_nifti` | Save key outputs as 3D Niftis | + +#### GROUP + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `changem_vectorized` | Replace multiple old values in array A with corresponding new values. | +| `combine_plots_by_suffix` | Combines subject-specific plots into a single montage image. | +| `create_group_MNI_plots` | Generate group-level plots in MNI space for multiple subjects. | + +#### HEAD + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `head_smooth_and_crop` | Convert and smooth segmentations into medium map & crop grid for effiency | +| `preproc_medium_mask` | Map segmentation indices onto the medium labels (in the config) [layered only] | +| `preproc_align_to_focal_axis` | Rotate input image and its grid coordinates to the transducer-focal axis (+Z) | +| `preproc_crop_eCSF` | Expand CSF to define outer edges of layered medium. | +| `preproc_crop_grid` | Crop grid to head + transducer + PML. | +| `preproc_head` | Preprocesses structural brain data for simulations (segmentation, alignment, cropping). | +| `preproc_segmentation` | Setup SimNIBS segmentation | +| `segmentation_run` | Submit SimNIBS segmentation | +| `skull_fill_holes` | Fill holes in skull segmentation and between skull and skin | +| `skull_rubber_wrap_visualize` | Visualize results of skull rubber expansion | +| `skull_rubber_wrap` | Inflate the skull layer to locally fill potential holes | +| `smooth_img` | Apply 3D smoothing | + +#### HELPER + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `check_availability` | Check file availability. | +| `check_layers` | Match requested layers to those available in the segmentation. For (p)CT, homogenize multi-skull layer into a single `skull `layer. | +| `confirm_overwriting` | Check overwriting (manual). | +| `confirmation_dlg` | Present confirmation dialogue. | +| `find_min_factor` | Find the number with the smallest maximum factor in a range. | +| `get_crop_dims` | Computes cropping dimensions for a 3D image with a margin. | +| `get_flhm_center_position` | Calculates the center position of the full-length half-maximum (FLHM). | +| `get_slice_by_label` | Extracts a specific slice from a 3D image based on axis label and slice number. | +| `get_xyz_mesh` | Generates a mesh of 3D coordinates for a given image. | +| `getidx` | Retrieves indices for requested tissues from a parameter structure. | +| `kwave_version` | Display k-Wave version number and (if available) git hash. | +| `log_timer` | Start or stop logs for benchmarking time, RAM, and disk space use. | +| `masked_max_3d` | Computes the maximum intensity within a masked 3D region. | +| `mergeStructure` | Merges multiple scalar structures into one. | +| `read_ini_file` | Read an INI file into MATLAB | +| `round_if_integer` | Rounds values if they are sufficiently close to integers; otherwise raises an error. | +| `simnibs_version` | Get SimNIBS version of segmentation from HTML, print, allocate to `parameters`. | +| `subset_fields` | Copy designated structure field to new structure. | +| `tissuemask_binary` | Extract binary tissue segmentation masks (for indexing) | +| `upsample_to_grid` | Upsamples a 3D image to a higher resolution grid. | +| `zip_fields` | Convert a structure's fields and values into a cell array. | + +#### HPC + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `hpc_detect_system` | Detect SLURM or qsub HPC system. | +| `hpc_job_info` | Generate formatted job display information. | +| `hpc_job_name` | Generate standardized HPC job name. | +| `hpc_setup_temp_files` | Setup directories and generate temporary files. | +| `hpc_submit_job` | Submit HPC batch job (SLURM or qsub). | +| `hpc_validate_parameters` | Validate HPC job parameters. | +| `hpc_wait_for_job` | Monitor HPC job until completion. | + +#### MEDIUM + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `fitPowerLawParamsMulti` | Fit power law absorption parameters for highly absorbing media. | +| `get_alpha_coeff` | Computes the amplitude attenuation coefficient for a given medium and frequency. | +| `medium_properties_nifti` | Save NifTi image of the specified medium property map. | +| `medium_setup` | Set up medium | +| `medium_pct_density` | Skull: pCT-informed density mapping | +| `medium_pct_soundspeed` | Skull: pCT-informed sound speed mapping | +| `medium_pct_attenuation` | Skull: pCT-informed attenuation mapping | + +#### NEURONAV + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `neuronav_compute_series_statistics` | Compute mean position and variability over stimulus train | +| `neuronav_convert_MNI_to_native` | Transform coordinates from MNI space to native subject space | +| `neuronav_convert_native_to_MNI` | Convert native RAS coordinates to MNI space | +| `neuronav_convert_trigger_to_voxels` | Convert Localite trigger positions to voxel (image) coordinates. | +| `neuronav_export_session_csv` | Export per-session coordinate arrays to CSV with labeled voxel and RAS (mm) positions. | +| `neuronav_get_group_mean_mni` | Compute group-level average MNI coordinates (both mm and voxel). | +| `neuronav_select_localite` | Select most recent Localite XML for a session. | +| `position_transducer_localite` | Determines transducer and focus positions in voxel space using Localite data and MRI header. | + +#### PSEUDO-CT + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `fit_pairwiselinear` | Perform a pairwise linear fit between HU and density values with optional plot. | +| `pct_create_pseudoCT` | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | +| `pct_skullexpand` | Load SimNIBS NIfTIs + headers, run skull rubber wrap, save output. | +| `pct_skullmapping` | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. [deprecated, debug] | +| `pct_soft_tissue_peak` | Identifies the soft tissue peak from UTE intensity distribution histograms. | + +#### PLOT + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `plot_coronal_slices` | (DEPRECATED) Visualizes coronal slices of a 3D image with optional legends for labeled images. | +| `plot_median_montage` | (DEPRECATED) Creates a montage of central slices from a 3D T1 image. | +| `plot_overlay_2d` | Overlays map on a background image slice with key positions highlighted. | +| `plot_overlay` | Visualizes map overlaid on a 2D slice of a 3D background image with advanced options. | +| `plot_t1_with_transducer` | Creates a plot of a T1 slice oriented along the transducer's axis with overlays. | +| `plot_transducer_overlay` | Visualize curved transducer geometry with exit plane. | +| `show_3d_head` | Visualizes segmented brain images in 3D with transducer placement and target location. | +| `show_positioning_plots` | Visualizes transducer positioning before and after preprocessing using segmented images. | + +#### SOURCE + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `grid_axisymmetry` | Convert grid to axisymmetry | +| `grid_tissue_setup` | Set up grid dimensions, preprocess head (if modeled), and place in grid | +| `grid_transducer_location` | Position transducer (and target) in simulation grid | +| `source_create` | Creates ultrasound source signals and masks for k-Wave simulations based on transducer geometry.| +| `source_sensor_setup` | Configure a k-Wave simulation grid, transducer sources, and sensor mask based on input parameters for recording pressure fields in 2D or 3D ultrasonic neuromodulation setups. | + +#### THERMAL + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `thermal_analysis` | Analyze output of thermal simulations | +| `thermal_parameters` | Checks and converts protocol timing setup for thermal estimation | +| `thermal_plot_protocol` | Visualize the requested protocol timing | +| `thermal_plot_sim` | Visualizes heating simulation results over time (temperature, rise, CEM43). Optional video | +| `thermal_simulation` | Simulate ultrasound-induced heating and thermal dose (CEM43) using k-Wave’s kWaveDiffusion from acoustic pressure data across pulsed train repetitions. | +| `thermal_update_timeseries` | Track max T/CEM43 per tissue layer. | + +#### TRANSDUCER + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `focal_distance_calculation` | Compute expected focal distances for (multi-)transducer setup. | +| `get_arc` | Generates the coordinates of an arc in 2D space. | +| `get_trans_pos_from_trigger_markers` | Determines transducer and target positions using Localite trigger marker files. | +| `get_transducer_box` | Computes transducer box dimensions and positions for simulations. | +| `transducer_analyze_position_fast` | Analyzes the transducer position relative to the skull and skin. | +| `transducer_analyze_position` | Computes geometric and statistical measures for a transducer position. | +| `transducer_positioning` | Determines heuristic transducer placement for a given target. | +| `tp_candidate_mesh` | Build coordinate mesh and candidate transducer geometry. | +| `tp_evaluate_candidate_positions` | Evaluate candidate transducer positions. | +| `tp_find_initial_candidate` | Find candidate transducer positions on skull surface. | +| `tp_plot_candidate_positions` | Plot transducer candidate positions on skull surface slice. | +| `tp_plot_geometry_overlay` | Visualize transducer geometry on skin segmentation slice. | +| `tp_plot_heuristic_position` | Create visualization of heuristic transducer placement. | +| `tp_remove_ear_locations` | Exclude ear entries from heuristic transducer positions. | +| `tp_select_heuristic_position` | Select optimal transducer position and export Localite coordinates. | +| `transducer_setup` | Create a transducer mask and label matrix for a computational grid. | + +#### TRANSFORM + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `convert_2d_to_axisymmetric` | Converts 2D k-Wave simulation grid, medium properties, and source to axisymmetric form by halving the shorter dimension (right half from center). | +| `convert_axisymmetric_to_2d` | Mirrors axisymmetric simulation data (sensor, medium, masks, source) left-right to full 2D, doubles radial dimension, transposes axes, and updates kgrid/positions. | +| `convert_axisymmetric_to_3d` | Expands 2D axisymmetric data (via radialExpand2DTo3D) to full 3D grid by duplicating radial dimension, updates positions/grid/kgrid for cubic symmetry. | +| `convert_final_to_MNI_matlab` | Converts an image from subject space to MNI space using MATLAB. | +| `convert_final_to_MNI_simnibs` | Converts an image to MNI space using SimNIBS. | +| `mni2subject_coords_LDfix` | Transforms a set of coordinates in MNI space to subject space. | +| `radialExpand2DTo3D` | Radially expands 2D axisymmetric data into 3D Cartesian volume. | +| `ras_to_grid` | Converts RAS coordinates to voxel (grid) coordinates using NIfTI header transformation matrix. | +| `subject2mni_coords_LDfix` | Transforms a set of coordinates in MNI space to subject space. | +| `transform_coordinates` | Transform input coordinates between coordinate systems (wrapper) space. | \ No newline at end of file diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md new file mode 100644 index 00000000..3070f8e9 --- /dev/null +++ b/documentation/doc_getting-started.md @@ -0,0 +1,73 @@ +# Quick Start Guide + +### Install PRESTUS and depencencies + +See [Installation](doc_installation.md). + +### [Optional] Explore demos + +A simplified 2D benchmarking example can be found [here](https://github.com/jkosciessa/PRESTUS_2D_demo). It is a lightweight example that is designed to run on local CPU ressources. + +A 3D demo using SimNIBS' Ernie template is provided as a [DataLad dataset](https://gin.g-node.org/PRESTUS/sim_ernie/). [Note: This example is based on a prior PRESTUS version. It still has to be updated, but provides a practical example of a possible data management scheme.] + +A (currently outdated) demo that focuses on transducer calibration can be found [here](https://github.com/jkosciessa/PRESTUS_bin/blob/main/tutorial/PRESTUS_intro_tutorial.md). + +### Create virtual transducer settings + +See [Transducer] and [Calibration](doc_calibration.md). + +### Create a study-specific config file + +Configuration metadata must be fully specified in (a set of) config files (in the .yaml format) placed in the 'configs' folder (see [the configuration overview](doc_parameters.md). Some parameters found in the 'default_config' are mandatory for the pipeline to run while others can be left out or are expected to be changed based on the requirements of the analysis. The different parameters that can be used are found in the `default_config.yaml` and in [the configuration overview](doc_parameters.md). The default config is not intended to be altered. Instead, parameter changes should be specified via an application-specific config file (or after loading the default config and before performing the subject call in MATLAB). + +The `load_parameters.m` function imports the configuration. Multiple configurations can be read in sequentially to overwrite specific portions of the default configuration. + +### Choose the Simulation Medium + +See [Medium Setup](doc_medium.md). + +### Choose the Simulation Backend + +See [Backend](doc_backend.md). + +### Specify I/O and Planning Images + +Specify folder management for inputs, segmentations, and simulations: + +``` +parameters.ld_library_path +parameters.data_path +parameters.seg_path +parameters.sim_path +parameters.paths_to_add = {pn.kwave, pn.minimize}; +parameters.simnibs_bin_path = fullfile('.conda', 'envs', 'simnibs_env', 'bin'); +``` + +For `layered` simulations, specify at least one T1w planning image (and optionally a T2w/UTE image) for the segmentation. + +``` +parameters.t1_path_template = fullfile(sprintf('m2m_sub-%03d', subject_id), "T1.nii.gz"); +parameters.t2_path_template = fullfile(sprintf('m2m_sub-%03d', subject_id), "T2_reg.nii.gz"); +``` + +### Specify a transducer and target location + +For `layered` simulations, specify locations via `parameters.transducer.trans_pos` and `parameters.transducer.focus_pos`. + +See [Placement](doc_placement.md). + +### [Optional] One-shot SimNIBS segmentation + +It may be desirable to run an intial SimNIBS call prior to running the full pipeline incl. segmentation postprocessing, source setup, acoustic and thermal simulations. By default, existing segmentations will be reused and not overwritten unless explicitly requested with `overwrite_simnibs` (regardless of `overwrite_files`). + +Set `modules.segmentation_only = 1` to stop the pipeline immediately after segmentation, skipping all subsequent steps. This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). + +### [Optional] Create a pseudoCT + +See [pseudoCT](doc_pseudoCT.md). + +### Run the prestus_pipeline | [Optional] iterate across parameters + +When the configuration is fully specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `prestus_pipeline` can be submitted using high performance computing jobs as a part of `prestus_pipeline_start` (see [HPC documentation](doc_hpc.md)). + +PRESTUS can be parallelized across-subject and within-subject across treatment setups, e.g., by iterating across different transducers, transducer placement and target coordinates, or amplitudes and/or focal depth settings (requiring either dynamic [recalibration](doc_calibration.md) using `transducer_calibration` or a precomputed lookup table) via custom MATLAB scripts. diff --git a/documentation/doc_group-plots.md b/documentation/doc_group.md similarity index 84% rename from documentation/doc_group-plots.md rename to documentation/doc_group.md index 0400a2bd..ea604f50 100644 --- a/documentation/doc_group-plots.md +++ b/documentation/doc_group.md @@ -1,22 +1,27 @@ -# How to use the 'create_group_MNI_plots.m' script -## Reasons for using this function +# Group analysis + +To facilitate group analysis, PRESTUS automatically outputs key results as Nifti images in MNI space. Projection to MNI space can be done either using SimNIBS’ subject2mni command, or in MATLAB by reading in MNI2conform_12DOF.txt. + +### create_group_MNI_plots + +#### Rationale - This will ensure that all figures will use the same scale - Allows one to add a ROI MNI mask to visualise targeting accuracy - Adds additional columns to each csv with information about intensity in the ROI mask, percentage of fwhm voxels within ROI etc. - When using structural MRI's, it also allows one to normalise the brightness so the discrepancy in average brightness between individual T1's is reduced. -## Necessary input: +#### Necessary input: - subject_list: list of all subjects that you want to include in the figures. - parameters: load the '.yaml' file using 'load_parameters.m' just as you would do in a pipeline. - options: a structure similar to 'parameters'. Most of these are (as the name would suggest) optional. only one of the two following parameters needs to be selected: - options.slice_to_plot = 0 (give the number of the slice) - options.plot_max_intensity = 0 (turn on by changing to 1) -## Plotting a ROI mask +#### Plotting a ROI mask - Use a mask saved in MNI space - Load it into the function under the option 'ROI_MNI_mask' -## Optional parameters: +##### Optional parameters: options.ROI_MNI_mask (:,:,:) # Loads in a 3d matrix of your ROI mask (in MNI space) options.slice_label = 'y' # Selects the axis along which your slice is made options.rotation = 90; # Rotates your structural background figure diff --git a/documentation/doc_heuristic-placement.md b/documentation/doc_heuristic-placement.md deleted file mode 100644 index eb19d951..00000000 --- a/documentation/doc_heuristic-placement.md +++ /dev/null @@ -1,25 +0,0 @@ -# Heuristic transducer placement - -The function ```transducer_positioning``` identifies heuristic locations for transducer placement. - -The function targets brain regions specified by MNI coordinates, converting them to subject-native space using SimNIBS tools. It aims to identify candidate transducer positions where the geometric focus aligns with a target within a user-defined focal distance range, while ensuring >50% aperture overlaps skull/skin and low variance in distances. For each position, it computes the intersection proportion between the transducer and skin, mean/variance distances to skin and skull, and geometric focus/exit plane positions to ensure efficient coupling while minimizing aberrations. - -## Coordinate table - -The `tpars_sub-XXX_target.csv` table contains transducer position candidates with the following columns. This information can be used to select a suitable candidate (e.g., by selecting a position with low intersection of the transducer and head tissue (prop_intersect < 0.05) and minimal Euclidean distance to the target; often supported by visual inspection). - -| Column | Description | -|-----------------|------------------------------------------------| -| idx | index | -| trans_x | Transducer x-coordinate (voxels) | -| trans_y | Transducer y-coordinate (voxels) | -| trans_z | Transducer z-coordinate (voxels) | -| targ_x | Target x-coordinate (voxels) | -| targ_y | Target y-coordinate (voxels) | -| targ_z | Target z-coordinate (voxels) | -| dist_to_target | Euclidean distance transducer-to-target (voxels)| -| prop_intersect | Proportion of transducer volume intersecting head | -| meandistskin | Mean distance to skin surface in aperture plane (voxels) | -| vardistskin | Variance of distances to skin (voxelsΒ²) | -| meandistskull | Mean distance to skull surface in aperture plane (voxels)| -| vardistskull | Variance of distances to skull (voxelsΒ²) | diff --git a/documentation/doc_installation.md b/documentation/doc_installation.md index 5bfbacf8..2d107fb3 100644 --- a/documentation/doc_installation.md +++ b/documentation/doc_installation.md @@ -1,20 +1,17 @@ # Installation Guide -### PRESTUS installation +## PRESTUS installation Download and install these tools: -- MATLAB (R2022b*) -- [SimNIBS 4.0.0](https://github.com/simnibs/simnibs) (see instructions below) +- `MATLAB (R2023b)`. Other versions may work as well, but R2023b is the current default deployment. Especially on HPCs with GPUs, more recent MATLAB versions can cause issues. The HPC scripts currently hardcode the MATLAB R2023b module on the Donders HPC. +- [`SimNIBS 4`](https://github.com/simnibs/simnibs) (see [SimNIBS installation](#simnibs-installation)) - toolboxes | They are automatically included as submodules if this repository is recursively cloned (see below). They must be added on MATLAB startup. - - [k-Wave (1.4)](https://github.com/ucl-bug/k-wave.git) - - [export_fig](https://github.com/altmany/export_fig) - - [FEX-minimize](https://github.com/rodyo/FEX-minimize.git) - - [xml2struct](https://github.com/joe-of-all-trades/xml2struct) + - [`k-Wave (1.4.1)`](https://github.com/ucl-bug/k-wave.git). While k-Wave 1.4 is supported, version 1.4.1 (currently [GitHub exclusive](https://github.com/ucl-bug/k-wave/releases/tag/v1.4.1)) introduced GPU support for thermal simulations with kWaveDiffusion. We therefore recommend cloning k-Wvae 1.4.1 from GitHub. + - [`export_fig`](https://github.com/altmany/export_fig) + - [`FEX-minimize`](https://github.com/rodyo/FEX-minimize.git) + - [`xml2struct`](https://github.com/joe-of-all-trades/xml2struct) -\* Other versions may work as well, but R2022b was the default deployment. Especially on HPCs with GPUs, more recent MATLAB versions can cause issues. - -Before using the package, you need to have some libraries on your path. Major dependencies are included as submodules in the toolbox folder. If you clone this repository, you can retrieve the submodules as follows: ``` @@ -35,49 +32,116 @@ git submodule init git submodule update ``` -### SimNIBS installation +Ensure that the paths and subfolders are added in MATLAB. See `simple_main.m` for an example. + +#### Donders Institute HPC Cluster + +When working on the Donders High-Performance-Computing cluster, PRESTUS and its dependencies (SimNIBS, k-Wave) are already installed. Note that this may not be the most recent version of either the `main` or `development` branch. + +Type `module load simnibs/4.0.0` (or add the command to your .bashrc so that it is executed automatically once you login) and add `addpath('/opt/prestus/dev')` to your matlab path. If you want to use an older version you can also use `addpath('/opt/prestus/0.2.0')`, or older versions. Now you can start matlab R2022b. + +> `simnibs/4.0.0` and `simnibs/4.1.0` are currently available on the Donders HPC. -You additionally need to install SimNIBS (https://simnibs.github.io/simnibs/build/html/index.html#simnibs-4): e.g., `simnibs_installer/install -s -t /home/USER/SimNIBS`. -We recommend installing SimNIBS within an **anaconda environment** (especially on HPC clusters that often constrict individual user permissions). +> `/opt/prestus/0.1.0`, `/opt/prestus/0.2.0`, and (an outdated) `/opt/prestus/dev` are currently available on the Donders HPC. + +For more information on HPC usage, see the [HPC guide](doc_hpc.md) + +If you want to get started with simulations, you can use the PRESTUS example dataset. This command will copy the dataset to your home directory: + +``` +cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} +``` + +#### [Optional: Download C++ binaries] + +For the `cpp_cpu` and `cpp_gpu` modes, download the C++ binaries from the [k-Wave website](http://www.k-wave.org/download.php) and place them into `k-Wave/binaries/`; see also the instructions [here](http://www.k-wave.org/documentation/kspaceFirstOrder3DC.php). + +For more information on installing, (potentially) compiling, and specifying C++ binaries, see [the backend documentation](doc_backend.md). + +## SimNIBS installation + +You additionally need to install [SimNIBS 4](https://simnibs.github.io/simnibs/build/html/index.html#simnibs-4). We recommend installing SimNIBS within an **anaconda environment** (especially on HPC clusters that often constrict individual user permissions). This allows for the flexible installation of multiple SimNIBS versions. You can follow the [official installation guide](https://simnibs.github.io/simnibs/build/html/installation/conda.html). -The following is a step-by-step guide to install SIMNIBS on the computing cluster. Some steps and filepaths may differ between computing environments. +The following is a step-by-step guide to install SIMNIBS on the Linus computing cluster. Some steps and filepaths may differ between computing environments. 1. Log in to the cluster 2. Open terminal -3. Start an interactive session to come into another execution node (change walltime and memory as needed): `qsub -I -X -N 'simnibs' -l walltime=01:00:00,mem=8g` -4. Download the evironment for SIMNIBS: `wget https://github.com/simnibs/simnibs/releases/latest/download/environment_linux.yml` -5. Copy the generated environment file to your home directory (i.e., /home/neuromod/USER) -6. Navigate to your home directory (i.e., `cd ~`) -7. Load the anaconda environment (e.g., `module load anaconda3/2020.07`) -8. Create the anaconda environment: `conda env create -f ~/environment_linux.yml` -9. Activate the simnibs environment: `source activate simnibs_env` -10. Install the latest version of SIMNIBS: `pip install -f https://github.com/simnibs/simnibs/releases/latest simnibs` -11. [Optional] To setup menu icons, file association, the MATLAB library, and add SimNIBS to the system path, run the postinstall_simnibs script +3. Start an interactive session to come into another execution node (change walltime and memory as needed) + + QSUB: `qsub -I -X -N 'simnibs' -l walltime=01:00:00,mem=8g` + SLURM (Donders default): `srun --pty --x11 -J simnibs -t 1:00:00 --mem=8G bash` -``` -mkdir $HOME/SimNIBS -postinstall_simnibs --setup-links -d $HOME/SimNIBS -``` +4. Download the environment for the desired SIMNIBS version to your home directory (i.e., `/home/neuromod/USER`) + + `wget https://github.com/simnibs/simnibs/releases/download/v4.6.0/environment_linux.yml -O ~/environment_linux_v4.6.0.yml`. This includes dependencies such as Python, MATLAB Runtime, and other prerequisites. + +
+ + > **SimNIBS environment versions** + Multiple SimNIBS 4+ environment versions are available: + https://github.com/simnibs/simnibs/releases/download/v4.0.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.1.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.5.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.6.0/environment_linux.yml + To download the latest environment, select the following: + https://github.com/simnibs/simnibs/releases/latest/download/environment_linux.yml + +
+ +5. Navigate to your home directory (i.e., `cd ~`) +6. Load the anaconda environment + + e.g., `module load anaconda3/2020.07` + +7. Create the anaconda environment + + `conda env create -f ~/environment_linux_v4.6.0.yml -n simnibs_v4.6.0` + + This will create an environment in `~/.conda/envs/`. + +8. Activate the simnibs environment + + `source activate simnibs_v4.6.0` + +9. Install the desired SimNIBS version + + `pip install https://github.com/simnibs/simnibs/releases/download/v4.6.0/simnibs-4.6.0-cp311-cp311-linux_x86_64.whl` + +
+ + > **SimNIBS versions** + Multiple SimNIBS 4+ LINUX versions are available: + https://github.com/simnibs/simnibs/releases/download/v4.0.0/simnibs-4.0.0-cp39-cp39-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.1.0/simnibs-4.1.0-cp39-cp39-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.5.0/simnibs-4.5.0-cp311-cp311-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.6.0/simnibs-4.6.0-cp311-cp311-linux_x86_64.whl + To download the latest environment, select the following: + https://github.com/simnibs/simnibs/releases/latest + +
+ +10. [Optional] To setup menu icons, file association, the MATLAB library, and add SimNIBS to the system path, run the postinstall_simnibs script + + ``` + mkdir $HOME/SimNIBS_v4.6.0 + postinstall_simnibs --setup-links -d $HOME/SimNIBS_v4.6.0 + ``` #### [Optional: Starting SimNIBS after first installation] Exit out of everything and following the steps below for starting SimNIBS after first installation to check whether installation was successful. If installation was successful you should now be able to see the SimNIBS gui. -1. Start an interactive session, e.g., `qsub -I -X -N 'simnibs' -l walltime=01:00:00,mem=8g` +1. Start an interactive session (see Step 3 [above](#simnibs-installation)) 2. `module load anaconda3/2020.07` -3. `source activate simnibs_env` +3. `source activate simnibs_v4.6.0` 4. `simnibs_gui` -5. To close the virtual SimNIBS environment: conda deactivate +5. To close the virtual SimNIBS environment: `conda deactivate` #### Specifying SimNIBS paths in PRESTUS In PRESTUS, specify either in the config or directly in MATLAB both the path to the SimNIBS binaries, and the path to shared libraries for GCC 7.2.0: ``` -parameters.simnibs_bin_path = fullfile('/home', 'neuromod', 'USER', '.conda', 'envs', 'simnibs_env', 'bin'); +parameters.simnibs_bin_path = fullfile('/home', 'neuromod', 'USER', '.conda', 'envs', 'simnibs_v4.6.0', 'bin'); parameters.ld_library_path = "/opt/gcc/7.2.0/lib64"; -``` - -#### [Optional: Download C++ binaries] - -For the `cpp_cpu` and `cpp_gpu` modes, download the C++ binaries from the [k-Wave website](http://www.k-wave.org/download.php) and place them into `k-Wave/binaries/`; see also the instructions [here](http://www.k-wave.org/documentation/kspaceFirstOrder3DC.php). \ No newline at end of file +``` \ No newline at end of file diff --git a/documentation/doc_medium.md b/documentation/doc_medium.md index 5deb221a..72b942eb 100644 --- a/documentation/doc_medium.md +++ b/documentation/doc_medium.md @@ -1,14 +1,15 @@ -# Specifying homo- or heterogeneous medium properties +# Medium properties -### Choose a simulation medium +### Medium specification -PRESTUS supports various medium configurations. These can be specified with ```parameter.simulation_medium```. The following options are supported out of the box. +PRESTUS supports homo- and heterogeneous medium configurations. These can be specified with `parameter.simulation_medium`. The following options are supported out of the box. -- ```water``` +- `water` - Places a transducer into homogeneous water tissue. + Places a transducer into homogeneous water tissue (at the inner edge of the requested PML layer). +
-- ```layered``` +- `layered` Heterogeneous tissue composition based on charm segmentation. By default, the following media are included: @@ -18,24 +19,27 @@ PRESTUS supports various medium configurations. These can be specified with ```p - cortical skull bone - trabecular skull bone - Tissue can be removed (or added if more detailed segmentations and tissue properties are included) via the ```layers``` and ```medium``` configuration fields. The skull layer of a layered simulation can be informed by (pseudo-)CT images (see ```doc_pseudoCT```). This is a subtype of a layered simulation with a single skull layer. - -- ```phantom``` + Tissue can be removed (or added if more detailed segmentations and medium acoustic properties are included) via the ```layers``` configuration fields. The skull layer of a layered simulation can be informed by (pseudo-)CT images (see ```doc_pseudoCT```). The pCT implementation is a subtype of a `layered` simulation with a unified skull layer. If the segmentation for any requested layer is not available, requested layers will be removed from the specification. The output HTML provides an overview of which layers were modeled, along with their acoustic properties. + + `Layered` media will be [preprocessed](doc_preproc.md) during grid setup. +
- A 3D imaging-based simulation is not always necessary. For benchmarking, one may for instance externally design a 2D phantom, and run simulations in a well-defined space. As the 2D phantom is explicitly designed, no preprocessing (e.g., rotation, cropping, etc.) is necessary. Using the ```phantom``` flag allows the same tissue flexibility as the ```layered``` version, but maps segmentation files onto medium masks without any further image processing. The 2D segmentation phantom must be provided as a ```final_tissues.nii.gz``` file in a (dummy) SimNIBS output folder. +- `phantom` -PRESTUS allows for a flexible specification of segmentation layers to media to which acoustic properties are allocated. This is controlled by `parameters.layers`. If a segmentation for the requested layer is not available, requested layers will be removed from the specification. All segmentation ids that are not explicitly specified as other layers are automatically included in a water layer. + A 3D imaging-based simulation is not always necessary. For debugging and benchmarking, one may for instance externally design a 2D phantom, and run simulations in a well-defined space. As the 2D phantom is explicitly designed, no preprocessing (e.g., rotation, cropping, etc.) is necessary. Using the ```phantom``` flag allows the same tissue flexibility as the ```layered``` version, but maps segmentation files onto medium masks without any further image processing. The 2D segmentation phantom must be provided as a ```final_tissues.nii.gz``` file in a (dummy) SimNIBS output folder. + + A `phantom` Nifti is expected to contain artificial layers of a SimNIBS segmentation, including the PML padding. +
-To specify a `phantom`, indicate artificial layers of a SimNIBS segmentation. +### Medium acoustic properties -Internally computed medium maps will contain indices corresponding to the layer position in the medium properties. This allows for flexibility in layer specifications with stable medium ids (unless `parameters.medium` is edited). +![medium_properties](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/medium_properties.png) -### Default medium properties +Accurate modeling of wave propagation through heterogeneous media requires valid assignment of acoustic parameters such as speed of sound, density, and attenuation to the separate tissue media. PRESTUS provides defaults based on the literature, while providing flexibility to alter assigned parameter values, e.g., to implement more conservative or liberal assumptions. -The following are the current default medium properties in a full layered simualtion. Parameters are specified in the default_config.yaml file (see doc_config.md). -When a homogeneous skull layer is requested, the default assumes the medium properties of cortical bone. +The following default medium properties applt in a full layered simulation. Parameters are specified in the default_config.yaml file (see doc_parameters.md). While PRESTUS’s default parameters are chosen to align with commercially available tools and prior benchmarks, these should not be regarded as ground truths, and may need to be adjusted according to the goals at hand (e.g., a more conservative assessment of intensity and/or heating). -| Tissue | Density [kg/mΒ³] | Sound Speed [m/s] | Attenuation Coefficient [dB/(cmΒ·MHzy)] | Attenuation Power Law (y)| Thermal Conductivity [W/(mΒ·K)]| Specific Heat [J/(kgΒ·K)] | Perfusion [mL/min/kg] | Absorption Fraction [Note1] | +| Tissue | Density [kg/mΒ³] | Sound Speed [m/s] | Attenuation Coefficient [dB/(cmΒ·MHzy)] | Attenuation Power Law (y)| Thermal Conductivity [W/(mΒ·K)]| Specific Heat [J/(kgΒ·K)] | Perfusion [mL/min/kg] | Absorption Fraction | |-----------------------|--------|----------|----------|-------|--------|------|-------|---| | Water | 994 | 1500 | 0.00217 | 2 | 0.6 | 4178 | 0 | 1 | | Brain | 1046 | 1546 | 0.59 | 1.2 | 0.51 | 3630 | 559 | 1 | @@ -44,10 +48,13 @@ When a homogeneous skull layer is requested, the default assumes the medium prop | Skull (cortical) | 1850 | 2800 | 13.3 | 1 | 0.32 | 1313 | 10 | 0.28 | | Skull (trabecular) | 1700 | 2300 | 13.3 | 1 | 0.32 | 2274 | 30 | 0.28 | -[Note1] The fraction of attenuation (alpha) that is assumed to be absorbed in the bioheat equation (heating simulations). This is a best guess that is informed as follows: The bulk attenuation coefficient includes contributions from both longitudinal and shear waves. However, longitudinal waves contribute most (~ 80%, Wang et al., 2018) to absorption and heating in the skull (Pinton et al., 2012; White et al., 2006). For longitudinal and shear waves, absorption values of 2.7 dB/cm2/MHz, and 5.4 dB/cm2/MHz have been reported (Pinton et al., 2012), with a bulk attenuation of 13.3 dB/cm2/MHz. As such, we assume: absorption fraction = (longitudinal wave absorption + 0.2 * shear wave absorption) / bulk attenuation. This yields (2.7+0.2*5.4)/13.3 = 0.28. - -*References* +> **Note: Absorption Fraction.** For thermal simulations, PRESTUS models the fraction of attenuation that is assumed to be absorbed in the bioheat equation. The current default estimate is informed as follows: The bulk attenuation coefficient includes contributions from both longitudinal and shear waves. However, longitudinal waves contribute most (~ 80%, Wang et al., 2018) to absorption and heating in the skull (Pinton et al., 2012; White et al., 2006). For longitudinal and shear waves, absorption values of 2.7 dB/cm2/MHz, and 5.4 dB/cm2/MHz have been reported (Pinton et al., 2012), with a bulk attenuation of 13.3 dB/cm2/MHz. As such, we assume: absorption fraction = (longitudinal wave absorption + 0.2 * shear wave absorption) / bulk attenuation. This yields (2.7+0.2*5.4)/13.3 = 0.28. This estimate of the bone absorption fraction (28%) is substantially lower than the default of 100% implemented in k-Plan, but larger than the default implemented in BabelBrain (16%). More measurements are needed to decrease uncertainty about the relative acoustic energy absorption in the skull layer. -- Pinton G, Aubry J, Bossy E, Muller M, Pernot M, Tanter M. Attenuation, scattering, and absorption of ultrasound in the skull bone. Méd Phys 2012;39:299–307. https://doi.org/10.1118/1.3668316. -- Wang X-D, Lin W-J, Su C, Wang X-M. Influence of mode conversions in the skull on transcranial focused ultrasound and temperature fields utilizing the wave field separation method: A numerical study. Chin Phys B 2018;27:024302. https://doi.org/10.1088/1674-1056/27/2/024302. -- White PJ, Clement GT, Hynynen K. Longitudinal and shear mode ultrasound propagation in human skull bone. Ultrasound Med Biol 2006;32:1085–96. https://doi.org/10.1016/j.ultrasmedbio.2006.03.015. +
+ + References:
+ Pinton, G., Aubry, J., Bossy, E., Muller, M., Pernot, M., & Tanter, M. Attenuation, scattering, and absorption of ultrasound in the skull bone. MΓ©d Phys 39, 299–307 (2012). https://doi.org/10.1118/1.3668316.
+ Wang, X.-D., Lin, W.-J., Su, C., & Wang, X.-M. Influence of mode conversions in the skull on transcranial focused ultrasound and temperature fields utilizing the wave field separation method: A numerical study. Chin Phys B 27, 024302 (2018). https://doi.org/10.1088/1674-1056/27/2/024302.
+ White, P. J., Clement, G. T., & Hynynen, K. Longitudinal and shear mode ultrasound propagation in human skull bone. Ultrasound Med Biol 32, 1085–1096 (2006). https://doi.org/10.1016/j.ultrasmedbio.2006.03.015. +
+
\ No newline at end of file diff --git a/documentation/doc_modules.md b/documentation/doc_modules.md new file mode 100644 index 00000000..4b0f2d96 --- /dev/null +++ b/documentation/doc_modules.md @@ -0,0 +1,61 @@ +# Modules + +`prestus_pipeline` is composed of discrete, independently toggleable modules. Each module corresponds to a stage of the TUS simulation workflow and can be enabled or disabled via `modules.*` flags in the config. This allows partial runs β€” for example, re-running only analysis after changing output thresholds, or stopping after segmentation before committing to a full simulation. + +--- + +## Pipeline stages + +``` +load_parameters + β”‚ + β–Ό +[segmentation] SimNIBS charm β€” structural MRI β†’ tissue labels + β”‚ modules.segmentation_only = 1 β†’ stop here + β–Ό +[grid setup] modules.run_grid_setup + β”‚ Crop & orient head, position transducer & target + β–Ό +[medium setup] modules.run_medium_setup + β”‚ Map tissue labels β†’ acoustic/thermal properties + β–Ό +[source setup] modules.run_source_setup + β”‚ Construct k-Wave source from transducer geometry + β–Ό +[acoustic simulation] modules.run_acoustic_sims + β”‚ k-Wave pressure field simulation + β–Ό +[acoustic analysis] modules.run_acoustic_analysis + β”‚ Extract Isppa, Ispta, focal metrics; write CSV + β–Ό +[thermal simulation] modules.run_heating_sims + β”‚ k-Wave heat diffusion; requires acoustic results + β–Ό +[thermal analysis] (run automatically when heating results available) + β”‚ + β–Ό +[NIfTI export] modules.run_nifti_creation + β”‚ Write pressure/temperature maps in subject space + β–Ό +[report] modules.generate_report + β”‚ Self-contained HTML report + β–Ό +[free-water reference] modules.run_posthoc_water_sims + Re-run acoustics in water for Isppa normalisation +``` + +--- + +## Common partial-run patterns + +| Goal | Settings | +|---|---| +| Run SimNIBS segmentation only | `modules.segmentation_only = 1` | +| Re-run from acoustic simulation onwards | `modules.run_grid_setup = 0`, `run_medium_setup = 0`, `run_source_setup = 0` | +| Add thermal simulation to completed acoustic run | `modules.run_heating_sims = 1`; all others can remain `1` | +| Regenerate analysis outputs only | `run_acoustic_sims = 0`, `run_heating_sims = 0` | +| Skip free-water reference | `modules.run_posthoc_water_sims = 0` | + +> **Note:** Downstream modules depend on outputs from upstream ones. If intermediate files already exist on disk (e.g. from a prior run), PRESTUS reuses them subject to `io.overwrite_files`. If they do not exist, disabling an upstream module while enabling a downstream one will error. + +--- diff --git a/documentation/doc_neuronav.md b/documentation/doc_neuronav.md deleted file mode 100644 index 20cfc88e..00000000 --- a/documentation/doc_neuronav.md +++ /dev/null @@ -1,93 +0,0 @@ -Extracting neuronavigation coordinates for post-hoc ultrasound simulations is critical to verify and predict the focus of the realized stimulation sites during neuromodulation experiments. Neuronavigation systems provide spatial tracking of the ultrasound transducer position relative to the individual subject’s anatomy in real-time. - -PRESTUS provides functions that aim to facilitate the read-in and preprocessing of positions recorded with the Localite system. Multiple triggers can be sent during stimulation to acquire updates to the transducer location over time. Recorded locations will be recorded by localite to files named `TMSTrigger/TriggerMarkers_Coil0_'. - -Recorded locations will be averaged within a position (associated with a continuous time stamp stream). Multiple positions per recording session are supported. Average transducer and target locations can be provided in multiple coordinate spaces (native image space being the most relevant for PRESTUS). The functions also facilitate standard-space definitons (experimental), e.g., to plot average transducer positions in standard space. - -To get started, see an example demo (wihtout provided data) in '/examples/demo_localite.m'. - -### Relevant spaces and coordinate systems - -1. **Subject Space [planning image] (voxels)** - - Type: Integer (i, j, k) indices. E.g., 93 116 78 - - Reference: Specific image grid (subject’s T1 MRI, subject’s own segmentation, or MNI template image) - - Usage: Access data arrays; relates directly to matrix dimensions. - - Orientation: Based on image storage; mapping real anatomy depends on image header β€œtransform”. - - Columns in output CSV: - - `Mtrans_pos_x`, `Mtrans_pos_y`, `Mtrans_pos_z` - - `Mtarget_pos_x`, `Mtarget_pos_y`, `Mtarget_pos_z` -2. **Subject Space [segmentation image] (voxels)** - - same as above, but for the simnibs segmentation image -3. **World (Scanner) Space / RAS (Right-Anterior-Superior) (mm)** - - Type: Real (x, y, z) coordinates, in millimeters. - - Reference: Physical space of scanner (native) or MNI brain. - - Usage: Anatomical localization; input for nonlinear warps and overlays across images. - - Orientation: RAS (Right-Anterior-Superior, SPM/FreeSurfer/FSL convention); origin location varies by header (e.g., AC). -4. **MNI Space RAS (Right-Anterior-Superior) (mm)** - - Type: Real (x, y, z) in millimeters. - - Reference: Standard stereotactic space, e.g., MNI152 (often referred to as β€œworld” space in standard template). - - Usage: Cross-subject reporting, group analysis, publication, plotting in standard space. - - Note: Not usually 0-centered; (0,0,0) refers to AC in MNI, which can be off-center in template volume. - - Columns in output CSV (nonlinear deformation field (e.g., `Conform2MNI_nonl.nii.gz`) using the SimNIBS/CHARM pipeline): - - `Mtrans_pos_MNI_x`, `Mtrans_pos_MNI_y`, `Mtrans_pos_MNI_z` - - `Mtarget_pos_MNI_x`, `Mtarget_pos_MNI_y`, `Mtarget_pos_MNI_z` -5. **MNI Space (voxels)** - - Type: Integer voxel (i, j, k) indices within a template MNI image. - - Reference: Specific image (often 181Γ—217Γ—181 grid), for operations like drawing spheres or extracting image data. - - Conversion: Use template’s affine matrix to convert MNI mm ↔ MNI voxel indices. - ``` - template_affine = niftiinfo('MNI152_T1_1mm.nii.gz').Transform.T; - inv_affine = inv(template_affine); - mni_vox = (inv_affine * [mni_mm 1]')'; % yields [i j k 1], take [1:3] - ``` - -### Coordinate Spaces used in PRESTUS neuronav functions - -- neuronav_convert_trigger_to_voxels - - Input: Localite/trigger data (usually in RAS mm) - - Converts: RAS mm β†’ subject (native) voxel space - - Output: grid/voxel indices (i,j,k for subject’s T1) for targets/transducers -- neuronav_grid_to_mm_batch - - Input: Subject voxel indices (i,j,k) - - Operation: Uses subject’s T1 NIfTI affine transform - - Output: Subject native mm coordinates (in scanner/world space) -- neuronav_apply_deformation (forward warp) - - Input: Native mm locations - - Operation: Queries subject’s Conform2MNI_nonl.nii.gz (subject-native β†’ MNI (mm)) - - Output: MNI mm coordinates (for reporting/plotting/averaging) -- neuronav_get_group_mean_mni - - Input: CSVs containing columns like Mtrans_pos_MNI_x/y/z (all from previous function outputs) - - Operation: Aggregates these MNI mm locations across subjects/sessions, computes mean (in mm) - - Output: MNI mm group-mean locations (still in millimeters, not voxels) -- neuronav_apply_inverse_deformation (inverse warp) - - Input: MNI mm group-mean locations (x, y, z in mm) - - Operation: Uses subject’s MNI2Conform_nonl.nii.gz nonlinear field to map standard points back into that subject’s native space. - - Output: Native mm location (in subject’s world/scanner space, for that subject) -- neuronav_mm_to_voxel (helper) - - Input: Native mm (x, y, z) location - - Operation: Uses subject T1 affine to map mm β†’ voxel (i, j, k) - - Output: Subject voxel indices for subsequent image work or storage -- neuronav_export_session_csv - - Columns include: - - Native (subject) voxel indices for target/transducer: (i, j, k from subject’s grid) - - Native mm coordinates (if desired): (x, y, z in subject’s scanner space) - - MNI mm coordinates for each (from deformation step): (x, y, z in standard space) - - MNI voxel indices (may be inaccurate with limited FOVs) - -#### Example Transform order (per subject) - 1. Trigger to Voxel (neuronav_convert_trigger_to_voxels): β†’ subject voxel indices - 2. Voxel β†’ mm (neuronav_grid_to_mm_batch): β†’ subject mm - 3. Native mm β†’ MNI mm (neuronav_apply_deformation): β†’ MNI mm - # [Optional]: Interpolate average positions across subjects - 4. Aggregate means in MNI mm across subjects (neuronav_get_group_mean_mni) - 5. Group mean MNI mm β†’ subject mm (neuronav_apply_inverse_deformation) - 6. Subject mm β†’ subject voxel (neuronav_mm_to_voxel) - -#### Available transforms - -PRESTUS reuses available nonlinear transform matrices from SimNIBS. - -- CHARM’s `Conform2MNI_nonl.nii.gz` is a nonlinear deformation field that maps points from an individual SUBJECT’S SCANNER/CONFORM (native) space to MNI (template) space, expressed in millimeters. -- `MNI2Conform_nonl.nii.gz` is the inverse of `Conform2MNI_nonl.nii.gz`. It is a 4D nonlinear deformation field created by CHARM/SimNIBS that maps coordinates from MNI (template) space back into the subject’s native millimeter (mm) space. - -- **Note:** recorded localite coordinates are relative to a neuronavigation planning image [1] (e.g., `T1_forneuronav`) that was loaded in the neuronavigation software. SimNIBS' nonlinear transform matrices to MNI are relative to the `final_tissues.nii.gz` segmentation image [2] however, so we first need to apply a transform from the planning to the segmentation image to the coordinates before they can be ported to MNI space. \ No newline at end of file diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index cbcd9fdb..735708d9 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -1,83 +1,178 @@ -# Simulation outputs - -PRESTUS provides multiple outputs. Some of these outputs are optional (or can be deactivated upon request to save space.) - -#### Summary table - -Filename: sub-XXX__output_table.csv - -This output table provides an overview of key metrics. - -**Acoustic simulation** - -| Parameter | Description | -|-------------------------------|-----------------------------------------------------| -| subject_id | Unique identifier for the subject | -| max_Isppa | Maximum intensity [W/cmΒ²] | -| max_Isppa_after_exit_plane | Maximum intensity after the exit plane [W/cmΒ²] | -| real_focal_distance | Empirical focal distance (distance between transducer and max. intensity in brain medium (if modelled) or in any medium beyond the exit plane) [mm] | -| max_Isppa_skin | Maximum intensity (skin medium) [W/cmΒ²] | -| max_Isppa_skull | Maximum intensity (skull medium) [W/cmΒ²] | -| max_Isppa_brain | Maximum intensity (brain medium) [W/cmΒ²] | -| max_pressure_skin | Maximum pressure (skin medium) [Pa] | -| max_pressure_skull | Maximum pressure (skull medium) [Pa] | -| max_pressure_brain | Maximum pressure (brain medium) [Pa] | -| max_MI_skin | Maximum Mechanical Index (skin medium) | -| max_MI_skull | Maximum Mechanical Index (skull medium) | -| max_MI_brain | Maximum Mechanical Index (brain medium) | -| Ix_brain | X-coordinate of maximum intensity (brain medium) | -| Iy_brain | Y-coordinate of maximum intensity (brain medium) | -| Iz_brain | Z-coordinate of maximum intensity (brain medium) | -| trans_pos_final_1 | X-coordinate of final transducer position | -| trans_pos_final_2 | Y-coordinate of final transducer position | -| trans_pos_final_3 | Z-coordinate of final transducer position | -| focus_pos_final_1 | X-coordinate of final focus position | -| focus_pos_final_2 | Y-coordinate of final focus position | -| focus_pos_final_3 | Z-coordinate of final focus position | -| isppa_at_target | Intensity value at the target location [W/cmΒ²] | -| avg_isppa_around_target | Average intensity around the target [W/cmΒ²] | -| half_max_ISPPA_volume_brain | Volume of brain with half maximum intensity | - -**Thermal simulation** - -| Parameter | Description | -|-------------------------------|-----------------------------------------------------| -| maxT | Maximum temperature (global medium) [Β°C] | -| maxCEM43 | Maximum CEM43 thermal dose (global medium) | -| maxT_brain | Maximum temperature (brain medium) [Β°C] | -| maxT_skull | Maximum temperature (skull medium) [Β°C] | -| maxT_skin | Maximum temperature (skin medium) [Β°C] | -| riseT_brain | Temperature rise (brain medium) [Β°C] | -| riseT_skull | Temperature rise (skull medium) [Β°C] | -| riseT_skin | Temperature rise (skin medium) [Β°C] | -| CEM43_brain | CEM43 Thermal dose (brain medium) [mins] | -| CEM43_skull | CEM43 Thermal dose (skull medium) [mins] | -| CEM43_skin | CEM43 Thermal dose (skin medium) [mins] | - -#### 2D/3D NIFTI images - -PRESTUS outputs 2D or 3D images (depending on the input) of the following metrics, which can be used to calculat additional metrics post-hoc. -Images are provided in subject-space (```_orig_coord_```) and in MNI-152 space (```_MNI```). - -- medium_masks -- isppa [acoustic] -- MI [acoustic] -- pressure [acoustic] -- heating [heating] -- heatrise [heating] -- CEM43 [heating] - -#### MATLAB structures - -PRESTUS saves an overview of the parameters used to run the simulation and by default saves structures from acoustic and heating simulations. If these are detected in the results folder (and overwriting is deactivated), they will be loaded instead of performing the calculation. - -- sub-XXX__parameters.mat | simulation parameters -- sub-XXX__kwave_source.mat | k-Wave source | parameters, kgrid, trans_pos_final, focus_pos_final -- sub-XXX__results.mat | acoustic simulation outputs [can be deactivated via ```savemat``` = 0] -- sub-XXX__heating_res.mat | thermal simulation outputs [can be deactivated via ```savemat``` = 0] - -#### Figures - -PRESTUS provides multiple figures for quick visual inspection and debugging. - -TBD \ No newline at end of file +# Simulation outputs + +PRESTUS provides multiple outputs. Some of these outputs are optional (or can be deactivated upon request to save space.) + +--- + +## Summary table + +Filename: `sub-XXX__output_table.csv` + +All quantitative metrics are written to a single CSV file. Acoustic metrics are written first; thermal metrics are appended if heating simulations were run. Tissue-specific columns are only present for layered simulations. + +### Intensity nomenclature + +All intensity values are **pulse-average intensity (IPA)**, computed from the steady-state peak pressure amplitude as: + +``` +I = pΒ² / (2ρc) [W/cmΒ²] +``` + +where `p` is the peak pressure amplitude, `ρ` is density, and `c` is sound speed. The cycle-averaging factor of 2 reflects that `p` is the amplitude of a sinusoid β€” this yields the time-average intensity *during* the pulse, i.e. IPA, not the temporal peak (ITP, which would lack the factor of 2) and not the temporal average over the full repetition interval (ISPTA, which would additionally apply the duty cycle PD/PRI). + +The **spatial peak** of IPA is ISPPA. When IPA is reported at a specific location (target point, radius average) rather than at the spatial maximum, it is labelled IPA. + +The **mechanical index** at each voxel is defined as MI = p⁻ / √f, where p⁻ is the peak negative pressure in MPa and f is the centre frequency in MHz. Note: PRESTUS uses `p_max_all` (peak positive pressure) as a proxy for p⁻; a direct negative-pressure map would require additional sensor configuration. + +An overview of the nomenclature is provided in [Darmani et al. (2022, Clinical Neurophysiology)](https://doi.org/10.1016/j.clinph.2021.12.010). + +![intensity_nomenclature](https://ars.els-cdn.com/content/image/1-s2.0-S1388245721008920-gr2.jpg) + +
+ + References:
+ Darmani, G., Bergmann, T. O., Butts Pauly, K., Caskey, C. F., de Lecea, L., Fomenko, A., Fouragnan, E., Legon, W., Murphy, K. R., Nandi, T., Phipps, M. A., Pinton, G., Ramezanpour, H., Sallet, J., Yaakub, S. N., Yoo, S. S., & Chen, R. (2022). Non‑invasive transcranial ultrasound stimulation for neuromodulation. Clinical Neurophysiology, 133(7), 167–177. https://doi.org/10.1016/j.clinph.2022.03.011 +
+
+ +### Acoustic metrics + +#### Global + +| Column | Unit | Description | +|---|---|---| +| `subject_id` | β€” | Subject identifier | +| `freq_Hz` | Hz | Transducer centre frequency | +| `Isppa` | W/cmΒ² | ISPPA β€” maximum pulse-average intensity across the entire simulation grid | +| `Isppa_after_exitplane` | W/cmΒ² | Maximum IPA beyond the transducer exit plane (or within brain if modelled) | +| `real_focal_distance_mm` | mm | Distance from the transducer face to the location of maximum IPA | +| `Ipa_target` | W/cmΒ² | IPA at the specified target coordinate | +| `Ipa_target_radius` | W/cmΒ² | Mean IPA within a sphere of radius `analysis.focus_area_radius` mm centred on the target | +| `trans_pos_vox` | voxels | Transducer position in simulation grid coordinates | +| `focus_pos_vox` | voxels | Target focus position in simulation grid coordinates | + +Water/free-field simulations additionally include `Psptp` (spatial peak temporal peak pressure) and `Ptp_target` (temporal peak pressure at the target). + +#### Tissue-specific *(layered simulations only)* + +| Column | Unit | Description | +|---|---|---| +| `Isppa_brain` | W/cmΒ² | Maximum IPA within brain tissue (GM + WM) | +| `Isppa_skull` | W/cmΒ² | Maximum IPA within skull bone | +| `Isppa_skin` | W/cmΒ² | Maximum IPA within skin | +| `Psptp_brain` | Pa | Spatial peak temporal peak pressure within brain tissue | +| `Psptp_skull` | Pa | Spatial peak temporal peak pressure within skull bone | +| `Psptp_skin` | Pa | Spatial peak temporal peak pressure within skin | +| `Ptp_target` | Pa | Temporal peak pressure at the target coordinate | +| `MI_brain` | β€” | Maximum mechanical index within brain tissue (GM + WM) | +| `MI_skull` | β€” | Maximum mechanical index within skull bone | +| `MI_skin` | β€” | Maximum mechanical index within skin | +| `MI_tc` | β€” | **Transcranial MI** β€” maximum mechanical index across all intracranial voxels (WM, GM, CSF, blood; skull bone excluded). Primary safety metric for cavitation risk at the sonication target. ITRUSST limit: 1.9 | +| `Ix_brain_vox`, `Iy_brain_vox`, `Iz_brain_vox` | voxels | Grid coordinates of peak IPA within brain | +| `halfmax_ISPPA_volume_brain_mm3` | mmΒ³ | Volume of the βˆ’6 dB focal region within brain (voxels where IPA β‰₯ 50 % of `Isppa_brain`) | + +--- + +### Thermal metrics *(only written when heating simulations are run)* + +Temperature is simulated over the full sonication protocol (on/off cycles) using the Pennes bioheat equation. All values are spatial maxima within the respective tissue. + +#### Global + +| Column | Unit | Description | +|---|---|---| +| `maxT` | Β°C | Global maximum temperature reached at any point during sonication | +| `endT` | Β°C | Global maximum temperature at the end of the last sonication pulse | +| `maxCEM43` | min | Global maximum CEM43 over the full protocol | +| `maxCEM43end` | min | Global maximum CEM43 at end of last pulse | + +#### Tissue-specific + +| Column | Unit | Description | +|---|---|---| +| `maxT_brain/skull/skin` | Β°C | Maximum temperature in each tissue during sonication. ITRUSST limit: 39 Β°C | +| `endT_brain/skull/skin` | Β°C | Maximum temperature in each tissue at end of last pulse | +| `riseT_brain/skull/skin` | Β°C | Maximum temperature rise above baseline (maxT βˆ’ baseline). ITRUSST limit: 2 Β°C | +| `rise_endT_brain/skull/skin` | Β°C | Temperature rise above baseline at end of last pulse | +| `CEM43_brain/skull/skin` | min | Maximum CEM43 in each tissue. ITRUSST limits: brain 2 min, skull 16 min, skin 21 min | +| `CEM43_end_brain/skull/skin` | min | CEM43 in each tissue at end of last pulse | + +--- + +### Non-significant risk limits + +The HTML report flags each metric against the ITRUSST consensus safety limits for transcranial ultrasound (Aubry et al., 2025, Brain Stimulation): + +| Metric | Limit | +|---|---| +| MI transcranial | 1.9 | +| Temperature rise (a) | 2 Β°C | +| Maximum temperature (b) | 39 Β°C | +| CEM43 (brain) (c) | 2 min | +| CEM43 (skull) (c) | 16 min | +| CEM43 (skin) (c) | 21 min | + +For thermal estimates, non-significant risk can be characterized by a, b, or c. NSR limits above and in the HTML are provided for quick reference. Always check the original reference for details and check for possible consensus updates. PRESTUS does not make independent recommendations. + +--- + +## NIfTI images + +PRESTUS exports 3D volumes (or 2D for axisymmetric simulations) in NIfTI format, provided in subject-native space (`_orig_coord`) and MNI-152 space (`_MNI`). + +Filename pattern: `sub-XXX_final__MNI.nii.gz` + +| Type | Description | +|---|---| +| `medium_masks` | Tissue label mask (integer indices) | +| `intensity` | Pulse-average intensity (IPA) [W/cmΒ²] | +| `MI` | Mechanical index | +| `pressure` | Peak pressure [Pa] | +| `heating` | Maximum temperature during sonication [Β°C] | +| `heating_end` | Temperature at end of last pulse [Β°C] | +| `heatrise` | Temperature rise above baseline [Β°C] | +| `heatrise_end` | Temperature rise above baseline at end of last pulse [Β°C] | +| `CEM43` | CEM43 thermal dose [min] | +| `CEM43_end` | CEM43 at end of last pulse [min] | + +--- + +## MATLAB structures + +PRESTUS saves simulation parameters and, by default, intermediate matrices. If matrices are detected in the output folder and `io.overwrite_files` is set to `never`, they are loaded instead of recomputing. Matrix saving can be disabled globally via `io.save_matrices = 0`. + +| File | Description | +|---|---| +| `sub-XXX__parameters.mat` | Full parameters struct used for the simulation | +| `sub-XXX_after_rotating_and_scaling.mat` | Head volume after grid scaling | +| `sub-XXX_after_cropping_and_smoothing.mat` | Cropped head including medium masks | +| `sub-XXX__kwave_source.mat` | k-Wave source definition | +| `sub-XXX__results.mat` | Acoustic simulation sensor data | +| `sub-XXX__heating_res.mat` | Thermal simulation results | + +--- + +## Figures + +PRESTUS saves PNG overlays of intensity and temperature on the segmentation for quick visual inspection. + +![PRESTUS_fig_example_thermal](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/thermal_fig_examples.png) + +--- + +## HTML report + +A self-contained HTML report is written to the output directory (`sub-XXX__report.html`). It includes: + +- **Safety dashboard** β€” color-coded cards for all ITRUSST-limited metrics (green / amber / red) +- **Simulation summary** β€” key acoustic and thermal values at a glance +- **Configuration summary** β€” parameters used for the run +- **Medium properties** β€” per-tissue acoustic and thermal properties (layered only) +- **Acoustic results** β€” intensity overlay images and the full acoustic metrics table +- **Thermal results** β€” temperature and CEM43 plots (if heating simulations were run) +- **Post-hoc water simulation** β€” free-field reference results (if run) +- **Log** β€” full pipeline execution log + +![PRESTUS_html](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/html_report.png) + +A full example report on a phantom can be found here: [πŸ”— Download Example HTML](https://raw.githubusercontent.com/jkosciessa/PRESTUS_bin/main/examples/example_report.html) diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md new file mode 100644 index 00000000..4445e5f1 --- /dev/null +++ b/documentation/doc_parameters.md @@ -0,0 +1,328 @@ +## PRESTUS parameters + +Parameters are organised in nested structs that map directly to YAML keys. PRESTUS uses a two-layer configuration system: `default_config.yaml` defines all parameters and their default values and should not be edited. A study-specific config (e.g. `config_study.yaml`) is loaded on top and only needs to specify values that differ from the defaults. Parameters can also be set or overridden programmatically in MATLAB before calling `prestus_pipeline_start(parameters)`. Mandatory parameters have no meaningful default and must always be provided. + +--- + +### General + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `subject_id` | Subject identifier used for file naming and folder management. | β€” | Mandatory. Set before calling `prestus_pipeline_start(parameters)`. | +| `platform` | Execution platform. | `'auto'` | `auto` / `slurm` / `qsub` / `matlab` | + +--- + +### Simulation type (`simulation`) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `medium` | Simulation medium type. | `'layered'` | `water` / `layered` / `phantom`. Mandatory. | +| `code_type` | k-Wave backend. | `'matlab_gpu'` | `matlab_cpu` / `matlab_gpu` / `cpp_cpu` / `cpp_gpu`. See [doc_backend.md](doc_backend.md). | +| `precision` | Computational precision for acoustic and thermal simulations. | `'single'` | `single` / `double` | +| `interactive` | Interactive mode with prompts and evolving plots. | `0` | `1 = yes`, `0 = no`. If `0`, see `overwrite_files`. | +| `debug` | Verbose debug mode with additional intermediate outputs. | `1` | `1 = yes`, `0 = no` | + +--- + +### Data paths (`path`) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `anat` | Absolute path to structural input data. | β€” | Mandatory | +| `sim` | Absolute path to simulation outputs. | β€” | Mandatory | +| `seg` | Absolute path to SimNIBS segmentations (m2m folders). | β€” | Mandatory | +| `localite` | Path to Localite neuronavigation output folder. | β€” | Optional | +| `t1_pattern` | T1 image path template relative to `path.anat`. | `'sub-%1$03d_T1w.nii*'` | Supports `%03d`-style subject ID substitution | +| `t2_pattern` | T2 image path template relative to `path.anat`. | `'sub-%1$03d_T2w.nii*'` | Optional | +| `subject_subfolder` | Store outputs in subject-specific subdirectories? | `1` | `1 = yes`, `0 = no` | + +--- + +### Environment & toolbox paths (`startup`) + +| **Parameter** | **Description** | **Comments** | +|---|---|---| +| `simnibs_bin_path` | Absolute path to SimNIBS binaries. | Mandatory for segmentation and MNI conversion | +| `paths_to_add` | Paths to add with `addpath()`. | [cell] e.g. `{"path/to/x"}` | +| `subpaths_to_add` | Paths to add recursively with `addpath(genpath())`. | [cell]; relative to config file location | + +--- + +### I/O management (`io`) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `output_affix` | Optional affix for output file names. | `''` | Differentiates outputs for the same subject/transducer (e.g. different intensities or targets) | +| `overwrite_files` | File overwrite behaviour. | `'always'` | `never` / `always` / `ask`. Does NOT apply to SimNIBS segmentations. | +| `overwrite_simnibs` | Overwrite SimNIBS segmentation results? | `0` | `1 = yes`, `0 = no` | +| `save_matrices` | Save acoustic/thermal simulation outputs as `.mat`? | `0` | `1 = yes`, `0 = no`. Set to `0` for large batch runs to save disk space. | +| `save_heatingvideo` | Save a video of incremental heating? | `0` | `1 = yes`, `0 = no` | +| `adopted_heatmap` | Path to an existing intensity heatmap NIfTI to reuse instead of re-running acoustics. | β€” | Optional. Used for sequential multi-target runs. | +| `adopted_cem43` | Path to an existing CEM43 heatmap NIfTI to accumulate heating across runs. | β€” | Optional. Used for sequential multi-target runs. | + +**Runtime-derived (set by `path_log_setup`):** + +| **Field** | **Description** | +|---|---| +| `output_dir` | Resolved per-subject output directory. Set from `path.sim` (+ subject subfolder if `path.subject_subfolder = 1`). | +| `debug_dir` | Debug subfolder within `output_dir`. Created automatically. | +| `filename_output_table` | Path to the per-subject CSV results table. | +| `kwave_source_filename` | Path to cached k-Wave source `.mat` file. Set by `source_sensor_setup`. | + +--- + +### Pipeline modules (`modules`) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `run_grid_setup` | Setup grid and run head processing. | `1` | Mandatory for simulations. | +| `run_medium_setup` | Map medium acoustic properties. | `1` | Mandatory for simulations. | +| `run_source_setup` | Set up acoustic source. | `1` | Mandatory for simulations. | +| `run_acoustic_sims` | Run acoustic simulations. | `1` | | +| `run_acoustic_analysis` | Run acoustic analysis. | `1` | | +| `run_heating_sims` | Run thermal simulations. | `0` | Enable after acoustic results are validated. | +| `run_thermal_analysis` | Run thermal analysis. | `1` | | +| `run_nifti_creation` | Export results as NIfTI files. | `1` | | +| `run_posthoc_water_sims` | Run free-water reference simulations after head simulations. | `1` | | +| `generate_report` | Generate self-contained HTML simulation report. | `1` | | +| `segmentation_only` | Stop after segmentation; skip grid setup and all simulations. | `0` | Only has effect when `simulation.medium = 'layered'`. | + +All flags: `1 = yes`, `0 = no`. + +--- + +### Transducer specification (`transducer`) + +See [doc_transducer.md](doc_transducer.md). + +All fields are mandatory and have no defaults β€” they must be set in the study config. + +| **Parameter** | **Description** | **Comments** | +|---|---|---| +| `source_freq_hz` | Central frequency of the acoustic source [Hz]. | | +| `n_elements` | Number of transducer elements. | | +| `Elements_ID_mm` | Inner diameter of each element [mm]. | | +| `Elements_OD_mm` | Outer diameter of each element [mm]. | | +| `curv_radius_mm` | Radius of curvature of the transducer bowl [mm]. | | +| `dist_to_plane_mm` | Distance from geometric focus to transducer plane [mm]. | | +| `source_amp` | Pressure amplitude [Pa]. | Must be calibrated. | +| `source_phase_deg` | Source phase [degrees]. | Must be calibrated. | +| `source_phase_rad` | Source phase [radians]. | Must be calibrated. | +| `trans_pos` | Transducer bowl position (XYZ, T1 grid voxel space). | | +| `focus_pos` | Stimulation target position (XYZ, T1 grid voxel space). | | +| `expected_focal_distance_ep` | Expected distance from transducer exit plane to focus [mm]. | Alternative to specifying `trans_pos`/`focus_pos`. Either `expected_focal_distance_ep`, `expected_focal_distance_bowl`, or both pos fields must be set. | +| `expected_focal_distance_bowl` | Expected distance from transducer bowl to focus [mm]. | | + +--- + +### Transducer placement (`placement`) + +See [doc_placement.md](doc_placement.md). + +#### `placement.localite` + +See [doc_placement_neuronav.md](doc_placement_neuronav.md). + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `enabled` | Load transducer position from Localite neuronavigation files? | `0` | `1 = yes`, `0 = no` | +| `reference_distance_mm` | Distance from tracker to transducer exit plane [mm]. | `15` | Corrects for varying tracker-to-exit-plane distances. Only applies when `enabled=1`. | + +#### `placement.heuristic` + +See [doc_placement_heuristic.md](doc_placement_heuristic.md). + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `save_localite_t1` | Save Localite-aligned T1 output for header correction? | `false` | See [doc_placement_heuristic.md](doc_placement_heuristic.md). | +| `dist_close` | Distance from target considered sufficiently close [mm]. | `[]` | | +| `ear_radius` | Radius of the ear exclusion zone [mm]. | `35` | | +| `left_ear_center` | Approximate coordinates for left ear [image voxels]. | `[]` | Optional | +| `right_ear_center` | Approximate coordinates for right ear [image voxels]. | `[]` | Optional | +| `criterion_intersection` | Max fraction of exit plane intersecting skin. | `0.05` | 5% | +| `criterion_skin_mean` | Mean distance of EP voxels from skin [quantile]. | `[]` | | +| `criterion_skull_mean` | Mean distance of EP voxels from skull [quantile]. | `[]` | | +| `criterion_skin_var` | Variance of EP voxel distance from skin [quantile]. | `[]` | | +| `criterion_skull_var` | Variance of EP voxel distance from skull [quantile]. | `[]` | | +| `expand_step` | Expansion step for skin intersection criterion. | `0.01` | 1% | + +--- + +### Simulation grid (`grid`) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `resolution_mm` | Grid resolution (must be isotropic) [mm]. | `0.5` | | +| `default_dims` | Requested grid dimensions [voxels per dimension]. | `[144, 144, 400]` | Directly sets the simulation grid for `water` and `phantom` media. For `layered`, the grid is determined by head preprocessing and this value is not used. | +| `axisymmetric` | Run axisymmetric 2D simulation (`kspaceFirstOrderAS`). | `0` | `1 = yes`, `0 = no`. See [doc_simulations-acoustic.md](doc_simulations-acoustic.md). | +| `pml_size` | Perfectly Matched Layer (PML) size [voxels]. | `10` | Absorbs waves at boundaries. Recommended for 3D. See [k-Wave docs](http://www.k-wave.org/documentation/example_na_controlling_the_pml.php). | +| `source_ppw` | Points per wavelength. | `[]` | Calculated internally if not set. | +| `source_cfl` | Courant-Friedrichs-Lewy fraction. | `0.15` | | +| `source_limit_fraction` | Fraction of the stability limit to use for time step. | `0.9` | `0` = do not use stability limit | +| `max_expand` | Maximum grid expansion for prime-number FFT optimisation [voxels]. | `40` | | +| `use_kWaveArray` | Use the kWaveArray class for transducer modelling? | `1` | `1 = yes`, `0 = no` | + +--- + +### Segmentation (`segmentation`) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `use_qform` | Force qform reorientation before charm segmentation? | `0` | Set to `1` if charm reports a qform/sform mismatch error. | +| `debug` | Pass `--debug` to charm for verbose segmentation output? | `0` | `1 = yes`, `0 = no` | + +--- + +### Head model & segmentation preprocessing (`headmodel`) + +See [doc_preproc.md](doc_preproc.md). + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `head_pad_mm` | Symmetric padding applied to the cropped head grid prior to transducer + PML setup [mm]. | `0` | | +| `csf_expansion` | Dilation of the CSF brain mask into surrounding head regions [grid voxels]. | `40` | | +| `smooth_method` | Mask smoothing filter type. | `'gaussian'` | `gaussian` / `box` | +| `smooth_threshold_skull` | Binarisation threshold for skull mask; higher = thinner mask. | `0.5` | | +| `smooth_threshold_other` | Binarisation threshold for other masks; higher = thinner mask. | `0.5` | | +| `smooth_fwhm_mm` | FWHM of smoothing kernel [mm]. | `1` | | +| `smooth_properties` | Apply smoothing to acoustic property maps as well? | `false` | | +| `skull_fill_method` | Method for filling holes in the skull. | `'rubberwrap'` | `rubberwrap` / `imclose` | +| `skull_wrap_radius` | Rubber-wrap radius [grid voxels]. | `10` | Larger = tighter wrap ignoring bigger dents. Recommended: 2–10. | +| `skull_wrap_visualize` | Visualize rubber-wrap result? | `0` | `1 = yes`, `0 = no`. Avoid on HPC. | + +--- + +### Pseudo-CT skull property mapping (`pct`) + +See [doc_pseudoCT.md](doc_pseudoCT.md). + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `enabled` | Use (pseudo-)CT to inform skull medium properties? | `0` | `1 = yes`, `0 = no` | +| `mapping_density` | HU-to-density mapping algorithm. | `'k-plan'` | `k-plan` / `k-wave` / `marsac` / `aubry` / `none`. See [doc](doc_pseudoCT.md#mapping-skull-density). | +| `mapping_soundspeed` | HU-to-sound-speed mapping algorithm. | `'k-plan'` | `k-plan` / `marsac` / `aubry` / `none`. See [doc](doc_pseudoCT.md#mapping-skull-sound-speed). | +| `mapping_attenuation` | HU-to-attenuation mapping algorithm. | `'k-plan'` | `k-plan` / `mueller` / `aubry` / `none`. See [doc](doc_pseudoCT.md#mapping-skull-attenuation). | + +--- + +### Tissue layers (`layers`) + +See [doc_preproc.md](doc_preproc.md). + +Maps tissue compartment names to their SimNIBS charm label indices. Compartments are modelled in listed order; any voxel label not assigned to a named compartment is treated as water. Charm label values are hardcoded in `charm_seg_labels()`. Layers can be removed or added. + +| **Layer** | **Default charm labels** | **Comments** | +|---|---|---| +| `water` | `[0, 3, 6, 9, 10]` | Baseline layer; all unassigned voxels are treated as water | +| `brain` | `[1, 2]` | White matter + grey matter | +| `skin` | `[5]` | | +| `skull` | `[4]` | Single skull layer; used when cortical/trabecular split is not needed | +| `skull_cortical` | `[7]` | Cortical bone; used in multi-layer skull model | +| `skull_trabecular` | `[8]` | Trabecular bone; used in multi-layer skull model | + +--- + +### Tissue acoustic & thermal properties (`medium_properties`) + +See [doc_medium.md](doc_medium.md). + +Each tissue compartment (`water`, `brain`, `skin`, `skull`, `skull_trabecular`, `skull_cortical`) carries the following fields: + +| **Field** | **Description** | **Units / Reference** | +|---|---|---| +| `sound_speed` | Speed of sound. | m/s β€” ITRUSST benchmarks | +| `density` | Density. | kg/mΒ³ β€” ITRUSST benchmarks | +| `alpha_coeff` | Attenuation coefficient. | dB/cm/MHz | +| `alpha_power` | Attenuation power law exponent. | β€” | +| `thermal_conductivity` | Thermal conductivity. | W/m/Β°C β€” Tissue Properties DB | +| `specific_heat_capacity` | Specific heat capacity. | J/kg/Β°C β€” Tissue Properties DB | +| `perfusion` | Perfusion / heat transfer rate. | mL/min/kg β€” Tissue Properties DB | +| `absorption_fraction` | Fraction of attenuation converted to heat. | [0–1] β€” Pinton et al., 2012 | + +--- + +### Sonication timing (`timing`) + +See [doc_simulations-thermal.md](doc_simulations-thermal.md). + +Protocol duration fields must be set for thermal simulations. + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `pd` | Pulse Duration (PD) [s]. | `NaN` | Duty cycle = `pd`/`pri`. | +| `pri` | Pulse Repetition Interval (PRI) [s]. | `NaN` | PRF = 1/`pri`. | +| `ptd` | Pulse Train Duration (PTD) [s]. | `NaN` | | +| `pt_timestep` | Modelling time step within a pulse train [s]. | `0.02` | | +| `ptri` | Pulse Train Repetition Interval (PTRI) [s]. | `NaN` | OFF duration = `ptri` βˆ’ `ptd`. | +| `ptrd` | Pulse Train Repetition Duration (PTRD) [s]. | `NaN` | | +| `post_ptri_dur` | Post-PTRI steady-state duration [s]. | `NaN` | | +| `post_pt_timestep` | Modelling time step following PT & PTRI [s]. | `1` | | +| `equal_step_duration` | Equal step durations for on and off cycles? | `0` | `1 = yes`, `0 = no` | + +--- + +### Thermal settings (`thermal`) + +See [doc_simulations-thermal.md](doc_simulations-thermal.md). + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `cem43_iso` | Calculate CEM43 per ISO norm (`1`) or kWaveDiffusion (`0`)? | `0` | | +| `temp_0.{tissue}` | Initial temperature per tissue compartment [Β°C]. | `37` | Tissues: `water`, `skull`, `brain`, `skin`, `skull_trabecular`, `skull_cortical` | +| `sensor_xy_halfsize` | Sensor window half-size for temperature recording [grid units]. | `100` | | +| `record_t_at_every_step` | Record temperature at every time step for the full sensor window? | `0` | Memory-intensive; disable if out-of-memory errors occur. | + +--- + +### Output analysis (`analysis`) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `focus_area_radius` | Radius around the focus in which IPA is averaged for outputs [mm]. | `5` | | + +--- + +### High-performance computing (`hpc`) + +See [doc_backend.md](doc_backend.md) and [doc_hpc.md](doc_hpc.md). + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `gpu` | Request a specific GPU. | `''` | e.g. `"nvidia_a100-sxm4-40gb:1"` | +| `partition` | Request a dedicated queue partition. | `''` | | +| `reservation` | Request a reserved queue. | `''` | | +| `wait_for_job` | Block MATLAB until the submitted HPC job completes? | `false` | | +| `timelimit` | Job time limit. | `'04:00:00'` | | +| `memorylimit` | Memory limit [GB]. | `20` | | +| `ld_library_path` | LD_LIBRARY path for SimNIBS installation. | `''` | Set if you see `undefined symbol` errors. e.g. `/opt/gcc/7.2.0/lib64` | +| `job_prefix` | Prefix string for HPC job names. | `'PRESTUS'` | Overridden to `'TP'` for transducer positioning jobs. | +| `max_wait_checks` | Maximum number of job status checks when `wait_for_job = true`. | `540` | At 1 check/20 s, `540` β‰ˆ 3 hours. | + +--- + +### Transducer calibration (`calibration`) + +See [doc_calibration.md](doc_calibration.md). + +A separate `calibration_config.yaml` applies for calibration workflows and is loaded as `parameters.calibration`. + +| **Parameter** | **Description** | **Comments** | +|---|---|---| +| `path_input_axial` | Directory containing axial intensity profiles. | | +| `path_input_phase` | Directory containing phase data. | | +| `path_output` | Directory for saving free-water simulation results. | | +| `path_output_profiles` | Directory for saving optimised profile data. | | +| `filename_calibrated_CSV` | Filename of calibrated CSV data. | Mandatory only when not generated within standalone script. | +| `save_in_calibration_folder` | Save in `path_output` (`TRUE`) or `sim_path` (`FALSE`). | If `TRUE`, results are appended to existing calibration data. | +| `combinations` | Equipment combinations (must refer to entries in `equipment_config.yaml`). | Multiple combinations can be specified. | +| `focal_depths_wrt_exit_plane` | List of focal depths to characterise [mm]. | | +| `desired_intensities` | Desired free-water intensities [W/cmΒ²]. | | +| `add_FDO` | Append Focal Distance Offset (bowl-to-exit-plane distance)? | Set to `1` if zero point in profiles reflects exit plane rather than bowl. | +| `axisymmetric2D` | Use axisymmetric 2D instead of default 3D free-water simulations? | `1 = yes`, `0 = no` | +| `force_kwavearray` | Force use of kWaveArray for free-water simulations? | If `0`, uses the setting in the default/study config. | +| `opt_method` | Optimisation method. | `FEXminimize` (open source) / `GlobalSearch` (MATLAB Global Optimization Toolbox) | +| `opt_limits` | Distance limits for optimisation [mm]. | | +| `opt_weights` | Weighting of original profile during fitting. | `1` = equal; `>1` = Gaussian (narrower with larger values) | +| `opt_seed` | Random seed for optimisation. | | +| `skip_front_peak_mm` | Distance from profile start to ignore [mm]. | Avoids near-field artefacts in peak/FWHM calculations. | diff --git a/documentation/doc_placement.md b/documentation/doc_placement.md new file mode 100644 index 00000000..77e89e8b --- /dev/null +++ b/documentation/doc_placement.md @@ -0,0 +1,37 @@ +# Transducer Placement + +Simulations in free water will place the transducer in a homogeneous water medium without the need to specify a target. For head smulations, any transducer needs to be positioned close to the scalp (possibly at some distance to allow for coupling), and a target needs to be specified. + +PRESTUS expects coordinates of both the transducer (`transducer.trans_pos`) and target (`transducer.focus_pos`) to be specified as voxels in the T1w grid. The transducer coordinate describes the bowl of the transducer (i.e., not the exit plane in the case of curved transducers). Both coordinates need to be reported in the space of the planning image. + +## Manual coordinate selection + +Suitable coordinates (x/y/z) may be identified in preferred imaging software based on e.g. an fMRI hotspot, anatomical marker. + +## Heuristic coordinate selection + +PRESTUS can identify heuristic locations for transducer placement (see [Heuristic Tranducer Placement](doc_placement_heuristic.md)). This benefits iterative approaches without manual intervention. + +Outside of PRESTUS, alternative tools such as [PlanTUS](https://github.com/mlueckel/PlanTUS) can be used to manually identify candidate transducer locations based on a broader range of criteria. + +## Neuronavigation coordinate selection + +PRESTUS provides helper functions to read-in coordinates acquired with neuronavigation systems. Currently, PRESTUS supports read-in of localite positions. + +An example file (`examples/demo_localite.m`) is provided. + +The example script `demo_localite.m` highlights a workflow for extracting localite transducer positions. + +- Based on the study design, localite may encode the positions from multiple stimulations in the same output file. For a requested session, `neuronav_select_and_average_localite` selects the latest available Localite trigger XML file. +- In a localite session, triggers may be repeatedly acquired during the full pulse train repetition duration. `neuronav_compute_series_statistics` computes statistics across stimulus series (using a user-defined voxel size and user-defined trigger trains). +- From these extracted metrics, `neuronav_create_marker_averags` generates averaged marker positions for transducer and target locations. +- These are converted from Localite tracker space to native voxel indices and RAS coordinates aligned with the planning T1 image (`neuronav_convert_trigger_to_voxels`). +- Optionally, coordinates can be transformed to MNI space via SIMNIBS registration matrices (`neuronav_convert_native_to_MNI`). + +Results are exported to a CSV file in the Localite data folder, ready for PRESTUS simulation input. + +> **Alternative localite read-in.** +> PRESTUS currently allows an alternative Localite read-in using the following parameters. This is not yet documented. +| `transducer_from_localite` | Load transducer position from Localite files?. | +| `reference_transducer_distance_mm` | Distance from tracker to transducer exit plane (in mm). | + diff --git a/documentation/doc_placement_heuristic.md b/documentation/doc_placement_heuristic.md new file mode 100644 index 00000000..a24a6a5d --- /dev/null +++ b/documentation/doc_placement_heuristic.md @@ -0,0 +1,104 @@ +# Heuristic Transducer Placement + +The function `transducer_positioning` identifies heuristic locations for transducer placement. It can be called with `transducer_positioning_start`. + +The function targets brain regions specified by MNI coordinates, converting them to subject-native space using SimNIBS tools. It aims to identify candidate transducer positions where the geometric focus aligns with a target within a user-defined focal distance range. For each position, it computes the intersection proportion between the transducer and skin, mean/variance distances to skin and skull, and geometric focus/exit plane positions. It (optionally removes) areas including ears and selects a heuristic position based on user-defined criteria. Besides target MNI coordinates, the function does not require manual intervention, enabling automatic end-to-end workflows. + +For parameters, see the [overview](doc_parameters.md#heuristic-transducer-placement). + +### Candidate positions + +- Convert target from MNI (mm) to subject grid space (voxels) +- Find candidate transducer positions on skull (expanding sphere) +- Calculate criteria for each location: target distance, intersection with skin, mean distance to skull, variance in distance to skin & skull + +#### Table: Candidate Coordinates + +The `tpars_sub-XXX_target.csv` table contains transducer position candidates with the following columns. This information can be used to select a suitable candidate (e.g., by selecting a position with low intersection of the transducer and head tissue (prop_intersect < 0.05) and minimal Euclidean distance to the target; often supported by visual inspection). + +| Metric | Description | Interpretation | +|-----------------|------------------------------------------------|-----------------| +|`idx` | index | | +|`trans_x` | Transducer x-coordinate (voxels) | | +|`trans_y` | Transducer y-coordinate (voxels) | | +|`trans_z` | Transducer z-coordinate (voxels) | | +|`targ_x` | Target x-coordinate (voxels) | | +|`targ_y` | Target y-coordinate (voxels) | | +|`targ_z` | Target z-coordinate (voxels) | | +|`dist_to_target` | Euclidean distance transducer-to-target (voxels)| | +|`prop_intersect` | Fraction of voxels in the transducer’s orthogonal plane that intersect with the skull mask. | Low values (β‰ˆβ€―0) indicate minimal skull obstruction; high values (>β€―0.3) indicate significant bone interference. | +|`mean_dist_skin` | Mean distance from **non‑intersecting** voxels to the skin boundary. | Indicates average proximity of the plane to the skin surface. | +|`var_dist_skin` | Variance of distances from non‑intersecting voxels to the skin boundary. | Higher variance β†’ surface curvature or oblique intersection. | +|`mean_dist_skull`| Mean distance from **all** voxels in the plane to the nearest skull boundary. | Shows average clearance between the plane and skull. | +|`var_dist_skull` | Variance of distances to the skull boundary. | Quantifies irregularity of skull spacing within the beam footprint. | + + +### Remove ear locations + +**[Optional]** + +For practical reasons, transducer locations overlapping with the ears are not desired. Possible locations that overlap with ear positions can optionally be removed within an `tp_ear_radius` (Radius of the nogo zone, mm) around `tp_left_ear_center` (approximate coordinates for left ear, voxels) and `tp_right_ear_center`. + +### Select heuristic transducer position + +The desired criterion for intersection with the skin can be defined via `tp_criterion_intersection`. If no placement is found within this criterion, the intersection will be iteratively expanded by `tp_expand_step` (default: 1%) until a match is identified. + +| Criterion | Default | Explanation | +|:----------|---------------|-------------| +| `tp_criterion_intersect` | < 0.05 | [Fraction] Reduce skull traversal | +| `tp_criterion_skin_mean` | NaN | [Quantile] Short EP distance to skin | +| `tp_criterion_skull_mean` | NaN | [Quantile] Short EP distance to skull | +| `tp_criterion_skin_var` | NaN | [Quantile] Coherent transducer exit plane toward skin (e.g., little curvature) | +| `tp_criterion_skull_var` | NaN | [Quantile] Coherent transducer exit plane toward skull surface (Irregular bone β†’ phase distortion β†’ incoherent wavefront) | + +Amongst locations fulfilling the above criteria, the location with a minimum distance to the target is selected. + +An an output, this function will create a table that contains the coordinates in **voxel (grid space)**, **mm (Localite space)**, and **mm (RAS+ space)** (based on the image header of the segmentation image). + +> IMPORTANT: Localite mm coordinates assume that Localite planning image has `canonical_affine_transform` applied (see [below](#save-localite-planning-image))! The Localite coordinates are simply rescaled by the specified voxel sizes in the header assuming no affine transformations. Always visually verify coordinates ... + +**Example coordinate output table**: +
+trans_x	161     
+trans_y	138     
+trans_z	246     
+targ_x	117     
+targ_y	139     
+targ_z	161     
+
+<...>       
+
+LOCALITE_Transducer_mm	Inf       
+loc_trans_x_mm	144.9       
+loc_trans_y_mm	123.09      
+loc_trans_z_mm	221.02      
+
+LOCALITE_Target_mm	Inf     
+loc_targ_x_mm	105.3
+loc_targ_y_mm	124.88
+loc_targ_z_mm	144.65
+
+RAS_Transducer_mm	Inf
+ras_trans_x_mm	43.35
+ras_trans_y_mm	-16.17
+ras_trans_z_mm	82.3
+
+RAS_Target_mm	Inf
+ras_targ_x_mm	5.45
+ras_targ_y_mm	-20.71
+ras_targ_z_mm	5.19
+
+ +### Plot heuristic transducer position + +PRESTUS generates an overview of the selected transducer placement: + +![ex_heuristic_placement](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/ex_heuristic_placement.png) + +If `parameters.localite_path` is specified, it will also deposit a copy of the plot there. + +### Save Localite planning image + +**[Optional]** + + Localite can struggle with images that contain affine matrices in the image header. PRESTUS offers the optional step (`tp_save_localiteT1`) of depositing a header-adjusted T1 planning image for Localite (based on the `T1.nii.gz` in the SimNIBS output m2m directory). For this, `parameters.localite_path` has to be specified. diff --git a/documentation/doc_placement_neuronav.md b/documentation/doc_placement_neuronav.md new file mode 100644 index 00000000..bbb6c328 --- /dev/null +++ b/documentation/doc_placement_neuronav.md @@ -0,0 +1,68 @@ +# Neuronavigation read-in + +Extracting neuronavigation coordinates for post-hoc ultrasound simulations is critical to verify and predict the focus of the realized stimulation sites during neuromodulation experiments. Neuronavigation systems provide spatial tracking of the ultrasound transducer position relative to the individual subject’s anatomy in real-time. + +PRESTUS provides functions that aim to facilitate the read-in and preprocessing of positions recorded with the Localite system. +To get started, see an example demo (without provided data) in `/examples/demo_localite.m`. + +Multiple Localite filetypes are supported: + +### `TriggerMarkers` + +Multiple triggers can be sent during stimulation to acquire updates to the transducer location over time. Recorded locations will be recorded by localite to files named `TMSTrigger/TriggerMarkers_Coil0_`. Recorded locations will be averaged within a position (associated with a continuous time stamp stream). Multiple positions per recording session are supported. + +### `GUMMarkers` + +These files contain a static location that can be directly read in (either via `position_transducer_localite` or using the more comprehensive `neuronav_` function suite as in `demo_localite.m`). + +Average transducer and target locations can be provided in multiple coordinate spaces (native image space being the most relevant for PRESTUS). +The functions also facilitate standard-space definitons (experimental), e.g., to plot average transducer positions in standard space. + +### Neuronavigation Coordinate Transforms + +See the [coordinate system documentation](doc_coordinate_systems.md). + +Recorded localite coordinates are relative to a neuronavigation planning image [1] (e.g., `T1_forneuronav`) that was loaded in the neuronavigation software. + +SimNIBS' nonlinear transform matrices to MNI are relative to the `final_tissues.nii.gz` segmentation image however, so PRESTUS initially applies a transform from the planning to the segmentation image to the coordinates before they can be ported to MNI space. + +1. Trigger to Voxel (`neuronav_convert_trigger_to_voxels`): β†’ subject voxel indices +2. Voxel β†’ mm (`neuronav_grid_to_mm_batch`): β†’ subject mm +3. Native mm β†’ MNI mm (`neuronav_apply_deformation`): β†’ MNI mm + +[Optional]: Interpolate average positions across subjects + +4. Aggregate means in MNI mm across subjects (`neuronav_get_group_mean_mni`) +5. Group mean MNI mm β†’ subject mm (`neuronav_apply_inverse_deformation`) +6. Subject mm β†’ subject voxel (`neuronav_mm_to_voxel`) + +#### Coordinate Spaces in neuronav functions + +- `neuronav_convert_trigger_to_voxels` + - Localite/trigger data (RAS mm) β†’ subject (native) voxel space +- `neuronav_grid_to_mm_batch` + - Input: Subject voxel indices (i,j,k) + - Operation: Uses subject’s T1 NIfTI affine transform + - Output: Subject native mm coordinates (in scanner/world space) +- `neuronav_apply_deformation` (forward warp) + - Input: Native mm locations + - Operation: Queries subject’s Conform2MNI_nonl.nii.gz (subject-native β†’ MNI (mm)) + - Output: MNI mm coordinates (for reporting/plotting/averaging) +- `neuronav_get_group_mean_mni` + - Input: CSVs containing columns like Mtrans_pos_MNI_x/y/z (all from previous function outputs) + - Operation: Aggregates these MNI mm locations across subjects/sessions, computes mean (in mm) + - Output: MNI mm group-mean locations (still in millimeters, not voxels) +- `neuronav_apply_inverse_deformation` (inverse warp) + - Input: MNI mm group-mean locations (x, y, z in mm) + - Operation: Uses subject’s MNI2Conform_nonl.nii.gz nonlinear field to map standard points back into that subject’s native space. + - Output: Native mm location (in subject’s world/scanner space, for that subject) +- `neuronav_mm_to_voxel` (helper) + - Input: Native mm (x, y, z) location + - Operation: Uses subject T1 affine to map mm β†’ voxel (i, j, k) + - Output: Subject voxel indices for subsequent image work or storage +- `neuronav_export_session_csv` + - Columns include: + - Native (subject) voxel indices for target/transducer: (i, j, k from subject’s grid) + - Native mm coordinates (if desired): (x, y, z in subject’s scanner space) + - MNI mm coordinates for each (from deformation step): (x, y, z in standard space) + - MNI voxel indices (may be inaccurate with limited FOVs) \ No newline at end of file diff --git a/documentation/doc_preproc.md b/documentation/doc_preproc.md new file mode 100644 index 00000000..35b24f72 --- /dev/null +++ b/documentation/doc_preproc.md @@ -0,0 +1,43 @@ +# Head preprocessing + +PRESTUS preprocesses `layered` segmentations to set up simulation grids. + +![preprocessing](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/preprocessing.png) + +### Orientation + +PRESTUS initially creates a grid by orienting the segmentation to the focal plane of the first transducer. In multi-transducer setups, alignment is done to the first specified transducer. Specifically, `preproc_align_to_focal_axis` performs a two-step rotation to align an arbitrary focal axis (defined by transducer and focus positions) with the z-axis of the coordinate system. This prepares subject-specific head models for ultrasound neuromodulation simulations by standardizing the acoustic propagation direction along z. + +### Scaling + +The 3D planning image is then resliced to the requested grid resolution by isotropic scaling and centered regridding. These steps are applied to the planning image, the segmentation, and the bone mask (or (p)CT, if specified). For masks, nearest neighbor interpolation is used, for continuous values (e.g., from a (pseudo-)CT, linear interpolation is applied). + +### Medium Mapping (incl. segmentation smoothing) + +Segmentations are mapped onto the requested layer medium masks (to later assign acoustic properties). The standard (10-tissue) SimNIBS 4 segmentation will be converted into the (by default 5) specified layers (controlled by `parameters.layers`). If a segmentation for the requested layer is not available, requested layers will be removed from the specification. All segmentation IDs that are not explicitly assigned in dedicated layers are automatically included in a baseline water layer. Medium maps contain layer indices corresponding to the respective layer label position in the medium properties (`parameters.medium`). This allows for flexibility in requested layers with stable medium ids (unless `parameters.medium` is edited). + +Each layer is smoothed using one of the algorithms below. The size of smoothing kernels refer to the grid size of the medium mask, NOT the original dimensions of the planning image. If a multi-layer skull model is requested, both cortical and trabecular layers are initially combined into a skull segment and smoothed. Subsequently, the trabecular bone layer is smoothed and added. + +Multiple smoothing algorithms are available: +- `gaussian` (default) uses smooth3 for isotropic blurring, yielding smooth gradients and moderate edge erosion (default: 2-voxel FWHM kernel) +- `box` applies a uniform convolution, which is fastest, but also blocky and prone to skull holes at low binarization thresholds (default: 2-voxel kernel) + +Different binarization thresholds can be defined for skull (default: 50%) and other tissues (default: 50%). + +PRESTUS attempts to enforce skull continuity (i.e., absence of holes) either by applying a 'rubberwrap' skull inflation algorithm (default) or 'imclose' to the skull layer. This is controlled by `headmodel.skull_fill_method`. Potential holes are converted to (cortical) skull. + +### Cropping + +To increase computational efficiency, the grid is tightly cropped around the head. The cropping factor is determined by the edges of the layered medium and the location of the transducer. [Prior to adding the transducer, the grid can optionally be expanded via `pad_mm`, which applies symmetric padding and can avoid that the transducer ends out of grid bounds. This should not impact later computational efficiency of the simulation, as regions beyond the transducer + PML will afterwards be cropped]. To guide the bounds of the head area of interest in the simulation, the CSF segmentation (if available) will be expanded by `csf_mask_expansion_factor` [in voxels] to guide bounds of the layered medium. Areas outside of the crop mask will be assigned to the water layer (and are elibible for cropping). Tight crop dimensions are then defined by enclosing the layered medium mask, the transducer geometry, plus a PML buffer. This is followed by FFT-optimized resizing via `find_min_factor`, which expands dimensions up to `prime_factor_max_grid_expansion` for faster FFT convolution. The medium is anchored at the original origin (transducer/target unchanged relative to head), with symmetric expansion/padding around the content to fit FFT requirements while maintaining anatomical alignment for k-Wave propagation. + +All image transformations are concatenated to update transducer and target positions. + +### Acoustic property mapping + +See [acoustic property mapping](doc_medium.md). + +### Smoothing of acoustic property maps + +Sharp transitions in the acoustic properties of adjacent media can result in substantial wave reflections. To smoothen tissue transitions, acoustic property maps can be smoothed prior to the simulation. This is governed by `smooth_properties`. Identical smoothing will be applied to all acoustic property maps. This smoothing is deactivated by default, in part to allow users to set the desired smoothing kernel at the intended grid size. If set to `true`, this will apply the same smoothing parameters as used during the creation of the medium maps (by default 2 voxel FWHM). + +> pCT. If pCTs are generated using PRESTUS, they automatically have smoothing applied at the skull edges ([see the pCT documentation](doc_pseudoCT.md)). If acoustic properties are mapped from pCTs, smoothing of the resulting acoustic property maps may not be intended. Recommended settings are an active area of investigation. \ No newline at end of file diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 64d1ddef..90120211 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -1,133 +1,234 @@ -# PseudoCT +# (pseudo-)CT + +![pct_ute_density](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/ute_pct_density.png) PRESTUS supports the use of UTE-based images as a source of pseudo-Hounsfield units. These can replace the skull layer of a layered simulation for continuous tissue property mapping. > [!WARNING] -> (pseudo-)HU mapping is currently a beta feature in active development. +> (pseudo-)HU mapping is an experimental feature in active development. -### Creating a pseudoCT from UTE scans +### Creating pseudoCTs from UTE scans -1) Perform a SimNIBS segmentation using a T1w and a PETRA UTE scan (instead of T2) as inputs. - - You can either use the SimNIBS GUI or PRESTUS (**default**). -2) Run in bash: `create_pseudoCT.sh`. - - functions/create_pseudoCT.sh calls the MATLAB function pct_soft_tissue_peak +0. **MR acquisition** -The pseudoCT and an associated mask file will be deposited in the `m2m` folder alongside the SimNIBS segmentation. + Acquire an MR sequence with detailed bone contrast + + >**Example PETRA UTE parameters** (Carpino et al., 2023) + TR: 3.32 ms + TE: 0.07 ms + voxel size: 0.8 mm3 + 352 sagittal slices + flip angle: 2Β° + FoV: 294 mm + +1. **SimNIBS segmentation**. + + Perform a SimNIBS segmentation using a T1w and a PETRA UTE scan (instead of T2w) as inputs. You can either use the SimNIBS GUI or PRESTUS (**default**). + +2. **pseudoCT generation**. + +Run `pct_create_pseudoCT.sh` in bash. An example script to create a pseudoCT is provided at `examples/createPseudoCT.sh`. To run the code you will need to install (or load the following modules): -To run the code you will need to install (or load the following modules): - SimNIBS - FSL - ANTs -**Note: Example PETRA UTE parameters** (Carpino et al., 2023) - -- TR: 3.32 ms -- TE: 0.07 ms -- voxel size: 0.8 mm3 -- 352 sagittal slices -- flip angle: 2Β° -- FoV: 294 mm +The pseudoCT and an associated mask file will be deposited in the `m2m` folder alongside the SimNIBS segmentation. -#### Conceptual overview +#### pseudoCT generation The following steps are used to create the pseudoCT: - UTE: Threshold at 0, apply bias field correction to remove inhomogeneity, normalize (divide by soft tissue peak value > i.e., normalised UTE intensity = 1) +- Soft tissue value is identified with the MATLAB function `pct_soft_tissue_peak` - pHU Soft-tissue (normalised UTE intensity = 1): 42 HU (Wiesinger et al., 2018) - pHU Air: -1000 HU (Wiesinger et al., 2018; Miscouridou et al., 2022) - pHU Skull: Linear mapping - - "miscouridou" | pHU = βˆ’2085 UTE + 2329 - - "carpino" | pHU = -2194 UTE + 2236 - - "wiesinger" | pHU = -2000 (UTE-1) + 42 - - "treeby" | pHU = -2929.6 UTE + 3247.9 - - "kosciessa" | individualized: pHU_trabecular=300 & pHU_trabecular_cortical = 700 + - `miscouridou` | pHU = βˆ’2085 UTE + 2329 + - `carpino` | pHU = -2194 UTE + 2236 + - `wiesinger` | pHU = -2000 (UTE-1) + 42 + - `treeby` | pHU = -2929.6 UTE + 3247.9 + - `kosciessa` | individualized: pHU_trabecular=300 & pHU_trabecular_cortical = 700 - 3D Gaussian smoothing (Οƒ=0.8 voxels via ANTs’ SmoothImage) prevents abrupt tissue transitions in the simulation grid. To retain information in the skull layer, unsmoothed values are retained within an eroded skull mask that attempts to correct for partial volume effects. -*References* -- Wiesinger, F. et al. Zero TE-based pseudo-CT image conversion in the head and its application in PET/MR attenuation correction and MR-guided radiation therapy planning. Magn. Reson. Med. 80, 1440–1451 (2018). -- Miscouridou, M., Pineda-Pardo, J. A., Stagg, C. J., Treeby, B. E. & Stanziola, A. Classical and Learned MR to Pseudo-CT Mappings for Accurate Transcranial Ultrasound Simulation. IEEE Trans. Ultrason., Ferroelectr., Freq. Control 69, 2896–2905 (2022). -- Fat, D. L. et al. The Hounsfield value for cortical bone geometry in the proximal humerusβ€”an in vitro study. Skelet. Radiol. 41, 557–568 (2012). - + +**References:** +Wiesinger, F. et al. Zero TE-based pseudo-CT image conversion in the head and its application in PET/MR attenuation correction and MR-guided radiation therapy planning. Magn. Reson. Med. 80, 1440–1451 (2018). +Miscouridou, M., Pineda-Pardo, J. A., Stagg, C. J., Treeby, B. E. & Stanziola, A. Classical and Learned MR to Pseudo-CT Mappings for Accurate Transcranial Ultrasound Simulation. IEEE Trans. Ultrason., Ferroelectr., Freq. Control 69, 2896–2905 (2022). +Fat, D. L. et al. The Hounsfield value for cortical bone geometry in the proximal humerusβ€”an in vitro study. Skelet. Radiol. 41, 557–568 (2012). +Carpino et al. (2024). Transcranial ultrasonic stimulation of the human amygdala to modulate threat learning. MSc thesis. + -The following is an example script that you can use for a `create_pseudoCT.sh` call. +### Using (pseudo-)CTs to inform acoustic properties -``` -#!/bin/bash +Hounsfield Units can be used to inform acoustic properties in the bone layer of a SimNIBS segmentation. This is a subtype of a `layered` medium. -rootpath="$(pwd)/.." -rootpath=$(builtin cd $rootpath; pwd) +To inform skull properties by pCTs in simulations, set `parameters.use_pseudoCT = 1`, define `parameters.t2_path_template` as the `pseudoCT.nii.gz` in the simnibs output directory, and choose the desired acoustic parameter mapping algorithms. The current code supports the following mappings optionally model density, speed of sounds, and attenuation in the skull bone. When set to `"none"` or when the parameter field is deleted/missing, each property will revert to the uniform `skull` value. The defaults highlighted below are the ones specified in the `default_config.yaml`. -# Load necessary modules -module load ants -module load matlab -module load fsl +>[!warning] The most suitable mapping remains an active area of research. All mappings should therefore be treated as experimental. See [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43). Mappings will exclusively be applied within the uniform skull mask. -# Change directory to the scripts directory -scriptpath=${rootpath}/tools/PRESTUS/functions -cd "${scriptpath}" || { echo "Directory not found"; exit 1; } +#### Mapping skull density -# Source the script containing the function -source ${scriptpath}/create_pseudoCT.sh +Mapping algorithm is goverened by `pct_mapping_density`. -subject_id="001" # 'sub-' will automatically be added -m2m_path=${rootpath}/data/simnibs +- `k-plan` | 4-part piecewise linear fit based on k-Plan defaults (**default**)
-# Call the create_pseudoCT function -create_pseudoCT "$subject_id" "$m2m_path" "${scriptpath}" -``` + **Acoustic simulations** + Piece-wise linear mapping between HU and mass density in kg/m^3 using k-Plan default calibration [see k-Plan documentation](https://dispatch.k-plan.io/static/docs/planning-images.html#ct-calibration): + + $$ \rho_\text{skull} = \text{fit_pairwiselinear}(\text{HU}, \text{density}) $$ + ``` + HU = [-990, 60, 1000, 1950] + density = [1.2, 1060, 1530, 2150] + ``` -### Starting acoustic + thermal simulations using pseudoCT + Density estimates are regularized at the minimum to the water density specified in the configuration. -see [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43) + > Note: k-Plan's bone segmentation starts at density values of 1150 kg/m3, which internally regularized data ranges. Such density regularization is not currently enforced in PRESTUS... -To inform skull properties by pCTs in simulations, set `parameters.usepseudoCT = 1`, define `parameters.t2_path_template` as the `pseudoCT.nii.gz` in the simnibs output directory, and choose `parameters.pseudoCT_variant`. The current code supports the following variants to use pCTs to inform skull tissue parameters. Note that this affects only the pCT-to-tissueproperty conversion, only the above described procedure to derive pCTs is currently supported. + **Thermal simulations** + Overwrite bone density prior to the thermal simulation: + + $$ \rho_\text{skull} = 1850 \, \text{kg/m}^3 $$ -- `carpino` | (**default**) Algorithm described in Carpino et al. (2024).
+
- All subsequent steps are only applied inside the skull mask. +- `k-wave` | 4-part piecewise linear fit based on Schneider et al. (1996)
- ``` - ρ_skull = hounsfield2density(HU+1000) - c_skull = 1.33 * ρ_skull + 167 - Ξ±_skull = Ξ±_bone_min + (Ξ±_bone_max βˆ’ Ξ±_bone_min) * (1 βˆ’ (HU βˆ’ HU_min) / (HU_max βˆ’ HU_min))^0.5 [Mueller et al., 2017] - ``` + PseudoCT values are initially shifted by +1000 and thresholded at 300 to align with [hounsfield2density](http://www.k-wave.org/documentation/hounsfield2density.php). - - The k-Wave function hounsfield2density converts pseudo-HUs to density. Original pseudoCT values are initially shifted by 1000 and thresholded at 300 to align with [hounsfield2density](http://www.k-wave.org/documentation/hounsfield2density.php). - - Resulting density values are regularized to a minimum of the specified water density, and a maximum density of 2100 kg/m3. - - The sound speed c is calculated from density 𝜌 using the linear relationship: c = 1.33𝜌 + 167. This is identical to the [k-Plan estimation](https://dispatch.k-plan.io/static/docs/simulation-pipeline.html#evaluating-plans). Note that due to the density regularization, sound speed is implicitly regularized. - - The absorption coefficient 𝛼 is derived from HU values according to formula (3) in Yaakub et al., with `Ξ±_bone_min` = 4 and `Ξ±_bone_max` = 8.7. For both `carpino` and `yakuub` variants, these 𝛼 bounds are based on estimates made at 500 kHz (i.e., 𝛼(f); see Aubry, J.-F., 2022 for prior benchmark simulations). However, we require ```alpha_0``` in ```𝛼(f) = alpha_0 x f[MHz] ^ y```. We therefore estimate ```alpha_0 = 𝛼(f)/0.5^y``` with ```y``` being the specified ```alpha_power```for the skull tissue. + $$ \rho_\text{skull} = \text{hounsfield2density}(\text{HU} + 1000) $$ - *Reference:* Adapted from Carpino et al. (2024). Transcranial ultrasonic stimulation of the human amygdala to modulate threat learning. MSc thesis. + Density estimates are regularized at the minimum to the water density specified in the configuration, and a max. skull density `rho_bone = 2100` kg/m3. -- `yaakub` | Algorithm specified in Yaakub et al. (2023).
+ + **References:** + Schneider, U., Pedroni, E., and Lomax A., "The calibration of CT Hounsfield units for radiotherapy treatment planning," Phys. Med. Biol., 41, pp. 111-124 (1996). + +
- ``` - ρ_skull = ρ_water + (ρ_bone βˆ’ ρ_water) * (HU βˆ’ HU_min) / (HU_max βˆ’ HU_min) [Marsac et al., 2017] - c_skull = c_water + (c_bone βˆ’ c_water) * (ρ_skull βˆ’ ρ_water) / (ρ_bone βˆ’ ρ_water) [Marsac et al., 2017] - Ξ±_skull = Ξ±_bone_min + (Ξ±_bone_max βˆ’ Ξ±_bone_min) * (1 βˆ’ (HU βˆ’ HU_min) / (HU_max βˆ’ HU_min))^0.5 [Mueller et al., 2017] - ``` +- `marsac` | Marsac et al. (2017)
- *References:* - - Yaakub, S. N. et al. Pseudo-CTs from T1-Weighted MRI for Planning of Low-Intensity Transcranial Focused Ultrasound Neuromodulation: An Open-Source Tool. Brain Stimulation. 16. 75–78 (2023). - - Marsac, L. et al. Ex Vivo Optimisation of a Heterogeneous Speed of Sound Model of the Human Skull for Non-Invasive Transcranial Focused Ultrasound at 1 MHz. International Journal of Hyperthermia. 33. 635–645 (2017). - - Mueller, J. K., Ai, L., Bansal, P. & Legon, W. Numerical Evaluation of the Skull for Human Neuromodulation with Transcranial Focused Ultrasound. Journal of Neural Engineering. 14. 066012 (2017). + This algorithm initially regularizes skull pHU to a range of `HU_min = 300` and `HU_max = 2000`. Note that in contrast to Marsac et al., 2017, PRESTUS regularizes pHU values instead of excluding pHU < HU_min from the skull mask as the latter is prone to create skull holes. -- `k-plan` | Algorithm described in Carpino et al. (2024) with more fixed skull properties
+ $$ \rho_\text{skull} = \rho_\text{water} + (\rho_\text{bone} - \rho_\text{water}) \cdot \frac{\text{HU} - \text{HU}_\text{min}}{\text{HU}_\text{max} - \text{HU}_\text{min}} $$ - ``` - ρ_skull = hounsfield2density(HU+1000) - c_skull = 1.33 * ρ_skull + 167 - Ξ±_skull = alpha_0 - ``` + with max. skull density `rho_bone = 2100` kg/m3. + + + **References:** + Marsac, L. et al. Ex Vivo Optimisation of a Heterogeneous Speed of Sound Model of the Human Skull for Non-Invasive Transcranial Focused Ultrasound at 1 MHz. International Journal of Hyperthermia. 33. 635–645 (2017).
+
+
+ +- `aubry` | "Porosity-based" mapping based on Aubry et al. (2003)
+ + This algorithm first estimates the porosity as: + + $$ \phi_\text{skull} = 1 - \frac{\text{HU}}{\text{HU}_\text{max}} $$ - k-Plan fixes the absorption coeff. to `13.3` and power law to `1`. To allow more flexibility, this variant reads in the alpha power values specified for the respective bone segmentation (`trabecular` or `cortical`) from the current config. To replicate k-Plan's setup, specify `alpha_coeff = 13.3` and `alpha_power = 1` in the configuration of all bone segmentations. + This uses the max. skull HU denominator from Guo et al., 2019. + Skull density is then estimated as the mixture of bone and water density composites: - Heating simulations will initially run the acoustic simulation as specified above, and then overwrite bone density (= `1850`) and sound speed (= `1.33 x 1850 + 166.7`) prior to starting the heating simulation. + $$ \rho_\text{skull} = \rho_\text{water} \cdot \phi_\text{skull} + \rho_\text{bone} \cdot (1 - \phi_\text{skull}) $$ - ``` - ρ_skull = 1850 - c_skull = 1.33 * ρ_skull + 167 - Ξ±_skull = alpha_0 - ``` + Respective tissue sound speed properties are extracted from the configuration. + + + **References:** + Aubry, J.-F., et al. Experimental demonstration of noninvasive transskull adaptive focusing based on prior computed tomography scans. *J Acoust Soc Am* **113**, 84–93 (2003). + Guo, S., et al. Feasibility of ultrashort echo time images using full-wave acoustic and thermal modeling for transcranial MRI-guided focused ultrasound (tcMRgFUS) planning. *Phys. Med. Biol.* **64**, 095008 (2019). + +
+ +#### Mapping skull sound speed + +Mapping algorithm is goverened by `pct_mapping_soundspeed`. + +- `k-plan` | Density-based mapping (**default**)
+ + Implements [the k-Plan mapping](https://dispatch.k-plan.io/static/docs/simulation-pipeline.html#converting-the-primary-planning-image-to-material-properties): + + $$ c_\text{skull} = 1.33 \cdot \rho_\text{skull} + 167 $$ + + The minimum sound speed in skull bone is regularized to water sound speed. + +- `marsac` | Density-based mapping according to Marsac et al. (2017)
+ + $$ c_\text{skull} = c_\text{water} + (c_\text{bone} - c_\text{water}) \cdot \frac{\rho_\text{skull} - \rho_\text{water}}{\rho_\text{bone} - \rho_\text{water}} $$ + + with max. speed of sound in skull `c_skull = 3360` m/s and max. skull density `rho_bone = 2100` kg/m3. Respective water properties are extracted from the configuration. + + + **References:** + Marsac, L. et al. Ex Vivo Optimisation of a Heterogeneous Speed of Sound Model of the Human Skull for Non-Invasive Transcranial Focused Ultrasound at 1 MHz. International Journal of Hyperthermia. 33. 635–645 (2017).
+
+
+ +- `aubry` | "Porosity"-based mapping based on Aubry et al. (2003)
+ + This algorithm first estimates the porosity as: + + $$ \phi_\text{skull} = 1 - \frac{\text{HU}}{\text{HU}_\text{max}} $$ + + This uses the max. skull HU denominator from Guo et al., 2019. + Skull sound speed is then estimated as the mixture of bone and water sound speed composites: + + $$ c_\text{skull} = c_\text{water} \cdot \phi_\text{skull} + c_\text{bone} \cdot (1 - \phi_\text{skull}) $$ + + Respective tissue sound speed properties are extracted from the configuration. + + + **References:** + Aubry, J.-F., et al. Experimental demonstration of noninvasive transskull adaptive focusing based on prior computed tomography scans. *J Acoust Soc Am* **113**, 84–93 (2003). + Guo, S., et al. Feasibility of ultrashort echo time images using full-wave acoustic and thermal modeling for transcranial MRI-guided focused ultrasound (tcMRgFUS) planning. *Phys. Med. Biol.* **64**, 095008 (2019). + +
+ +#### Mapping skull attenuation + +Mapping algorithm is goverened by `pct_mapping_attenuation`. + +- `k-plan` | Uniform fixed (**default**) + + $$ \alpha_\text{skull} = \alpha_\text{coeff} $$ + + k-Plan fixes the attenuation coeff. to `13.3` and frequency power law to `1`. To allow more flexibility, this variant reads in the alpha power values specified for the respective bone segmentation (`trabecular` or `cortical`) from the current config. To replicate k-Plan's setup, specify `alpha_coeff = 13.3` and `alpha_power = 1` in the skull bone medium configuration. + +
+ +- `mueller` | Algorithm specified in Mueller et al. (2017).
+ + $$ \alpha_\text{skull} = \alpha_\text{min} + (\alpha_\text{max} - \alpha_\text{min}) \left(1 - \frac{\text{HU} - \text{HU}_\text{min}}{\text{HU}_\text{max} - \text{HU}_\text{min}}\right)^{0.5} $$ + + with `Ξ±_min` = 4 and `Ξ±_max` = 8.7 (see Yakuub et al., 2023). + + These 𝛼 estimates are based on 500 kHz (i.e., 𝛼(f); see Aubry, J.-F., 2022 for prior benchmark simulations). However, we require `alpha_0` in `𝛼(f) = alpha_0 x f[MHz] ^ y`. We therefore estimate `alpha_0 = 𝛼(f)/0.5^y` with `y` being the specified `alpha_power`for the skull tissue. + + + **References:** + Aubry, J.-F. et al. Benchmark problems for transcranial ultrasound simulation: Intercomparison of compressional wave modelsa). J. Acoust. Soc. Am. 152, 1003–1019 (2022). + Mueller, J. K., Ai, L., Bansal, P. & Legon, W. Numerical Evaluation of the Skull for Human Neuromodulation with Transcranial Focused Ultrasound. Journal of Neural Engineering. 14. 066012 (2017). + Yaakub, S. N. et al. Pseudo-CTs from T1-Weighted MRI for Planning of Low-Intensity Transcranial Focused Ultrasound Neuromodulation: An Open-Source Tool. Brain Stimulation. 16. 75–78 (2023). + +
+ +- `aubry` | "Porosity-based" mapping based on Aubry et al. (2003)
+ + This algorithm first estimates the porosity as: + + $$ \phi_\text{skull} = 1 - \frac{\text{HU}}{\text{HU}_\text{max}} $$ + + This uses the max. skull HU denominator from Guo et al., 2019. + Skull attenuation is then estimated as: + + $$ \alpha_\text{skull} = \alpha_\text{min} + (\alpha_\text{max} - \alpha_\text{min}) \cdot \phi_\text{skull}^{0.5} $$ + with `Ξ±_min = 0.2` and `Ξ±_max = 8` (Aubry et al., 2003). + + **References:** + Aubry, J.-F., et al. Experimental demonstration of noninvasive transskull adaptive focusing based on prior computed tomography scans. *J Acoust Soc Am* **113**, 84–93 (2003). + Guo, S., et al. Feasibility of ultrashort echo time images using full-wave acoustic and thermal modeling for transcranial MRI-guided focused ultrasound (tcMRgFUS) planning. *Phys. Med. Biol.* **64**, 095008 (2019). + +
\ No newline at end of file diff --git a/documentation/doc_simulations-acoustic.md b/documentation/doc_simulations-acoustic.md index be716af7..f164a76c 100644 --- a/documentation/doc_simulations-acoustic.md +++ b/documentation/doc_simulations-acoustic.md @@ -1,15 +1,24 @@ -# Performing acoustic simulations +# Acoustic simulations -## Axisymmetric simulations (3D simulations on 2D input setup) +PRESTUS supports 3D acoustic wave simulations using k-Wave’s pseudo-spectral method for time-domain 3D acoustic simulations. These simulations model compressional waves (P-waves), which involve pressure changes propagating through isotropic media *without* shear deformations. To describe wave propagation, they calculate pressure and velocity fields. They are faster, less memory-intensive, and require less assumptions than elastic wave simulations (e.g., using `kWaveElastic`; not implemented) because they ignore shear waves and complex material interactions. However, this also means that scattering via shear waves is not explicitly modeled (albeit contributing to the bulk attenuation coefficient resulting from absorption and scattering). -[kspaceFirstOrder2D](http://www.k-wave.org/documentation/kspaceFirstOrder2D.php) models an infinite cylinder, not a focusing shell. It uses a 2D Cartesian grid with **X and Y axes**. These are assumed to map onto the second and first dimensions of input matrices and coordinates (i.e., [y, x]), respectively. This may at first may seem unintuitive, but it is designed to facilitate the mapping to 3D image space voxel coordinates [i,j,k], which traditionally map onto [Y,X,Z]. In the future, this could also be resolved by assuming a XY mapping for k-Wave, and introducing a remapping of voxel space coordinates. +PRESTUS by default supports 3D simulations using `kspaceFirstOrder3D`. -To model a 3D axisymmetric problem using a 2D computational grid (recommended), [kspaceFirstOrderAS](http://www.k-wave.org/documentation/kspaceFirstOrderAS.php) can be used (see [this example benchmark](https://github.com/ucl-bug/k-wave/blob/main/k-Wave/examples/example_at_focused_bowl_AS.m). It significantly reduces computational cost compared to a full 3D simulation. The input structures (`kgrid`, `medium`, `source`, and `sensor`) are defined similarly to 2D simulations but interpreted in the axisymmetric coordinate system. The function `kspaceFirstOrderAS` accounts for wave propagation in such axisymmetric media and is functionally similar to `kspaceFirstOrder2D` but adapted for axisymmetry. kspaceFirstOrderAS uses a 2D axisymmetric coordinate system with **axial and radial axes**. These are assumed to map onto the first and second dimensions of input matrices and coordinates, respectively. +See the available [simulation backends](doc_backend.md). + +> **Turning off acoustic simulations.** PRESTUS uses the `run_acoustic_sims` flag to indicate whether to run simulations. If set to `false`, PRESTUS will first check for existing files and check whether `overwrite_files` is set to off to load the existing matrices. This is intended to make this more intuitive for workflows in which acoustic simulations are "turned off" to run follow-up thermal simulations. This behaviour is unique for this module (turning off other modules may not run the step at all incl. loading existing data). + +## Axisymmetric simulations + +For debugging, it can be useful to specify and model symmetric 2D tissues (e.g, 2D `phantom` or `water` media). However, [`kspaceFirstOrder2D`](http://www.k-wave.org/documentation/kspaceFirstOrder2D.php) models an infinite cylinder, not a focusing shell. It uses a 2D Cartesian grid with **X and Y axes**. These are assumed to map onto the second and first dimensions of input matrices and coordinates (i.e., [y, x]), respectively. This may at first may seem unintuitive, but it is designed to facilitate the mapping to 3D image space voxel coordinates [i,j,k], which traditionally map onto [Y,X,Z]. In the future, this could also be resolved by assuming a XY mapping for k-Wave, and introducing a remapping of voxel space coordinates. + +To model a 3D axisymmetric problem using a 2D computational grid (as recommended), [`kspaceFirstOrderAS`](http://www.k-wave.org/documentation/kspaceFirstOrderAS.php) can be used (see [this example benchmark](https://github.com/ucl-bug/k-wave/blob/main/k-Wave/examples/example_at_focused_bowl_AS.m). It significantly reduces computational cost compared to a full 3D simulation. The input structures (`kgrid`, `medium`, `source`, and `sensor`) are defined similarly to 2D simulations but interpreted in the axisymmetric coordinate system. The function `kspaceFirstOrderAS` accounts for wave propagation in such axisymmetric media and is functionally similar to `kspaceFirstOrder2D` but adapted for axisymmetry. kspaceFirstOrderAS uses a 2D axisymmetric coordinate system with **axial and radial axes**. These are assumed to map onto the first and second dimensions of input matrices and coordinates, respectively. Multiple adjustments are made if axisymmetry is requested: -- The grid should be specified as [radial x 2, axial]. + +- The grid should be specified as `[radial x 2, axial]`. - It is assumed that the (2x) radial axis is shorter than the axial axis. -- Internally, [radial x 2, axial] is remapped onto [axial radial]; only values from [half+1:end] are retained. +- Internally, `[radial x 2, axial]` is remapped onto `[axial radial]` and only values from `[half+1:end]` are retained. - Medium, source, and sensor are set up according to [axial, radial] dimensions. - Following acoustic simulation, values are either mirrored into a cartesian 2D grid, or radially expanded into a cartesion 3D grid (see the note below). In cartesian 2D, the output will have [radial x 2, axial] dimensions again, in line with the default specification for kspaceFirstOrder2D. diff --git a/documentation/doc_simulations-getting-started.md b/documentation/doc_simulations-getting-started.md deleted file mode 100644 index 2273ce75..00000000 --- a/documentation/doc_simulations-getting-started.md +++ /dev/null @@ -1,35 +0,0 @@ -# How to design your own processing pipeline - -### install packages (ensure the paths and subfolders are added in Matlab) -- SimNIBS 4 -- k-Wave 1.4.1 (place in PRESTUS' toolbox folder) - - Note that k-Wave 1.4 is supported, but version 1.4.1 (currently [GitHub exclusive](https://github.com/ucl-bug/k-wave/releases/tag/v1.4.1)) introduced GPU support for thermal simulations with kWaveDiffusion. - -### start with the tutorial (documentation/PRESTUS_intro_tutorial.md) -- The acoustic profile is derived from the manufacturers measurements and adjusted to more closely match the actual characteristics of the transducer. -- The structural data is segemented with SimNIBS 4's charm. -- The segmented data is translated to a matrix of acoustic properties. -- k-wave performs acoustic and thermal simulations. -- Select outputs are plotted. - -### create a study-specific config file -- Configuration metadata must be fully specified in (a set of) config files (in the .yaml format) placed in the 'configs' folder (see ```doc_config.md```). -- The ```load_parameters.m``` function imports the configuration. -- Some of the parameters found in the 'default_config' are mandatory for the pipeline to run while others can be left out based on the requirements of the analysis. -- The different parameters that can be used are found in the 'default_config.yaml'. The default config should generally not be altered. Instead, parameter changes should be specified via an application-specific config file or before performing the subject call in MATLAB). - -### specify a transducer and target location -- These can be specified in your config `parameters.transducer.trans_pos` and `parameters.transducer.focus_pos`. -- See also `doc_neuronav.md` - -### choose a simulation medium - -- see `doc_medium.md` - -### choose a simulation type - -- see `doc_backend.md` - -### run the single_subject_pipeline -- Ideally, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). -- To run multiple subjects or setups in parallel, the single_subject_pipeline can be submitted using high performance computing jobs (see doc_hpc.md). diff --git a/documentation/doc_simulations-thermal.md b/documentation/doc_simulations-thermal.md index b17f948a..7dd38bab 100644 --- a/documentation/doc_simulations-thermal.md +++ b/documentation/doc_simulations-thermal.md @@ -1,42 +1,42 @@ -# Setting up thermal simulations +# Thermal simulations Thermal simulations depend on the output of the acoustic simulations. As such, running thermal simulations without first running acoustic simulations is not an option. Protocol timing is oriented after the nomencalture in the [TUS Calculator](https://www.socsci.ru.nl/fusinitiative/tuscalculator/). You can use this tool to specify the desired protocol. Note that all three levels of the nesting (pulse, pule train, pulse train, pulse train repetition) have to be specified. If you only want to simulate a pulse train without repetitions, set PTRI and PTRD to be equal to the PTD. -For protocols with long intervals between successive pulse trains, a coarser temporal sampling resolution may be suffient. This can be specified with ```post_pt_timestep```. +For protocols with long intervals between successive pulse trains, a coarser temporal sampling resolution may be suffient. This can be specified with `post_pt_timestep`. -# Examples +### Example: Timing Specification Continuous pulse train protocols can be modelled either as (a) a single pulse train repetition with multiple pulse trains, or as (b) multiple pulse train repetitions of a single pulse train. e.g., modeling a 20s, 5 Hz, 10% DC protocol as a single PTR (a): -- `pd`: 0.02 # Pulse Duration (PD) [seconds] -- `pri`: 0.2 # Pulse Repetition Interval (PRI) [seconds] -- `ptd`: 20 # Pulse Train Duration (PTD) [seconds] -- `pt_timestep`: 0.02 # Modeling time steps inside a PT [seconds] -- `ptri`: 20 # Pulse Train Repetition Interval (PTRI) [seconds] -- `ptrd`: 20 # Pulse Train Repetition Duration (PTRD) [seconds] -- `post_ptri_dur`: 20 # Steady-state Duration following the PTRI [seconds] -- `post_pt_timestep`: 1 # Modeling time steps following PT & PTRI [seconds] -- `equal_step_duration`: 0 | [0] Variable step duration between pulse on & off [fixed step count = 1] [1] Variable step number between pulse on & off [fixed step duration: sim_time_steps] +- `pd` | 0.02 | Pulse Duration (PD) [seconds] +- `pri` | 0.2 | Pulse Repetition Interval (PRI) [seconds] +- `ptd` | 20 | Pulse Train Duration (PTD) [seconds] +- `pt_timestep` | 0.02 | Modeling time steps inside a PT [seconds] +- `ptri` | 20 | Pulse Train Repetition Interval (PTRI) [seconds] +- `ptrd` | 20 | Pulse Train Repetition Duration (PTRD) [seconds] +- `post_ptri_dur` | 20 | Steady-state Duration following the PTRI [seconds] +- `post_pt_timestep` | 1 | Modeling time steps following PT & PTRI [seconds] +- `equal_step_duration` | 0 | [0] Variable step duration between pulse on & off [fixed step count = 1] [1] Variable step number between pulse on & off [fixed step duration: sim_time_steps] You may also specify a protocol version that neglects the fine-grained heating dynamics between each pulse. In this scenario, you can model a single longer pulse train. Modeling a long PTR with a single Pulse - single PT has the added benefit of allowing a coarser estimation step size during the (collapsed) OFF period. -- `pd`: 2 -- `pri`: 2 -- `ptd`: 2 -- `pt_timestep`: 0.02 -- `ptri`: 20 -- `ptrd`: 20 -- `post_ptri_dur`: 0 -- `post_pt_timestep`: 1 +- `pd` | 2 +- `pri` | 2 +- `ptd` | 2 +- `pt_timestep` | 0.02 +- `ptri` | 20 +- `ptrd` | 20 +- `post_ptri_dur` | 0 +- `post_pt_timestep` | 1 Heat dissipation continues beyond the final ultrasound pulse. To capture delayed heating, it is advised to run simulations until a steady-state has been reached. This can be specified via ```parameters.thermal.post_ptri_dur```. This acquires the corresponding duration following the end of the protocol. Time steps for the post-stimulation duration can be defined to be different from the time steps of the main simulation ```pt_timestep``` (by default they are equal, unless explicitly specified via ```parameters.thermal.post_pt_timestep```). Alternatively, for a continuous pulse train, a steady-state phase could be included as part of an extended PTR interval. The former option enables a steady-state phase also for protocols with PTRs. -# Parameters explained +### Additional parameters These parameters influence the underlying mechanisms of how the thermal simulations are run. @@ -53,7 +53,7 @@ These parameters influence the underlying mechanisms of how the thermal simulati There are two implementations of thermal dose calculations: -- By default, PRESTUS calculates CEM43 as implemented in kWave's kWaveDiffusion. +- By default, PRESTUS calculates CEM43 as implemented in k-Wave's kWaveDiffusion. ``` cem43 = cem43 + ... diff --git a/documentation/doc_testing.md b/documentation/doc_testing.md new file mode 100644 index 00000000..0e8173e3 --- /dev/null +++ b/documentation/doc_testing.md @@ -0,0 +1,89 @@ +# Testing + +PRESTUS uses MATLAB's built-in [`matlab.unittest`](https://www.mathworks.com/help/matlab/matlab-unit-test-framework.html) framework. Tests live in `tests/` at the repo root and are organised into two tiers. + +--- + +## Tier 1 β€” Unit tests + +Unit tests cover pure, deterministic functions β€” no external tools, no file I/O, no k-Wave. They run in seconds on any machine. + +| File | Functions covered | +|---|---| +| `test_helper.m` | `round_if_integer`, `find_min_factor`, `get_crop_dims`, `masked_max_3d`, `charm_seg_labels`, `get_flhm_center_position`, `cast_struct`, `get_xyz_mesh`, `zip_fields`, `subset_fields` | +| `test_thermal_parameters.m` | `thermal_parameters` β€” duty cycle, pulse counts, step discretisation, validation errors | +| `test_transform.m` | `ras_to_grid`, axisymmetric round-trip size checks | +| `test_load_parameters.m` | `load_parameters` β€” default keys, config merging, affix sanitisation | +| `test_head_preprocessing.m` | `get_crop_dims`, `preproc_medium_mask`, `skull_fill_holes` on synthetic segmentation volumes | + +Run all unit tests from MATLAB: + +```matlab +run_all_tests % equivalent to run_all_tests('unit') +``` + +From the command line (e.g. in CI): + +```bash +matlab -batch "run_all_tests" +``` + +--- + +## Tier 2 β€” Integration / smoke tests + +Integration tests verify that pipeline stages reach expected output checkpoints on real demo data. They are tagged by pipeline depth so only the relevant subset runs. + +| Tag | Pipeline coverage | Typical duration | +|---|---|---| +| `smoke_config` | Config loading only | seconds | +| `smoke_head` | Head preprocessing up to medium masks | 1–5 min | +| `smoke_acoustic` | Full acoustic simulation | 10–60 min | +| `smoke_thermal` | Thermal simulation (reuses cached acoustic output) | 10–60 min | + +Each level is a superset of the one above; `run_all_tests('acoustic')` also runs `smoke_config` and `smoke_head`. + +### Prerequisites + +1. Run SimNIBS `charm` segmentation for the demo subject (or set `modules.segmentation_only = 1` to do this in isolation; see [Modules](doc_modules.md)). +2. Set two environment variables before starting MATLAB: + +```bash +export PRESTUS_TEST_DATA=/path/to/demo/data # folder containing m2m_sub-001/ +export PRESTUS_DEMO_CONFIG=/path/to/config.yaml # optional; defaults to tutorial_config.yaml +``` + +### Running a specific level + +```matlab +run_all_tests('head') % unit + smoke_config + smoke_head +run_all_tests('acoustic') % ... + smoke_acoustic +run_all_tests('all') % full suite +``` + +Or target a single tag directly: + +```matlab +runtests('tests/test_integration_pipeline.m', 'Tag', 'smoke_head') +``` + +Tests that require demo data skip gracefully (rather than failing) when `PRESTUS_TEST_DATA` is not set, so the unit test level is always safe to run in CI without data. + +--- + +## What is not tested + +The following are intentionally out of scope for automated tests and are validated through the demo/tutorial runs instead: + +- Acoustic and thermal k-Wave simulations (require licensed k-Wave and GPU/CPU compute) +- SimNIBS segmentation calls +- HPC job submission +- Plot and visualisation functions +- NIfTI read/write as primary logic + +--- + +## Adding new tests + +- **New pure function** β†’ add a `methods (Test)` block to the relevant `test_*.m` file, or create a new class file following the same `matlab.unittest.TestCase` pattern. +- **New pipeline stage** β†’ add a tagged method to `test_integration_pipeline.m` and a corresponding case in `run_all_tests.m` if the stage warrants its own level. diff --git a/documentation/doc_transducer.md b/documentation/doc_transducer.md new file mode 100644 index 00000000..e7e470be --- /dev/null +++ b/documentation/doc_transducer.md @@ -0,0 +1,77 @@ +## Transducer definition + +PRESTUS does not provide default transducer calibrations. Instead, it encourages users to set up transducers and optimize settings to match their empirical setup. + +See the full [parameter documentation](doc_parameters.md#transducer-specification). + +#### Static parameters + +`transducer.source_freq_hz` +Central frequency of the acoustic source (in Hz). + +`transducer.n_elements` +Number of transducer elements. + +`transducer.Elements_ID_mm` +Inner diameter of each transducer element (in mm). + +`transducer.Elements_OD_mm` +Outer diameter of each transducer element (in mm). + +`transducer.curv_radius_mm` +Radius of curvature of the transducer bowl (in mm). + +`transducer.dist_to_plane_mm` +Distance from the geometric focus to the transducer plane (in mm). + +#### Dynamic parameters: amplitude and focus + +These parameters are traditionally calibrated in free-water simulations (see below). + +`transducer.source_amp` +Amplitude of the acoustic source (in Pa). [**CALIBRATED**] + +`transducer.source_phase_deg` +Phase of the acoustic source (in degrees). [**CALIBRATED**] + +`transducer.source_phase_rad` +Phase of the acoustic source (in radians). [**CALIBRATED**] + +See [transducer calibration](doc_calibration.md). + +#### Placement parameters + +See also [transducer placement](doc_placement.md). + +In `water` simulations, the placement will be automatically determined based on the edge of the PML layer. + +`transducer.trans_pos` +Position of transducer bowl (XYZ, T1 grid voxel space). + +`transducer.focus_pos` +Position of stimulation target (XYZ, T1 grid voxel space). + +#### Target distance parameters + +![PRESTUS transducer distance definitions](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/transducer_distances.png) + +The figure shows PRESTUS definitions for annular transducer distances. + +PRESTUS expects one either of the following to be specified (or it will attempt a geometric distance calculation based on `trans_pos` and `focus_pos`, if specified). + +`expected_focal_distance_ep` +Expected distance from the transducer exit plane to the stimulation focus (mm). This often corresponds to the focal depth setting of the driving system - transducer calibration. + +`expected_focal_distance_bowl` +Expected distance from the transducer bowl to the stimulation focus (mm). Will be internally calculated based on `expected_focal_distance_ep`, `dist_to_plane_mm`, and `curv_radius_mm`. + +## Transducer modeling + +The `setup_source` function creates realistic transducer sources for k-Wave simulations, supporting both simple uniform elements and advanced curved arrays that produce focused beams. The function produces binary masks (active source locations) and time-varying pressure signals. Two setups are supported: + +1. Realistic kWaveArray Elements (**recommended**) +The kWaveArray class (`use_kWaveArray`) in the k-Wave toolbox enables realistic simulation of multi-element phased arrays by defining transducers via physical parameters (radius, element position, apodization) rather than discrete grid points. Elements render smoothly regardless of grid resolution. Independent delays/weights per element enable dynamic focusing and element overlap is automatically handled through weighted element contributions. Supports annular rings, axisymmetric 2D simulations, and GPU acceleration. This is the recommended setup type. +
+2. Simple Custom Elements +Creates single- or multi-element bowl-shaped (3D) or arc-shaped (2D) pressure source regions for each transducer element using geometric functions (k-Wave function: `makeBowl`). Each element gets identical continuous wave (CW) signals. Best for uniform, non-overlapping arrays where you want simple control. The discretization of grid points can introduce staircasing artifacts in the resulting `source.p` masks. + diff --git a/documentation/doc_troubleshooting.md b/documentation/doc_troubleshooting.md index 78b23e31..28dacfe6 100644 --- a/documentation/doc_troubleshooting.md +++ b/documentation/doc_troubleshooting.md @@ -1,4 +1,5 @@ # Troubleshooting + This document contains fixes to known usage issues. If you find and solve an issue not listed here, please add it with an elaborate explanation. If you stumble upon an error in the working of the pipeline, or have suggestions for improvement, please [open an issue on GitHub](https://github.com/Donders-Institute/PRESTUS/issues). ## Simulation errors @@ -11,4 +12,4 @@ This document contains fixes to known usage issues. If you find and solve an iss - When running bilateral simulations simultaneously when segmentation has not yet been completed, both will try to start a segmentation run. - One of these will produce an `segment_error` file in the `batch_job_logs` folder. Simply wait for the segmentation to complete and run both again. - A common segmentation error is `ValueError: The qform and sform of do not match. Please run charm with the --forceqform option`. - - This can be solved by pasting `use_forceqform: 1` into your config file. + - This can be solved by adding `segmentation.use_qform: 1` to your config file. diff --git a/documentation/img/figure_0.png b/documentation/img/figure_0.png deleted file mode 100644 index bd789fd8..00000000 Binary files a/documentation/img/figure_0.png and /dev/null differ diff --git a/documentation/img/figure_1.png b/documentation/img/figure_1.png deleted file mode 100644 index acadebb9..00000000 Binary files a/documentation/img/figure_1.png and /dev/null differ diff --git a/documentation/img/figure_2.png b/documentation/img/figure_2.png deleted file mode 100644 index 301fe10f..00000000 Binary files a/documentation/img/figure_2.png and /dev/null differ diff --git a/documentation/img/figure_3.png b/documentation/img/figure_3.png deleted file mode 100644 index dc40545f..00000000 Binary files a/documentation/img/figure_3.png and /dev/null differ diff --git a/documentation/img/figure_4.png b/documentation/img/figure_4.png deleted file mode 100644 index 81193b49..00000000 Binary files a/documentation/img/figure_4.png and /dev/null differ diff --git a/documentation/img/figure_5.png b/documentation/img/figure_5.png deleted file mode 100644 index 7900ea5e..00000000 Binary files a/documentation/img/figure_5.png and /dev/null differ diff --git a/documentation/img/figure_6.png b/documentation/img/figure_6.png deleted file mode 100644 index 976fcf70..00000000 Binary files a/documentation/img/figure_6.png and /dev/null differ diff --git a/documentation/img/figure_7.png b/documentation/img/figure_7.png deleted file mode 100644 index b3bc2654..00000000 Binary files a/documentation/img/figure_7.png and /dev/null differ diff --git a/examples/Imasonic_test_ISPPA_50mm.csv b/examples/Imasonic_test_ISPPA_50mm.csv deleted file mode 100755 index d79410e1..00000000 --- a/examples/Imasonic_test_ISPPA_50mm.csv +++ /dev/null @@ -1,280 +0,0 @@ -0.5;0.82322 -1;1.05247 -1.5;1.29425 -2;1.52881 -2.5;1.73513 -3;1.89481 -3.5;1.99568 -4;2.03447 -4.5;2.01748 -5;1.95933 -5.5;1.88006 -6;1.80172 -6.5;1.74548 -7;1.72996 -7.5;1.77094 -8;1.88167 -8.5;2.07288 -9;2.35167 -9.5;2.71909 -10;3.16707 -10.5;3.6764 -11;4.21711 -11.5;4.75217 -12;5.2436 -12.5;5.65898 -13;5.97584 -13.5;6.1825 -14;6.27572 -14.5;6.25685 -15;6.12917 -15.5;5.89791 -16;5.57317 -16.5;5.17418 -17;4.73199 -17.5;4.28852 -18;3.89161 -18.5;3.58827 -19;3.41972 -19.5;3.42048 -20;3.62068 -20.5;4.04848 -21;4.7292 -21.5;5.68097 -22;6.90963 -22.5;8.4071 -23;10.15505 -23.5;12.13246 -24;14.32323 -24.5;16.71969 -25;19.32047 -25.5;22.12356 -26;25.11767 -26.5;28.27527 -27;31.55042 -27.5;34.88237 -28;38.20446 -28.5;41.45574 -29;44.59169 -29.5;47.59075 -30;50.4545 -30.5;53.20128 -31;55.85523 -31.5;58.43399 -32;60.93888 -32.5;63.35037 -33;65.63008 -33.5;67.72864 -34;69.59662 -34.5;71.19506 -35;72.50257 -35.5;73.51758 -36;74.25576 -36.5;74.74426 -37;75.01456 -37.5;75.09571 -38;75.00931 -38.5;74.76732 -39;74.37324 -39.5;73.82641 -40;73.12811 -40.5;72.28721 -41;71.32305 -41.5;70.26392 -42;69.1414 -42.5;67.98225 -43;66.80086 -43.5;65.59511 -44;64.34738 -44.5;63.03095 -45;61.61974 -45.5;60.09847 -46;58.46942 -46.5;56.75321 -47;54.98338 -47.5;53.19662 -48;51.4225 -48.5;49.67635 -49;47.95773 -49.5;46.25462 -50;44.55149 -50.5;42.83811 -51;41.11569 -51.5;39.39834 -52;37.70934 -52.5;36.074 -53;34.51197 -53.5;33.03165 -54;31.62871 -54.5;30.28881 -55;28.99327 -55.5;27.72563 -56;26.47666 -56.5;25.24642 -57;24.04272 -57.5;22.87697 -58;21.75921 -58.5;20.69414 -59;19.67959 -59.5;18.70768 -60;17.76814 -60.5;16.8523 -61;15.95627 -61.5;15.08221 -62;14.23716 -62.5;13.42997 -63;12.66765 -63.5;11.95237 -64;11.28032 -64.5;10.6426 -65;10.02798 -65.5;9.42649 -66;8.83251 -66.5;8.24646 -67;7.67437 -67.5;7.1256 -68;6.60967 -68.5;6.13336 -69;5.69889 -69.5;5.30388 -70;4.94256 -70.5;4.6079 -71;4.2937 -71.5;3.99609 -72;3.71387 -72.5;3.44785 -73;3.19953 -73.5;2.96975 -74;2.75783 -74.5;2.56155 -75;2.37781 -75.5;2.20374 -76;2.03758 -76.5;1.87925 -77;1.73002 -77.5;1.59177 -78;1.46587 -78.5;1.35235 -79;1.24958 -79.5;1.15453 -80;1.06357 -80.5;0.97355 -81;0.88269 -81.5;0.79111 -82;0.70062 -82.5;0.61407 -83;0.53431 -83.5;0.46341 -84;0.40223 -84.5;0.35047 -85;0.30706 -85.5;0.27054 -86;0.23953 -86.5;0.21288 -87;0.18974 -87.5;0.16949 -88;0.15164 -88.5;0.13572 -89;0.12132 -89.5;0.10816 -90;0.09616 -90.5;0.08548 -91;0.07648 -91.5;0.06958 -92;0.06509 -92.5;0.06317 -93;0.06369 -93.5;0.06625 -94;0.07017 -94.5;0.07452 -95;0.07838 -95.5;0.08108 -96;0.08246 -96.5;0.08292 -97;0.08331 -97.5;0.0846 -98;0.08773 -98.5;0.09338 -99;0.10192 -99.5;0.11339 -100;0.12754 -100.5;0.14383 -101;0.1615 -101.5;0.1796 -102;0.19702 -102.5;0.21251 -103;0.2248 -103.5;0.23286 -104;0.23616 -104.5;0.23499 -105;0.23049 -105.5;0.22453 -106;0.21926 -106.5;0.21672 -107;0.21851 -107.5;0.22555 -108;0.23799 -108.5;0.25525 -109;0.27603 -109.5;0.29859 -110;0.32107 -110.5;0.34193 -111;0.36025 -111.5;0.37582 -112;0.38902 -112.5;0.40053 -113;0.411 -113.5;0.4209 -114;0.43053 -114.5;0.4401 -115;0.44993 -115.5;0.46053 -116;0.47247 -116.5;0.48619 -117;0.50161 -117.5;0.51786 -118;0.53326 -118.5;0.54552 -119;0.55234 -119.5;0.55208 -120;0.54438 -120.5;0.53034 -121;0.51222 -121.5;0.4929 -122;0.47518 -122.5;0.46128 -123;0.45264 -123.5;0.44996 -124;0.45336 -124.5;0.46259 -125;0.47718 -125.5;0.49651 -126;0.51982 -126.5;0.54615 -127;0.5743 -127.5;0.60281 -128;0.63007 -128.5;0.65444 -129;0.6745 -129.5;0.6892 -130;0.69813 -130.5;0.70152 -131;0.70025 -131.5;0.69566 -132;0.68926 -132.5;0.68239 -133;0.67596 -133.5;0.67027 -134;0.66494 -134.5;0.65916 -135;0.65194 -135.5;0.64247 -136;0.63048 -136.5;0.61638 -137;0.60116 -137.5;0.58618 -138;0.57281 -138.5;0.56208 -139;0.55448 -139.5;0.54988 -140;0.54755 diff --git a/examples/Imasonic_test_ISPPA_65mm.csv b/examples/Imasonic_test_ISPPA_65mm.csv deleted file mode 100755 index ac9bf37c..00000000 --- a/examples/Imasonic_test_ISPPA_65mm.csv +++ /dev/null @@ -1,280 +0,0 @@ -ο»Ώ0;1.20169 -0.5;1.21579 -1;1.24886 -1.5;1.31792 -2;1.43407 -2.5;1.60057 -3;1.8115 -3.5;2.05141 -4;2.29738 -4.5;2.52349 -5;2.70642 -5.5;2.82987 -6;2.88634 -6.5;2.87629 -7;2.80592 -7.5;2.6853 -8;2.52739 -8.5;2.34783 -9;2.16468 -9.5;1.99748 -10;1.86552 -10.5;1.78616 -11;1.77396 -11.5;1.84052 -12;1.99456 -12.5;2.24108 -13;2.57969 -13.5;3.00279 -14;3.49515 -14.5;4.03538 -15;4.5989 -15.5;5.16089 -16;5.69786 -16.5;6.18757 -17;6.60793 -17.5;6.93672 -18;7.15303 -18.5;7.24089 -19;7.19354 -19.5;7.01657 -20;6.72776 -20.5;6.35352 -21;5.92305 -21.5;5.46271 -22;4.99293 -22.5;4.52831 -23;4.08059 -23.5;3.66234 -24;3.28956 -24.5;2.98183 -25;2.76056 -25.5;2.6467 -26;2.65953 -26.5;2.81652 -27;3.13273 -27.5;3.6185 -28;4.27582 -28.5;5.0961 -29;6.06193 -29.5;7.15331 -30;8.3555 -30.5;9.66462 -31;11.08771 -31.5;12.63732 -32;14.32273 -32.5;16.14151 -33;18.07513 -33.5;20.09047 -34;22.14728 -34.5;24.20877 -35;26.25131 -35.5;28.2691 -36;30.27172 -36.5;32.2755 -37;34.29164 -37.5;36.31571 -38;38.32257 -38.5;40.26938 -39;42.1064 -39.5;43.79242 -40;45.30924 -40.5;46.66927 -41;47.91295 -41.5;49.09623 -42;50.27245 -42.5;51.47454 -43;52.70345 -43.5;53.92661 -44;55.08701 -44.5;56.12042 -45;56.97507 -45.5;57.62707 -46;58.08602 -46.5;58.38891 -47;58.58517 -47.5;58.71863 -48;58.81333 -48.5;58.86776 -49;58.85923 -49.5;58.756 -50;58.53261 -50.5;58.1823 -51;57.72208 -51.5;57.18846 -52;56.62584 -52.5;56.07202 -53;55.54628 -53.5;55.04409 -54;54.54012 -54.5;53.99826 -55;53.38517 -55.5;52.68246 -56;51.8934 -56.5;51.04176 -57;50.1635 -57.5;49.29453 -58;48.45901 -58.5;47.66214 -59;46.8898 -59.5;46.11472 -60;45.30714 -60.5;44.44598 -61;43.52673 -61.5;42.56328 -62;41.58305 -62.5;40.61753 -63;39.69161 -63.5;38.8156 -64;37.98258 -64.5;37.17173 -65;36.35634 -65.5;35.5136 -66;34.63257 -66.5;33.71763 -67;32.78625 -67.5;31.86204 -68;30.96621 -68.5;30.11039 -69;29.29351 -69.5;28.50362 -70;27.72379 -70.5;26.9397 -71;26.14609 -71.5;25.34951 -72;24.56631 -72.5;23.81676 -73;23.11746 -73.5;22.47497 -74;21.883 -74.5;21.32407 -75;20.7751 -75.5;20.21516 -76;19.63246 -76.5;19.02819 -77;18.41567 -77.5;17.8153 -78;17.24717 -78.5;16.72425 -79;16.24825 -79.5;15.80951 -80;15.39059 -80.5;14.97235 -81;14.54014 -81.5;14.08821 -82;13.62064 -82.5;13.14885 -83;12.68676 -83.5;12.24571 -84;11.83092 -84.5;11.4407 -85;11.06827 -85.5;10.70529 -86;10.34567 -86.5;9.9881 -87;9.63639 -87.5;9.29756 -88;8.97869 -88.5;8.68369 -89;8.41145 -89.5;8.15589 -90;7.90803 -90.5;7.65918 -91;7.40416 -91.5;7.14325 -92;6.88214 -92.5;6.62987 -93;6.39547 -93.5;6.18478 -94;5.99827 -94.5;5.83076 -95;5.67287 -95.5;5.51385 -96;5.34487 -96.5;5.16149 -97;4.96472 -97.5;4.76007 -98;4.55529 -98.5;4.35763 -99;4.17168 -99.5;3.99853 -100;3.83634 -100.5;3.68186 -101;3.53235 -101.5;3.38702 -102;3.24746 -102.5;3.11701 -103;2.99925 -103.5;2.89644 -104;2.80832 -104.5;2.73184 -105;2.66186 -105.5;2.59247 -106;2.51875 -106.5;2.43805 -107;2.35064 -107.5;2.25918 -108;2.1676 -108.5;2.07955 -109;1.99725 -109.5;1.92093 -110;1.84905 -110.5;1.77922 -111;1.70921 -111.5;1.63794 -112;1.56588 -112.5;1.49473 -113;1.42667 -113.5;1.36331 -114;1.30496 -114.5;1.25034 -115;1.19702 -115.5;1.14225 -116;1.08409 -116.5;1.02223 -117;0.95833 -117.5;0.89552 -118;0.83744 -118.5;0.78722 -119;0.74662 -119.5;0.71577 -120;0.6933 -120.5;0.67688 -121;0.66383 -121.5;0.6518 -122;0.63915 -122.5;0.62508 -123;0.60944 -123.5;0.59231 -124;0.57366 -124.5;0.55315 -125;0.53022 -125.5;0.50432 -126;0.47528 -126.5;0.44352 -127;0.41014 -127.5;0.37668 -128;0.34473 -128.5;0.31556 -129;0.28981 -129.5;0.26744 -130;0.2478 -130.5;0.22994 -131;0.21292 -131.5;0.19616 -132;0.1796 -132.5;0.16373 -133;0.14929 -133.5;0.13704 -134;0.12741 -134.5;0.12042 -135;0.11564 -135.5;0.11237 -136;0.10985 -136.5;0.10749 -137;0.10504 -137.5;0.10266 -138;0.10071 -138.5;0.0996 -139;0.09953 -139.5;0.10035 \ No newline at end of file diff --git a/examples/Imasonic_test_pressure_50mm.csv b/examples/Imasonic_test_pressure_50mm.csv deleted file mode 100755 index b54a5eac..00000000 --- a/examples/Imasonic_test_pressure_50mm.csv +++ /dev/null @@ -1,282 +0,0 @@ -ο»Ώ0;50 -0;49375.67 -0.5;55829.05 -1;61910.64 -1.5;67287.21 -2;71684.02 -2.5;74909.76 -3;76877.91 -3.5;77621.38 -4;77296.61 -4.5;76174.47 -5;74617.67 -5.5;73046.6 -6;71897.43 -6.5;71577.13 -7;72420 -7.5;74649.68 -8;78350.74 -8.5;83453.53 -9;89736.24 -9.5;96846.75 -10;104343.97 -10.5;111754.1 -11;118632.03 -11.5;124615.16 -12;129456.9 -12.5;133031.81 -13;135312.63 -13.5;136328.89 -14;136123.8 -14.5;134727.76 -15;132161.54 -15.5;128471.67 -16;123787.54 -16.5;118379.9 -17;112696.37 -17.5;107354.63 -18;103085.74 -18.5;100635.57 -19;100646.67 -19.5;103550.24 -20;109496.96 -20.5;118345.07 -21;129708.18 -21.5;143048.59 -22;157789.95 -22.5;173419.16 -23;189552.94 -23.5;205957 -24;222520.6 -24.5;239202.08 -25;255966.82 -25.5;272738.09 -26;289374.03 -26.5;305674.16 -27;321409.81 -27.5;336366.86 -28;350387.41 -28.5;363398.47 -29;375420.01 -29.5;386550.38 -30;396933 -30.5;406713 -31;415995.76 -31.5;424818.48 -32;433142.43 -32.5;440867.01 -33;447860.05 -33.5;453994.13 -34;459178 -34.5;463375.27 -35;466607.54 -35.5;468944.25 -36;470484.23 -36.5;471334.19 -37;471589.06 -37.5;471317.68 -38;470556.79 -38.5;469315.06 -39;467586.56 -39.5;465369.93 -40;462686.56 -40.5;459590.55 -41;456165.39 -41.5;452506.93 -42;448697.77 -42.5;444781.97 -43;440749.54 -43.5;436537.54 -44;432049.06 -44.5;427185.07 -45;421878.96 -45.5;416121.87 -46;409969.33 -46.5;403526.32 -47;396915.61 -47.5;390240.87 -48;383557.97 -48.5;376864.71 -49;370112.45 -49.5;363234.64 -50;356181.44 -50.5;348947.39 -51;341582.12 -51.5;334180.11 -52;326853.63 -52.5;319698.81 -53;312767.24 -53.5;306053.16 -54;299500.28 -54.5;293025.07 -55;286547.65 -55.5;280019.17 -56;273436.24 -56.5;266838.16 -57;260288.74 -57.5;253850.3 -58;247559.64 -58.5;241414.94 -59;235378.15 -59.5;229391.43 -60;223401.29 -60.5;217381.1 -61;211343.4 -61.5;205337.3 -62;199431.46 -62.5;193688.61 -63;188140.88 -63.5;182775.02 -64;177533.35 -64.5;172330.81 -65;167082.55 -65.5;161732.82 -66;156275.15 -66.5;150756.99 -67;145266.92 -67.5;139909.16 -68;134773.72 -68.5;129912.61 -69;125329.42 -69.5;120985.22 -70;116817.45 -70.5;112764.45 -71;108786.21 -71.5;104874.36 -72;101048.56 -72.5;97341.77 -73;93781.23 -73.5;90373.19 -74;87097.79 -74.5;83916.06 -75;80785.97 -75.5;77680.78 -76;74601.57 -76.5;71578.43 -77;68658.89 -77.5;65887.65 -78;63285.04 -78.5;60832.86 -79;58473.38 -79.5;56122.86 -80;53695.23 -80.5;51128.26 -81;48403.14 -81.5;45551.03 -82;42644.77 -82.5;39779.01 -83;37045.76 -83.5;34513.65 -84;32216.79 -84.5;30155.4 -85;28305.79 -85.5;26634.22 -86;25108.68 -86.5;23704.68 -87;22404.25 -87.5;21191.37 -88;20048 -88.5;18954.75 -89;17897.11 -89.5;16874.97 -90;15910.62 -90.5;15049.98 -91;14354.44 -91.5;13884.06 -92;13677.14 -92.5;13733.42 -93;14007.39 -93.5;14415.5 -94;14856.04 -94.5;15235.91 -95;15496.04 -95.5;15627.16 -96;15671.03 -96.5;15707 -97;15828.78 -97.5;16118.99 -98;16629.59 -98.5;17373.01 -99;18324.86 -99.5;19434.57 -100;20638.4 -100.5;21869.5 -101;23062.76 -101.5;24155.39 -102;25086.89 -102.5;25802.29 -103;26260.42 -103.5;26445.85 -104;26380.07 -104.5;26126.67 -105;25786.47 -105.5;25481.99 -106;25334.37 -106.5;25438.76 -107;25844.93 -107.5;26548.31 -108;27493.83 -108.5;28591.13 -109;29736.51 -109.5;30835.77 -110;31821.81 -110.5;32663.07 -111;33361.53 -111.5;33942.54 -112;34440.8 -112.5;34887.93 -113;35305.87 -113.5;35707.4 -114;36102.1 -114.5;36503.17 -115;36930.41 -115.5;37406.27 -116;37945.47 -116.5;38542.36 -117;39161.93 -117.5;39739.94 -118;40194.17 -118.5;40444.41 -119;40434.94 -119.5;40152.07 -120;39630.68 -120.5;38947.92 -121;38206.51 -121.5;37513.4 -122;36960.59 -122.5;36612.87 -123;36504.33 -123.5;36641.97 -124;37012.98 -124.5;37591.98 -125;38345.85 -125.5;39235.74 -126;40217.26 -126.5;41240.59 -127;42251.92 -127.5;43196.68 -128;44024.29 -128.5;44693.65 -129;45178.23 -129.5;45469.86 -130;45580.15 -130.5;45538.99 -131;45389.51 -131.5;45180.06 -132;44954.36 -132.5;44742.23 -133;44553.27 -133.5;44375.97 -134;44182.72 -134.5;43939.81 -135;43619.56 -135.5;43210.85 -136;42724.87 -136.5;42194.11 -137;41665.09 -137.5;41186.96 -138;40799.34 -138.5;40522.83 -139;40354.45 -139.5;40268.86 -140;40224.56 \ No newline at end of file diff --git a/examples/Imasonic_test_pressure_65mm.csv b/examples/Imasonic_test_pressure_65mm.csv deleted file mode 100755 index 740ffbdb..00000000 --- a/examples/Imasonic_test_pressure_65mm.csv +++ /dev/null @@ -1,282 +0,0 @@ -ο»Ώ0;65 -0;59655.72 -0.5;60004.59 -1;60815.27 -1.5;62474.28 -2;65168.99 -2.5;68848.39 -3;73244.55 -3.5;77944 -4;82484.59 -4.5;86448.44 -5;89526.91 -5.5;91545.97 -6;92454.94 -6.5;92293.71 -7;91157.75 -7.5;89176.99 -8;86515.2 -8.5;83385.2 -9;80066.91 -9.5;76912.65 -10;74328.62 -10.5;72730.55 -11;72481.63 -11.5;73828.95 -12;76856.33 -12.5;81467.62 -13;87405.67 -13.5;94301.51 -14;101739.4 -14.5;109319.71 -15;116703.31 -15.5;123628.41 -16;129900.9 -16.5;135368.03 -17;139890.71 -17.5;143328.67 -18;145546.3 -18.5;146437.39 -19;145957.88 -19.5;144151.28 -20;141153.41 -20.5;137171.34 -21;132442.91 -21.5;127192.16 -22;121600.04 -22.5;115804.16 -23;109930.34 -23.5;104144.33 -24;98701.73 -24.5;93971.77 -25;90417.92 -25.5;88533.62 -26;88748 -26.5;91329.77 -27;96320.23 -27.5;103519.02 -28;112529.31 -28.5;122849.92 -29;133986.69 -29.5;145549.13 -30;157304.99 -30.5;169179.79 -31;181207.86 -31.5;193456.67 -32;205953.43 -32.5;218639.32 -33;231364.59 -33.5;243922.15 -34;256103.97 -34.5;267758.01 -35;278824.98 -35.5;289342.45 -36;299415.81 -36.5;309166.66 -37;318676.69 -37.5;327946.81 -38;336886.4 -38.5;345337.41 -39;353126.4 -39.5;360126.95 -40;366310.63 -40.5;371767.7 -41;376688.69 -41.5;381311.77 -42;385852.38 -42.5;390438.27 -43;395071.48 -43.5;399629.65 -44;403906.41 -44.5;407677.37 -45;410769.87 -45.5;413113.55 -46;414755.31 -46.5;415835.28 -47;416533.54 -47.5;417007.73 -48;417343.85 -48.5;417536.94 -49;417506.67 -49.5;417140.41 -50;416346.67 -50.5;415098.93 -51;413453.96 -51.5;411538.4 -52;409509.03 -52.5;407501.54 -53;405586.65 -53.5;403749.05 -54;401896.46 -54.5;399895.05 -55;397618.37 -55.5;394992.78 -56;392023.59 -56.5;388793.47 -57;385434.02 -57.5;382081.07 -58;378829.18 -58.5;375701.51 -59;372645.02 -59.5;369552.31 -60;366302.15 -60.5;362804.26 -61;359032.84 -61.5;355037.05 -62;350925.01 -62.5;346827.02 -63;342851.09 -63.5;339046.55 -64;335388.67 -64.5;331789.41 -65;328130.22 -65.5;324304.87 -66;320256.89 -66.5;315998.27 -67;311603.26 -67.5;307179.99 -68;302830.91 -68.5;298616.89 -69;294538.36 -69.5;290540.16 -70;286538.15 -70.5;282457.13 -71;278265.62 -71.5;273993.92 -72;269728.06 -72.5;265581.32 -73;261653.28 -73.5;257991.68 -74;254571.41 -74.5;251299.25 -75;248043.43 -75.5;244677.89 -76;241125.7 -76.5;237385.86 -77;233533.89 -77.5;229695.59 -78;226003.45 -78.5;222550.98 -79;219361.03 -79.5;216379.09 -80;213493.09 -80.5;210572.25 -81;207510.7 -81.5;204260.34 -82;200842.19 -82.5;197333.16 -83;193834.72 -83.5;190435.56 -84;187182.54 -84.5;184069.76 -85;181048.94 -85.5;178055.46 -86;175039.25 -86.5;171987.8 -87;168932.53 -87.5;165936.02 -88;163065.69 -88.5;160364.52 -89;157830.7 -89.5;155414.6 -90;153034.86 -90.5;150607.76 -91;148079.16 -91.5;145446.72 -92;142763.78 -92.5;140122.73 -93;137623.42 -93.5;135337.48 -94;133281.28 -94.5;131407.07 -95;129615.63 -95.5;127786.14 -96;125812.76 -96.5;123635.7 -97;121256.09 -97.5;118730.65 -98;116148.7 -98.5;113600.78 -99;111150.52 -99.5;108819.43 -100;106589.58 -100.5;104421.48 -101;102279.4 -101.5;100153.24 -102;98068.21 -102.5;96078.28 -103;94245.95 -103.5;92616.5 -104;91196.7 -104.5;89946.44 -105;88786.8 -105.5;87621.97 -106;86367.1 -106.5;84972.38 -107;83435.11 -107.5;81795.88 -108;80120.83 -108.5;78476.77 -109;76908.22 -109.5;75424.35 -110;73999.88 -110.5;72589.02 -111;71146.54 -111.5;69647.52 -112;68098.13 -112.5;66533.11 -113;65000.7 -113.5;63540.97 -114;62166.19 -114.5;60851.38 -115;59539.71 -115.5;58161.65 -116;56661.48 -116.5;55021.32 -117;53273.86 -117.5;51498.26 -118;49800.42 -118.5;48284.07 -119;47022.57 -119.5;46040.79 -120;45312.48 -120.5;44772.55 -121;44338.89 -121.5;43935.15 -122;43506.67 -122.5;43025.38 -123;42483.74 -123.5;41882.42 -124;41217.68 -124.5;40474.28 -125;39626.48 -125.5;38646.58 -126;37517.15 -126.5;36242.08 -127;34851.71 -127.5;33399.68 -128;31951.76 -128.5;30569.87 -129;29296.22 -129.5;28142.76 -130;27089.87 -130.5;26095.31 -131;25110.99 -131.5;24102.25 -132;23062.82 -132.5;22019.91 -133;21026.83 -133.5;20145.51 -134;19425.03 -134.5;18884.25 -135;18505.56 -135.5;18242.21 -136;18036.52 -136.5;17841.61 -137;17637.7 -137.5;17436.2 -138;17270.09 -138.5;17174.84 -139;17168.37 -139.5;17239.05 -140;17347.16 \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index 007d9f52..a80cbd8f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,9 +1,16 @@ # Example scripts -This folder contains example scripts that call the PRESTUS pipeline (currently with fixed paths) and examples of acoustic profiles. These need to be replaced with acoustic profiles for the transduers that should be simulated (often provided as part of the manufacturer's documentation, but can also be based on personal free-water measurements). +This folder contains example scripts that interact with the PRESTUS pipeline -An example of a possible way to use these scrips for acoustic and heating simulations is provided as a DataLad dataset at https://gin.g-node.org/PRESTUS/sim_ernie/. +- `calibration_standalone` initiates the calibration of transducer phases and amplitudes for equipment at the Donders institute. +- `createPhantom` can be used to generate 2D phantoms +- `createPseudoCT` can be used to start pseuodCT generation from UTE images +- `demo_localite` illustrates localite coordinate extraction for read-in in PRESTUS +- `hpc_compile_CUDA` contains instructions to set up and compile CUDA binaries for k-Wave -A simplified 2D benchmarking example can be found [here](https://github.com/jkosciessa/PRESTUS_2D_demo). +#### Further examples -# TO DO: document / provide updated example scripts \ No newline at end of file + +- An example of a possible way to use these scrips for acoustic and heating simulations is provided as a DataLad dataset at https://gin.g-node.org/PRESTUS/sim_ernie/. +- A simplified 2D benchmarking example can be found [here](https://github.com/jkosciessa/PRESTUS_2D_demo). +- A (currently outdated) demo that focuses on transducer calibration can be found [here](https://github.com/jkosciessa/PRESTUS_bin/blob/main/tutorial/PRESTUS_intro_tutorial.md). \ No newline at end of file diff --git a/examples/acoustic_profile_tutorial.csv b/examples/acoustic_profile_tutorial.csv deleted file mode 100755 index 799b04ed..00000000 --- a/examples/acoustic_profile_tutorial.csv +++ /dev/null @@ -1,61 +0,0 @@ -ο»Ώ4,1.50 -6,1.76 -8,2.29 -10,3.21 -12,4.09 -14,4.16 -16,3.18 -18,1.76 -20,0.82 -22,0.76 -24,1.78 -26,4.28 -28,8.17 -30,12.86 -32,17.40 -34,21.73 -36,25.74 -38,28.18 -40,29.53 -42,30.56 -44,30.31 -46,29.38 -48,28.74 -50,27.47 -52,25.98 -54,24.77 -56,23.16 -58,21.68 -60,20.57 -62,19.30 -64,18.09 -66,16.95 -68,15.77 -70,14.83 -72,14.02 -74,13.18 -76,12.42 -78,11.70 -80,11.04 -82,10.47 -84,9.92 -86,9.39 -88,8.89 -90,8.43 -92,8.00 -94,7.56 -96,7.14 -98,6.77 -100,6.43 -102,6.14 -104,5.87 -106,5.59 -108,5.31 -110,5.04 -112,4.79 -114,4.55 -116,4.33 -118,3.64 -120,1.90 -122,0.39 -124,0.02 diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index 7ac88fb8..24a88d13 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -22,7 +22,7 @@ % Set up paths % Determine the current and main folder paths -func_path = fileparts(mfilename('fullpath'), '..'); +func_path = fullfile(fileparts(mfilename('fullpath')), '..', '..'); main_folder = fileparts(func_path); cd(main_folder); % Change directory to the main folder @@ -33,6 +33,13 @@ %% Load configuration settings +config_folder = ""; % [optional] specify a application-specific config folder +if strcmp(config_folder, "") + warning("Using configurations in PRESTUS config folder. It is recommended that you work with a local copy instead..."); +else + cd(config_folder) % move to local folder containing configs +end + % Load equipment parameters from the YAML configuration file equip_param = yaml.loadFile('equipment_config.yaml', 'ConvertToArray', true); % Equipment information (by default Donders-specific) @@ -45,6 +52,11 @@ disp('Available Equipment Combinations:'); disp(available_combos); +% Create output folder (if not yet available) +if ~exist(parameters.calibration.path_output) + mkdir(parameters.calibration.path_output); +end + %% Iterate through equipment combinations N_i = length(parameters.calibration.combinations); for i = 1:N_i @@ -93,114 +105,111 @@ dist_from_exit_plane = charac_data(3:end, 1); intens_data = charac_data(3:end, 2:end); - % Convert exit plane reference to mid-bowl reference - % Simulation results are w.r.t. mid-bowl of transducer - % Profiles are referenced to 0 = exit plane. - % If the profile is taken from the bowl: add the distance from the exit plane (if requested) - if isfield(parameters.calibration, 'addEPdistance') && parameters.calibration.addEPdistance == 1 - dist_tran_exit_plane = parameters.transducer.curv_radius_mm - parameters.transducer.dist_to_plane_mm; - else - dist_tran_exit_plane = 0; - end - dist_from_tran = dist_from_exit_plane + dist_tran_exit_plane; - % Ensure focal depths are specified. % If no focal depths, perform the simulations for all available focal depths if isempty(parameters.calibration.focal_depths_wrt_exit_plane{i}) parameters.calibration.focal_depths_wrt_exit_plane{i} = available_foci_wrt_exit_plane; end - % Load default parameters incl. additional chosen parameters like transducer - parameters = load_parameters(parameters); - fprintf('Equipment: transducer %s and driving system %s \n', [tran.name, ds.name]) % Iterate across focal depths N_j = length(parameters.calibration.focal_depths_wrt_exit_plane{i}); for j = 1:N_j - focus_wrt_exit_plane = round(parameters.calibration.focal_depths_wrt_exit_plane{i}{j}, 2); - fprintf('Focus: %.2f \n', focus_wrt_exit_plane) + + focal_distance_ep = round(parameters.calibration.focal_depths_wrt_exit_plane{i}{j}, 2); + fprintf('Focus from exit plane: %.2f \n', focal_distance_ep) + + parameters.expected_focal_distance_ep = focal_distance_ep; + + % Load default parameters incl. additional chosen parameters like transducer + parameters = load_parameters(parameters); % Verify focal range - if focus_wrt_exit_plane < tran.min_foc || focus_wrt_exit_plane > tran.max_foc + if focal_distance_ep < tran.min_foc || focal_distance_ep > tran.max_foc warning('Focus %.2f mm is outside the range [%.2f, %.2f] mm. Skipping.\n', ... - focus_wrt_exit_plane, tran.min_foc, tran.max_foc); + focal_distance_ep, tran.min_foc, tran.max_foc); continue; end - % [SIM] Set the expected focal distance to exit plane - % Account for the potential distance between bowl (actual focal - % distance) and exit plane (axial profile definition) - parameters.expected_focal_distance_mm = focus_wrt_exit_plane; - parameters.expected_focal_distance_bowl_mm = focus_wrt_exit_plane + dist_tran_exit_plane; - % [SIM] Set the manufacturer-specified phases of the transducer for the focal distance - source_phase_deg = set_real_phases(phase_table, tran, focus_wrt_exit_plane, parameters); + source_phase_deg = set_real_phases(phase_table, tran, focal_distance_ep, parameters); parameters.transducer.source_phase_deg = source_phase_deg; clear source_phase_deg; % Interpolate or select axial profile - [profile_focus, max_intens] = extract_real_intensity_profile(... + [profile_focus_ep, max_intens] = extract_real_intensity_profile(... parameters,... available_foci_wrt_exit_plane, ... - focus_wrt_exit_plane, ... + focal_distance_ep, ... intens_data, ... equipment_name, ... - dist_from_tran); + dist_from_exit_plane); + + % figure; plot(dist_from_exit_plane, profile_focus_ep); hold on; xline(focal_distance_ep) % Iterate across intensities N_k = length(parameters.calibration.desired_intensities{i}); for k = 1:N_k desired_intensity = parameters.calibration.desired_intensities{i}{k}; + desired_focal_distance_ep = focal_distance_ep; % assign a loop-specific simulation id sim_id = (i-1)*N_j*N_k + (j-1)*N_k + (k-1) + 1; + % By default, values are reported from the exit plane of the transducer + % For simulations, we need to implement the distance from the bowl + + % If the profile is taken from the bowl: add the distance from the exit plane (if requested) + if isfield(parameters.calibration, 'add_FDO') && parameters.calibration.add_FDO == 1 + dist_bowl_exit_plane = parameters.transducer.curv_radius_mm - parameters.transducer.dist_to_plane_mm; + else + dist_bowl_exit_plane = 0; + end + dist_bowl_focus = dist_from_exit_plane + dist_bowl_exit_plane; + + % Set the expected focal distance to exit plane + % Account for the potential distance between bowl (actual focal distance) and exit plane (axial profile definition) + parameters.expected_focal_distance_ep = focal_distance_ep; + parameters.expected_focal_distance_bowl = focal_distance_ep + dist_bowl_exit_plane; + % if the original profiles are measured from the exit plane, % we do not model the space until the exit plane; this can lead % to suboptimal solutions; assume that the amplitude is zero in % that space (i.e., no strong near-field interference) % add distance values prior to the exit plane - parameters.calibration.interpolateToEP = 1; - if parameters.calibration.interpolateToEP == 1 - Nvals = round(dist_from_tran(1)/(dist_from_tran(2)-dist_from_tran(1))); - dist_from_tran_tmp = cat(1, ... - linspace(0, dist_from_tran(1), Nvals)',... - dist_from_tran); - % set those to initial value in amplitude profile - profile_focus_tmp = cat(1, repmat(profile_focus(1),Nvals,1), profile_focus'); + if isfield(parameters.calibration, 'add_FDO') && parameters.calibration.add_FDO == 1 + Nvals = round(dist_bowl_focus(1)/(dist_bowl_focus(2)-dist_bowl_focus(1))); + dist_bowl_focus = cat(1, linspace(0, dist_bowl_focus(1), Nvals)',dist_bowl_focus); + % set those to zero in amplitude profile + profile_focus = cat(1, repmat(0, Nvals,1), profile_focus_ep'); else - dist_from_tran_tmp = dist_from_tran; - profile_focus_tmp = profile_focus; + profile_focus = profile_focus_ep; end - % [cleanup] regularize lower values to 0 [possible after interpolation] - profile_focus_tmp(profile_focus_tmp<0) = 0; + % regularize lower values to 0 [possible after interpolation] + profile_focus(profile_focus<0) = 0; % collect data on empirical profile - profile_empirical.profile_focus = profile_focus_tmp; - profile_empirical.dist_from_tran = dist_from_tran_tmp; - profile_empirical.focus_wrt_exit_plane = focus_wrt_exit_plane; - - % convert from default 3D to 2D axisymmetric simulation (if requested) - if isfield(parameters.calibration, 'axisymmetric2D') && ... - parameters.calibration.axisymmetric2D == 1 - parameters.n_sim_dims = 2; - parameters.axisymmetric = 1; - if numel(parameters.default_grid_dims)==3 - parameters.default_grid_dims(2) = []; - end - end + profile_empirical.axial_intensity = profile_focus; + profile_empirical.axial_distance_bowl = dist_bowl_focus; + + % figure; plot(profile_empirical.axial_distance_bowl, profile_empirical.profile_focus); + % hold on; xline(parameters.expected_focal_distance_bowl) % Show current iteration disp(['Profiling: ', num2str(sim_id), ' - ', equipment_name{1}, ... - ' F ', num2str(profile_empirical.focus_wrt_exit_plane), ' I ', num2str(desired_intensity)]) + ' F ', num2str(desired_focal_distance_ep), ' I ', num2str(desired_intensity)]) + + % Set to always overwrite existing calibration files + parameters.io.overwrite_files = 'always'; % perform the calibration calibration_transducer(... profile_empirical, ... equipment_name, ... desired_intensity, ... + desired_focal_distance_ep,... parameters,... sim_id) diff --git a/examples/createPseudoCT.sh b/examples/createPseudoCT.sh new file mode 100644 index 00000000..804ade82 --- /dev/null +++ b/examples/createPseudoCT.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +rootpath="$(pwd)/.." # move to functions directory +rootpath=$(builtin cd $rootpath; pwd) + +# Load necessary modules +module load ants +module load matlab +module load fsl + +# Change directory to the scripts directory +scriptpath=${rootpath} +cd "${scriptpath}" || { echo "Directory not found"; exit 1; } + +# Source the script containing the function +source ${scriptpath}/pct/pct_create_pseudoCT.sh + +for i in {1..1}; do # run only for sub-001 + subject_id=$(printf "%03d" ${i}) + m2m_path=${rootpath}/data/simnibs + echo "$subject_id" + + # Call the create_pseudoCT function + pct_create_pseudoCT "$subject_id" "$m2m_path" "/opt/matlab/R2022b/bin/matlab" "miscouridou" "1" # use miscouridou mapping +done diff --git a/examples/demo_localite.m b/examples/demo_localite.m index 2e8c005d..b0ff2ed7 100644 --- a/examples/demo_localite.m +++ b/examples/demo_localite.m @@ -1,4 +1,4 @@ -% DEMO_THALSTIM_LOCALITE - Demo extraction of Localite coordinates +% DEMO_LOCALITE - Demo extraction of Localite coordinates % The goal is to obtain transducer and target positions as they have been % captured by localite trackers. They will be provided in the format @@ -10,76 +10,74 @@ % For optional MNI analysis, a matching simnibs segmentation and simbins binary path (parameters.simnibs_bin_path) must be specified. % Clear environment to ensure a clean state before running the demo -restoredefaultpath; % Restore MATLAB's default search path to avoid conflicts +restoredefaultpath; % Restore MATLAB's default search path to avoid conflicts clear all; % Clear all variables from workspace close all; % Close all figure windows clc; % Clear the command window % --- Hard-coded demo inputs --- -demo_sub_id = 'sub-001'; % Subject identifier for demo processing -demo_session = 'ses-02'; % Session identifier to process (localite files are expected to follow a sub-xxx/ses-xx organization) +demo_sub_id = 'sub-003'; % Subject identifier for demo processing +demo_session = 'ses-01'; % Session identifier to process (localite files are expected to follow a sub-xxx/ses-xx organization) demo_setup = 'CTX500-026-010_79.6mm'; % Transducer/setup configuration name (suffix of 'config_XXX') -demo_sim_dir = 'CTX500-026-010_79.6mm_60W_post'; % Simulation output folder name based on setup and intensity -demo_positions = {'left X', 'right X'}; % Labels for multiple positions (left and right hemisphere) +demo_positions = {'lVS', 'rVS'}; % Labels for multiple positions (left and right hemisphere) +demo_markertype = 'GUMMarkers'; % TriggerMarkers/GUMMarkers -rootpath = ''; % Root directory of data and tools, adjust to your environment +rootpath = '/project/2425122.01/kplan_example/'; % Root directory of data and tools, adjust to your environment % Indicate whether to compute and include MNI-space coordinates (optional) mni_coords_requested = 0; % Define key path structure for data and toolboxes -pn.tuSIM = fullfile(rootpath, 'tools', 'PRESTUS'); % PRESTUS toolbox path +pn.tuSIM = fullfile('/project/2425122.01/v05/', 'tools', 'PRESTUS'); % PRESTUS toolbox path addpath(pn.tuSIM); pn.tuSIM_fun = fullfile(pn.tuSIM, 'functions'); % PRESTUS functions path addpath(genpath(pn.tuSIM_fun)); pn.tuSIM_tools = fullfile(pn.tuSIM, 'toolboxes'); % PRESTUS toolboxes path addpath(genpath(pn.tuSIM_tools)); +% [Optional, MNI transform] Set user/environment-specific paths and parameters +% parameters.io.simnibs_bin_path = fullfile('/home', 'neuromod', 'julkos', '.conda', 'envs', 'simnibs_env', 'bin'); +% parameters.io.data_path = fullfile(pn.data_seg, sprintf('m2m_%s', demo_sub_id)); +% parameters.io.seg_path = pn.data_seg; +% parameters.hpc.ld_library_path = "/opt/gcc/7.2.0/lib64"; + % The expected folder structure for key data and intermediate files is: -% (1) Localite trigger position files in: +% (1a) [TriggerMarkers] Localite trigger position files in: % /sub-XXX/ses-XX/localite/Session_XXXX/TMSTrigger +% (1b) [GUMMarkers] Localite General positioning markers in: +% /sub-XXX/ses-XX/localite/ % (2) A planning image aligned with localite coordinate expectations: -% /sub-XXX_T1_forneuronav.nii.gz +% /sub-XXX_T1(_forneuronav).nii(.gz) % (3) SIMNIBS segmentation folder for the subject: % /m2m_sub-XXX/ % (4) PRESTUS config files, for example in: % / pn.data_path = fullfile(rootpath, 'data'); % Base data path -pn.data_postlocalite = fullfile(pn.data_path, 'demo_neuronav'); % Folder [1] where localite XML are stored +pn.data_postlocalite = fullfile(pn.data_path, 'localite'); % Folder [1] where localite XML are stored pn.data_prelocalite = fullfile(pn.data_path, 'localite'); % Folder [2] containing T1 planning images pn.data_seg = fullfile(pn.data_path, 'simnibs'); % Folder [3] Segmentation files for SIMNIBS pn.configs = fullfile(pn.data_path, 'configs'); % Folder [4] containing config YAMLs -% Move to data root for relative file handling -cd(fullfile(pn.configs, '..')); - % Load stimulation parameters from the transducer YAML config +cd(pn.configs); parameters = load_parameters(['config_', demo_setup, '.yaml']); -% Set user/environment-specific paths and parameters -parameters.simnibs_bin_path = fullfile('/home', 'neuromod', 'julkos', '.conda', 'envs', 'simnibs_env', 'bin'); -parameters.data_path = fullfile(pn.data_seg, sprintf('m2m_%s', demo_sub_id)); -parameters.seg_path = pn.data_seg; -parameters.ld_library_path = "/opt/gcc/7.2.0/lib64"; -parameters.sim_path = fullfile(pn.data_path, 'tussim', demo_sim_dir); - % Select the latest valid Localite trigger XML file for the subject and session -localite = neuronav_select_and_average_localite(demo_sub_id, demo_session, pn); +localite = neuronav_select_and_average_localite(pn, demo_sub_id, demo_session, demo_markertype); % Calculate statistics over stimulus series (e.g., average position, variability) -% Parameters: voxel size of planning image (0.9 mm), expected localite trigger train length (80) -[results, n_series] = neuronav_compute_series_statistics(localite, parameters, 0.9, 80); - -% Generate averaged trigger marker structure for subsequent conversions and visualization -outputStruct = neuronav_create_marker_average(localite, results); +% Parameters: voxel size of planning image (1 mm), expected localite trigger train length (2) +[results] = neuronav_compute_series_statistics(localite, 1, 2, demo_markertype); % Fix demo position indices as 1 (left) and 2 (right), matching demo_positions positions = 1:numel(demo_positions); % Convert averaged triggers to voxel indices and RAS coordinates in native space [trans_ras, trans_pos, target_ras, target_pos, ~] = ... - neuronav_convert_trigger_to_voxels(demo_sub_id, positions, outputStruct, parameters, pn); + neuronav_convert_trigger_to_voxels(demo_sub_id, positions, results, parameters, pn); + +clear results; % Conditional logic to convert native space coordinates to MNI space if mni_coords_requested == 1 @@ -95,9 +93,6 @@ targ_mni_ras(positions, 1:3) = NaN; end -% Set output folder for CSV as the localite data folder for the subject/session -csv_outfile = pn.data_postlocalite; - % Flag indicating if interpolation was used (0 = no) interpolated = 0; @@ -106,8 +101,7 @@ demo_sub_id, ... demo_session, ... demo_positions, ... - n_series, ... - csv_outfile, ... + pn.data_postlocalite, ... trans_pos, ... target_pos, ... trans_ras_seg, ... @@ -119,7 +113,7 @@ interpolated); % Inform user of processing completion and output location -fprintf('Demo processing completed. CSV saved to:\n%s\n', csv_outfile); +fprintf('Demo processing completed. CSV saved to:\n%s\n', pn.data_postlocalite); % If interpolation across a group of subjects would be desirable, the % following additional steps could be used: @@ -130,4 +124,4 @@ % neuronav_convert_MNI_to_native(sub_id, parameters, pn, trans_mni_ras, targ_mni_ras); % [INT3] Write output CSV % interpolated = 1; -% neuronav_export_session_csv(...) +% neuronav_export_session_csv(...) \ No newline at end of file diff --git a/examples/four_channel_example_49mm.csv b/examples/four_channel_example_49mm.csv deleted file mode 100755 index 799b04ed..00000000 --- a/examples/four_channel_example_49mm.csv +++ /dev/null @@ -1,61 +0,0 @@ -ο»Ώ4,1.50 -6,1.76 -8,2.29 -10,3.21 -12,4.09 -14,4.16 -16,3.18 -18,1.76 -20,0.82 -22,0.76 -24,1.78 -26,4.28 -28,8.17 -30,12.86 -32,17.40 -34,21.73 -36,25.74 -38,28.18 -40,29.53 -42,30.56 -44,30.31 -46,29.38 -48,28.74 -50,27.47 -52,25.98 -54,24.77 -56,23.16 -58,21.68 -60,20.57 -62,19.30 -64,18.09 -66,16.95 -68,15.77 -70,14.83 -72,14.02 -74,13.18 -76,12.42 -78,11.70 -80,11.04 -82,10.47 -84,9.92 -86,9.39 -88,8.89 -90,8.43 -92,8.00 -94,7.56 -96,7.14 -98,6.77 -100,6.43 -102,6.14 -104,5.87 -106,5.59 -108,5.31 -110,5.04 -112,4.79 -114,4.55 -116,4.33 -118,3.64 -120,1.90 -122,0.39 -124,0.02 diff --git a/examples/four_channel_example_60mm.csv b/examples/four_channel_example_60mm.csv deleted file mode 100755 index 1c8f9662..00000000 --- a/examples/four_channel_example_60mm.csv +++ /dev/null @@ -1,61 +0,0 @@ -ο»Ώ4,2.66 -6,2.38 -8,1.84 -10,1.35 -12,1.23 -14,1.48 -16,2.00 -18,2.54 -20,2.62 -22,2.38 -24,2.34 -26,2.77 -28,3.96 -30,6.05 -32,8.63 -34,11.83 -36,15.67 -38,19.00 -40,21.74 -42,24.49 -44,26.58 -46,28.10 -48,29.35 -50,29.66 -52,29.91 -54,30.39 -56,29.86 -58,29.03 -60,28.40 -62,27.30 -64,26.23 -66,25.34 -68,24.22 -70,23.17 -72,22.20 -74,21.18 -76,20.23 -78,19.35 -80,18.57 -82,17.84 -84,17.03 -86,16.16 -88,15.31 -90,14.50 -92,13.77 -94,13.10 -96,12.51 -98,11.97 -100,11.45 -102,11.01 -104,10.61 -106,10.19 -108,9.77 -110,9.34 -112,8.89 -114,8.50 -116,8.15 -118,6.92 -120,3.64 -122,0.75 -124,0.03 diff --git a/functions/acoustic/acoustic_analysis.m b/functions/acoustic/acoustic_analysis.m index 73e06745..c96a1ae1 100644 --- a/functions/acoustic/acoustic_analysis.m +++ b/functions/acoustic/acoustic_analysis.m @@ -1,4 +1,4 @@ -function [results, acoustic_isppa, acoustic_MI, acoustic_pressure, highlighted_pos] = ... +function [results, acoustic_Ipa, acoustic_MI, acoustic_pressure, highlighted_pos] = ... acoustic_analysis(parameters, kwave_medium, medium_masks, sensor_data, ... segmentation, source_labels) @@ -10,15 +10,15 @@ % intialize output structure results = struct(); - % What is the highest pressure level for every gridpoint + % Temporal peak pressure at every gridpoint (p_max_all = peak over last steady-state cycles) acoustic_pressure = gather(sensor_data.p_max_all); % gather is used since it could be a GPU array - results.max_pressure = max(acoustic_pressure(:)); + results.Psptp = max(acoustic_pressure(:)); % spatial peak temporal peak pressure % Calculates the Isppa for every gridpoint - acoustic_isppa = acoustic_pressure.^2./... + acoustic_Ipa = acoustic_pressure.^2./... (2*(kwave_medium.sound_speed.*kwave_medium.density)).*1e-4; - % Calculates the max Isppa - results.max_Isppa = max(acoustic_isppa(:)); + % Calculates the ISPPA (spatial peak pulse-average intensity) + results.Isppa = max(acoustic_Ipa(:)); % Calculates the Mechanical Index for every gridpoint acoustic_MI = (acoustic_pressure/10^6)/sqrt((parameters.transducer(1).source_freq_hz/10^6)); @@ -32,38 +32,39 @@ comp_grid_size = size(sensor_data.p_max_all); after_exit_plane_mask = ones(comp_grid_size); bowl_depth_grid = round((parameters.transducer(1).curv_radius_mm-... - parameters.transducer(1).dist_to_plane_mm)/parameters.grid_step_mm); + parameters.transducer(1).dist_to_plane_mm)/parameters.grid.resolution_mm); % Places the exit plane mask in the grid, adjusted to the amount of dimensions - if parameters.n_sim_dims == 3 + if numel(parameters.grid.dims) == 3 if trans_pos(3) > comp_grid_size(3)/2 - after_exit_plane_mask(:,:,(trans_pos(parameters.n_sim_dims)-... + after_exit_plane_mask(:,:,(trans_pos(numel(parameters.grid.dims))-... bowl_depth_grid):end) = 0; else - after_exit_plane_mask(:,:,1:(trans_pos(parameters.n_sim_dims)+... + after_exit_plane_mask(:,:,1:(trans_pos(numel(parameters.grid.dims))+... bowl_depth_grid)) = 0; end - elseif parameters.n_sim_dims == 2 + elseif numel(parameters.grid.dims) == 2 if trans_pos(2) > comp_grid_size(2)/2 - after_exit_plane_mask(:,(trans_pos(parameters.n_sim_dims)-... + after_exit_plane_mask(:,(trans_pos(numel(parameters.grid.dims))-... bowl_depth_grid):end) = 0; else - after_exit_plane_mask(:,1:(trans_pos(parameters.n_sim_dims)+... + after_exit_plane_mask(:,1:(trans_pos(numel(parameters.grid.dims))+... bowl_depth_grid)) = 0; end end % [1] average Isppa within a circle around the target % convert the radius from mm to voxels - avg_radius = round(parameters.focus_area_radius/parameters.grid_step_mm); % [voxel] + avg_radius = round(parameters.analysis.focus_area_radius/parameters.grid.resolution_mm); % [voxel] idx = arrayfun(@(d) max(1,focus_pos(d)-avg_radius):... - min(size(acoustic_isppa,d),focus_pos(d)+avg_radius), ... - 1:ndims(acoustic_isppa), 'UniformOutput', false); - results.avg_isppa_around_target = acoustic_isppa(idx{:}); - results.avg_isppa_around_target = mean(results.avg_isppa_around_target(:)); + min(size(acoustic_Ipa,d),focus_pos(d)+avg_radius), ... + 1:ndims(acoustic_Ipa), 'UniformOutput', false); + results.Ipa_target_radius = acoustic_Ipa(idx{:}); + results.Ipa_target_radius = mean(results.Ipa_target_radius(:)); - % [2] Isppa within the original stimulation target + % [2] IPA and temporal peak pressure at the target idx = num2cell(focus_pos); - results.isppa_at_target = acoustic_isppa(idx{:}); + results.Ipa_target = acoustic_Ipa(idx{:}); + results.Ptp_target = acoustic_pressure(idx{:}); % temporal peak pressure at target % Get tissue-specific masks mask = tissuemask_binary(parameters, medium_masks); @@ -72,79 +73,95 @@ % if medium includes brain, consider only intensities within the brain % otherwise: consider all media beyond the exit plane if ~isempty(mask.brain) - [results.max_Isppa_after_exit_plane, Ix_eplane, Iy_eplane,Iz_eplane] = ... - masked_max_3d(acoustic_isppa, mask.brain); + [results.Isppa_after_exit_plane, Ix_eplane, Iy_eplane,Iz_eplane] = ... + masked_max_3d(acoustic_Ipa, mask.brain); else - [results.max_Isppa_after_exit_plane, Ix_eplane, Iy_eplane, Iz_eplane] = ... - masked_max_3d(acoustic_isppa, after_exit_plane_mask); + [results.Isppa_after_exit_plane, Ix_eplane, Iy_eplane, Iz_eplane] = ... + masked_max_3d(acoustic_Ipa, after_exit_plane_mask); end % combine coordinates into one point of max. intensity in the grid - if parameters.n_sim_dims==3 + if numel(parameters.grid.dims)==3 results.max_isppa_eplane_pos = [Ix_eplane, Iy_eplane, Iz_eplane]; - elseif parameters.n_sim_dims==2 + elseif numel(parameters.grid.dims)==2 results.max_isppa_eplane_pos = [Ix_eplane, Iy_eplane]; end disp('Final transducer, expected focus, and max ISPPA positions') % [3] calculate the realized focal distance - real_focal_distance = norm(results.max_isppa_eplane_pos-trans_pos)*parameters.grid_step_mm; % [mm] + real_focal_distance = norm(results.max_isppa_eplane_pos-trans_pos)*parameters.grid.resolution_mm; % [mm] % Layer-specific outcomes (in case a layered simulation) - if contains(parameters.simulation_medium, {'layered'; 'phantom'}) + if contains(parameters.simulation.medium, {'layered'; 'phantom'}) % calculate max. isppa and location across full space - [~, Ix, Iy, Iz] = masked_max_3d(acoustic_isppa, ones(size(medium_masks))); + [~, Ix, Iy, Iz] = masked_max_3d(acoustic_Ipa, ones(size(medium_masks))); % highlight this position in the future highlighted_pos = [Ix, Iy, Iz]; highlighted_pos = highlighted_pos(1:numel(trans_pos)); % extract indices in brain medium - [results.max_Isppa_brain, Ix_brain, Iy_brain, Iz_brain] = ... - masked_max_3d(acoustic_isppa, mask.brain); - [results.min_Isppa_brain] = min(acoustic_isppa(mask.brain)); - half_max = acoustic_isppa >= results.max_Isppa_brain/2 & mask.brain; - half_max_ISPPA_volume_brain = sum(half_max(:))*(parameters.grid_step_mm^3); - [results.max_pressure_brain] = masked_max_3d(acoustic_pressure, mask.brain); - [results.max_MI_brain] = masked_max_3d(acoustic_MI, mask.brain); - + [results.Isppa_brain, Ix_brain, Iy_brain, Iz_brain] = ... + masked_max_3d(acoustic_Ipa, mask.brain); + [results.min_Isppa_brain] = min(acoustic_Ipa(mask.brain)); + half_max = acoustic_Ipa >= results.Isppa_brain/2 & mask.brain; + half_max_ISPPA_volume_brain = sum(half_max(:))*(parameters.grid.resolution_mm^3); + [results.Psptp_brain] = masked_max_3d(acoustic_pressure, mask.brain); + [results.MI_brain] = masked_max_3d(acoustic_MI, mask.brain); + % extract indices in skull medium - [results.max_Isppa_skull] = masked_max_3d(acoustic_isppa, mask.skull); - [results.max_pressure_skull] = masked_max_3d(acoustic_pressure, mask.skull); - [results.max_MI_skull] = masked_max_3d(acoustic_MI, mask.skull); - + [results.Isppa_skull] = masked_max_3d(acoustic_Ipa, mask.skull); + [results.Psptp_skull] = masked_max_3d(acoustic_pressure, mask.skull); + [results.MI_skull] = masked_max_3d(acoustic_MI, mask.skull); + % extract indices in skin medium - [results.max_Isppa_skin] = masked_max_3d(acoustic_isppa, mask.skin); - [results.max_pressure_skin] = masked_max_3d(acoustic_pressure, mask.skin); - [results.max_MI_skin] = masked_max_3d(acoustic_MI, mask.skin); - - writetable(table(parameters.subject_id, results.max_Isppa, results.max_Isppa_after_exit_plane, ... - real_focal_distance, results.max_Isppa_skin, results.max_Isppa_skull, ... - results.max_Isppa_brain, results.max_pressure_skin, results.max_pressure_skull, ... - results.max_pressure_brain, results.max_MI_skin, results.max_MI_skull, ... - results.max_MI_brain, Ix_brain, Iy_brain, Iz_brain, trans_pos, focus_pos, ... - results.isppa_at_target, results.avg_isppa_around_target, half_max_ISPPA_volume_brain, ... - 'VariableNames', { 'subject_id', 'max_Isppa', 'max_Isppa_after_exitplane', 'real_focal_distance_mm', ... - 'max_Isppa_skin', 'max_Isppa_skull', 'max_Isppa_brain', ... - 'max_pressure_skin_Pa', 'max_pressure_skull_Pa', 'max_pressure_brain_Pa', ... - 'max_MI_skin', 'max_MI_skull', 'max_MI_brain', ... + [results.Isppa_skin] = masked_max_3d(acoustic_Ipa, mask.skin); + [results.Psptp_skin] = masked_max_3d(acoustic_pressure, mask.skin); + [results.MI_skin] = masked_max_3d(acoustic_MI, mask.skin); + + % MItc: transcranial MI β€” max MI across intracranial voxels (WM, GM, CSF, blood) + intracranial_mask = ismember(segmentation, charm_seg_labels().intracranial); + if any(intracranial_mask(:)) + [results.MI_tc] = masked_max_3d(acoustic_MI, intracranial_mask); + else + results.MI_tc = NaN; % intracranial tissues not modelled + end + + freq_Hz = parameters.transducer(1).source_freq_hz; + writetable(table(parameters.subject_id, freq_Hz, ... + results.Isppa, results.Isppa_after_exit_plane, ... + real_focal_distance, results.Isppa_skin, results.Isppa_skull, ... + results.Isppa_brain, results.Psptp_skin, results.Psptp_skull, ... + results.Psptp_brain, results.Ptp_target, results.MI_skin, results.MI_skull, ... + results.MI_brain, results.MI_tc, Ix_brain, Iy_brain, Iz_brain, trans_pos, focus_pos, ... + results.Ipa_target, results.Ipa_target_radius, half_max_ISPPA_volume_brain, ... + 'VariableNames', { 'subject_id', 'freq_Hz', ... + 'Isppa', 'Isppa_after_exitplane', 'real_focal_distance_mm', ... + 'Isppa_skin', 'Isppa_skull', 'Isppa_brain', ... + 'Psptp_skin', 'Psptp_skull', 'Psptp_brain', 'Ptp_target', ... + 'MI_skin', 'MI_skull', 'MI_brain', 'MI_tc', ... 'Ix_brain_vox', 'Iy_brain_vox', 'Iz_brain_vox', ... 'trans_pos_vox', 'focus_pos_vox', ... - 'isppa_at_target', 'avg_isppa_around_target', ... + 'Ipa_target', 'Ipa_target_radius', ... 'halfmax_ISPPA_volume_brain_mm3'}), ... - parameters.filename_output_table); - else + parameters.io.filename_output_table); + else % If no layered tissue was selected, the max Isppa is highlighted on the plane and written in a table. highlighted_pos = results.max_isppa_eplane_pos; - writetable(table(parameters.subject_id, results.max_Isppa, results.max_Isppa_after_exit_plane, results.max_pressure, real_focal_distance, trans_pos, focus_pos, results.isppa_at_target, results.avg_isppa_around_target, ... - 'VariableNames', {'subject_id','max_Isppa', 'max_Isppa_after_exitplane', 'max_pressure_Pa', 'real_focal_distance_mm', 'trans_pos_vox', 'focus_pos_vox', 'isppa_at_target', 'avg_isppa_around_target'}), ... - parameters.filename_output_table); + freq_Hz = parameters.transducer(1).source_freq_hz; + writetable(table(parameters.subject_id, freq_Hz, results.Isppa, results.Isppa_after_exit_plane, ... + results.Psptp, results.Ptp_target, real_focal_distance, trans_pos, focus_pos, ... + results.Ipa_target, results.Ipa_target_radius, ... + 'VariableNames', {'subject_id', 'freq_Hz', 'Isppa', 'Isppa_after_exitplane', ... + 'Psptp', 'Ptp_target', 'real_focal_distance_mm', 'trans_pos_vox', 'focus_pos_vox', ... + 'Ipa_target', 'Ipa_target_radius'}), ... + parameters.io.filename_output_table); end % Plot intensity on the segmented image (up to 2 transducers) n_plots = min(2, numel(parameters.transducer)); if numel(parameters.transducer) > n_plots - warning('More than two transducers: ISPPA plots on segmentation will be created only for the first 2 transducers'); + warning('More than two transducers: intensity plots on segmentation will be created only for the first 2 transducers'); end for ti = 1:n_plots if ti == 1 @@ -155,9 +172,9 @@ fpos_sim = parameters.transducer(ti).focus_pos; end - if parameters.n_sim_dims==3 + if numel(parameters.grid.dims)==3 [~,~,~,~,~,~,~,h]=plot_overlay(... - acoustic_isppa, ... + acoustic_Ipa, ... segmentation, ... source_labels, ... parameters, ... @@ -167,7 +184,7 @@ highlighted_pos); else h = plot_overlay_2d(... - acoustic_isppa, ... + acoustic_Ipa, ... segmentation, ... source_labels, ... after_exit_plane_mask, ... @@ -176,9 +193,11 @@ highlighted_pos); end - output_plot = fullfile(parameters.output_dir, ... - sprintf('sub-%03d_%s_isppa_T%02d%s.png', ... - parameters.subject_id, parameters.simulation_medium, ti, parameters.results_filename_affix)); + trans_suffix = ''; + if n_plots > 1; trans_suffix = sprintf('_T%02d', ti); end + output_plot = fullfile(parameters.io.output_dir, ... + sprintf('sub-%03d_%s_intensity%s%s.png', ... + parameters.subject_id, parameters.simulation.medium, trans_suffix, parameters.io.output_affix)); set(h, 'InvertHardcopy', 'off'); % keep original colours saveas(h, output_plot, 'png') close(h); diff --git a/functions/acoustic/acoustic_convert_axisymmetry.m b/functions/acoustic/acoustic_convert_axisymmetry.m index 13002888..dd1ddc27 100644 --- a/functions/acoustic/acoustic_convert_axisymmetry.m +++ b/functions/acoustic/acoustic_convert_axisymmetry.m @@ -3,7 +3,7 @@ parameters, sensor_data, segmentation, medium_masks, kwave_medium, source, source_labels) % if using axisymmetric settings and requesting heating simulations, reshape output to 3D - if isfield(parameters, 'run_heating_sims') && parameters.run_heating_sims==1 + if isfield(parameters.modules, 'run_heating_sims') && parameters.modules.run_heating_sims==1 % when follow-up thermal simulation is requested, expand axisymmetric to 3D [sensor_data, parameters, segmentation, medium_masks, kwave_medium, kgrid, source, source_labels] = ... convert_axisymmetric_to_3d(... diff --git a/functions/acoustic/acoustic_simulation.m b/functions/acoustic/acoustic_simulation.m index b0a085f0..6401b224 100644 --- a/functions/acoustic/acoustic_simulation.m +++ b/functions/acoustic/acoustic_simulation.m @@ -20,7 +20,7 @@ % 4: C++ GPU code 'cpp_gpu' % Only actively plot each timepoint of the simulations if it is interactive -if ~parameters.interactive +if ~parameters.simulation.interactive input_args.PlotSim = false; end @@ -33,26 +33,26 @@ input_args.PlotScale = [-1, 1] * parameters.transducer(1).source_amp(1); % Select submission based on code type -switch parameters.code_type +switch parameters.simulation.code_type case 'cpp_cpu' % Force precision for C++ HDF5 compatibility - medium = cast_struct(medium, parameters.precision); - source = cast_struct(source, parameters.precision); - sensor = cast_struct(sensor, parameters.precision); - + medium = cast_struct(medium, parameters.simulation.precision); + source = cast_struct(source, parameters.simulation.precision); + sensor = cast_struct(sensor, parameters.simulation.precision); + % Pathname for the input and output files (used only for non-interactive computations) - input_args.SaveToDisk = fullfile(parameters.output_dir, ... + input_args.SaveToDisk = char(fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_%s_input%s.h5', parameters.subject_id, ... - parameters.simulation_medium, parameters.results_filename_affix)); - - input_args.DataName = sprintf('kwave_sub-%03d%s', parameters.subject_id, parameters.results_filename_affix); - input_args.DataPath = parameters.output_dir; + parameters.simulation.medium, parameters.io.output_affix))); + + input_args.DataName = sprintf('kwave_sub-%03d%s', parameters.subject_id, parameters.io.output_affix); + input_args.DataPath = char(parameters.io.output_dir); input_args.DeleteData = true; - if parameters.n_sim_dims == 3 + if numel(parameters.grid.dims) == 3 input_args.FunctionName = 'kspaceFirstOrder3D'; - elseif parameters.n_sim_dims == 2 && isfield(parameters, 'axisymmetric') && parameters.axisymmetric == 1 + elseif numel(parameters.grid.dims) == 2 && isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 input_args.FunctionName = 'kspaceFirstOrderAS'; else input_args.FunctionName = 'kspaceFirstOrder2D'; @@ -64,22 +64,22 @@ case 'cpp_gpu' % Force precision for C++ HDF5 compatibility - medium = cast_struct(medium, parameters.precision); - source = cast_struct(source, parameters.precision); - sensor = cast_struct(sensor, parameters.precision); + medium = cast_struct(medium, parameters.simulation.precision); + source = cast_struct(source, parameters.simulation.precision); + sensor = cast_struct(sensor, parameters.simulation.precision); % Pathname for the input and output files (used only for non-interactive computations) - input_args.SaveToDisk = fullfile(parameters.output_dir, ... + input_args.SaveToDisk = char(fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_%s_input%s.h5', parameters.subject_id, ... - parameters.simulation_medium, parameters.results_filename_affix)); + parameters.simulation.medium, parameters.io.output_affix))); - input_args.DataName = sprintf('kwave_sub-%03d%s', parameters.subject_id, parameters.results_filename_affix); - input_args.DataPath = parameters.output_dir; + input_args.DataName = sprintf('kwave_sub-%03d%s', parameters.subject_id, parameters.io.output_affix); + input_args.DataPath = char(parameters.io.output_dir); input_args.DeleteData = true; - if parameters.n_sim_dims == 3 + if numel(parameters.grid.dims) == 3 input_args.FunctionName = 'kspaceFirstOrder3D'; - elseif parameters.n_sim_dims == 2 && isfield(parameters, 'axisymmetric') && parameters.axisymmetric == 1 + elseif numel(parameters.grid.dims) == 2 && isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 input_args.FunctionName = 'kspaceFirstOrderAS'; else input_args.FunctionName = 'kspaceFirstOrder2D'; @@ -94,12 +94,12 @@ case 'matlab_gpu' - input_args.DataCast = ['gpuArray-', char(parameters.precision)]; + input_args.DataCast = ['gpuArray-', char(parameters.simulation.precision)]; input_args_cell = zip_fields(input_args); - if parameters.n_sim_dims == 3 + if numel(parameters.grid.dims) == 3 sensor_data = kspaceFirstOrder3D(kgrid, medium, source, sensor, input_args_cell{:}); - elseif parameters.n_sim_dims == 2 && isfield(parameters, 'axisymmetric') && parameters.axisymmetric == 1 + elseif numel(parameters.grid.dims) == 2 && isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 sensor_data = kspaceFirstOrderAS(kgrid, medium, source, sensor, input_args_cell{:}, 'RadialSymmetry', 'WSWA-FFT'); else % 2D simulation sensor_data = kspaceFirstOrder2D(kgrid, medium, source, sensor, input_args_cell{:}); @@ -107,12 +107,12 @@ case 'matlab_cpu' - input_args.DataCast = char(parameters.precision); + input_args.DataCast = char(parameters.simulation.precision); input_args_cell = zip_fields(input_args); - if parameters.n_sim_dims == 3 + if numel(parameters.grid.dims) == 3 sensor_data = kspaceFirstOrder3D(kgrid, medium, source, sensor, input_args_cell{:}); - elseif parameters.n_sim_dims == 2 && isfield(parameters, 'axisymmetric') && parameters.axisymmetric == 1 + elseif numel(parameters.grid.dims) == 2 && isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 sensor_data = kspaceFirstOrderAS(kgrid, medium, source, sensor, input_args_cell{:}, 'RadialSymmetry', 'WSWA-FFT'); else % 2D simulation sensor_data = kspaceFirstOrder2D(kgrid, medium, source, sensor, input_args_cell{:}); @@ -120,7 +120,7 @@ otherwise - error('Unsupported code_type: %s. Supported options: matlab_cpu, matlab_gpu, cpp_cpu, cpp_gpu', parameters.code_type); + error('Unsupported code_type: %s. Supported options: matlab_cpu, matlab_gpu, cpp_cpu, cpp_gpu', parameters.simulation.code_type); end diff --git a/functions/acoustic/acoustic_wrapper.m b/functions/acoustic/acoustic_wrapper.m index e5fec203..ecbac7e5 100644 --- a/functions/acoustic/acoustic_wrapper.m +++ b/functions/acoustic/acoustic_wrapper.m @@ -16,10 +16,10 @@ plminside = true; kwave_input_args = struct('PMLInside', plminside, ... - 'PMLSize', parameters.pml_size, ... + 'PMLSize', parameters.grid.pml_size, ... 'PlotPML', true); - if contains(parameters.simulation_medium, {'layered'}) && ... + if contains(parameters.simulation.medium, {'layered'}) && ... any(ismember(fieldnames(parameters.layers), {'skull'})) % Extract the skull edge ... mask = tissuemask_binary(parameters, medium_masks); @@ -28,7 +28,7 @@ kwave_input_args.DisplayMask = skull_edge; end - if parameters.run_source_setup==0 + if parameters.modules.run_source_setup==0 error('Source setup not requested. Not able to proceed with acoustic simulation.') end @@ -36,9 +36,9 @@ sensor_data = acoustic_simulation(kgrid, kwave_medium, source, sensor, kwave_input_args, parameters); % convert media and results to 2D/3D (if axisymmetry was used) - if parameters.n_sim_dims == 2 && isfield(parameters, 'axisymmetric') && parameters.axisymmetric == 1 + if numel(parameters.grid.dims) == 2 && isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 % if using axisymmetric settings and requesting heating simulations, reshape output to 3D - if isfield(parameters, 'run_heating_sims') && parameters.run_heating_sims==1 + if isfield(parameters.modules, 'run_heating_sims') && parameters.modules.run_heating_sims==1 % when follow-up thermal simulation is requested, expand axisymmetric to 3D [sensor_data, parameters, segmentation, medium_masks, kwave_medium, kgrid, source, source_labels] = ... convert_axisymmetric_to_3d(sensor_data, parameters, segmentation, medium_masks, kwave_medium, source, source_labels); @@ -54,7 +54,7 @@ acoustic_info.parameters = parameters; acoustic_info.kwave_input_args = kwave_input_args; - if isfield(parameters, 'savemat') && parameters.savemat==0 + if isfield(parameters.io, 'save_matrices') && parameters.io.save_matrices==0 disp("Not saving acoustic output matrices ...") else % keep 'parameters' as a copy so not to confuse future runs diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index ad339151..38245c53 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -2,6 +2,7 @@ profile_empirical,... equipment_name, ... desired_intensity, ... + desired_focal_distance_ep, ... parameters, ... sim_id) @@ -9,14 +10,14 @@ % % Inputs: % profile_empirical -% profile_focus - Measured or theoretical intensity profile along beam axis -% dist_from_tran - Distance from transducer reference point (mm) -% focus_wrt_exit_plane - Desired focal distance from transducer exit plane (mm) -% equipment_name - Name/identifier for the transducer or equipment -% desired_intensity - Target focal intensity (W/cm^2) -% parameters - Structure with simulation parameters and paths -% parameters.calibration - Structure with calibration settings -% sim_id - Numeric ID of the subject (or simulation) +% axial_intensity - Measured or theoretical intensity profile along beam axis +% axial_distance_bowl - Distance from transducer reference point (mm) +% equipment_name - Name/identifier for the transducer or equipment +% desired_intensity - Target focal intensity (W/cm^2) +% desired_focal_distance_ep - Desired focal distance from transducer exit plane (mm) +% parameters - Structure with simulation parameters and paths +% parameters.calibration - Structure with calibration settings +% sim_id - Numeric ID of the subject (or simulation) % % Outputs: % opt_source_amp | Optimized amplitude @@ -26,12 +27,27 @@ % Description: % This function scales the input intensity profile to the desired value, % runs an initial simulation using the specified submission method, -% computes analytical and simulated pressure profiles, +% computes analytical and simulated intensity profiles, % performs optimization of transducer element phases and amplitudes % to match the desired acoustic profile, % reruns the simulation with optimized parameters, % visualizes results, and saves optimized values. + %% Attach more information to parameters + + parameters.calibration.equipment_name = equipment_name; + parameters.calibration.desired_intensity = desired_intensity; + parameters.calibration.desired_focal_distance_ep = desired_focal_distance_ep; + + % Scale the requested profile to the desired intensity + [profile_target, ~] = scale_real_intensity_profile(... + parameters, ... + profile_empirical, ... + desired_intensity); + + % We continue with the scaled empirical profile + clear profile_empirical; + %% Initial water simulation disp('Run initial simulation...') @@ -40,140 +56,95 @@ % Run all simulations in calibration folder (default) if parameters.calibration.save_in_calibration_folder - parameters.data_path = parameters.calibration.path_output; - parameters.seg_path = parameters.calibration.path_output; - parameters.sim_path = parameters.calibration.path_output; + parameters.path.anat = parameters.calibration.path_output; + parameters.path.seg = parameters.calibration.path_output; + parameters.path.sim = parameters.calibration.path_output; end - disp(['Saving free-water calibration in ', parameters.sim_path]); + disp(['Saving free-water calibration in ', parameters.path.sim]); % if a subfolder is requested, move outputs to subfolders - if parameters.subject_subfolder - parameters.outputs_folder = sprintf('%s/sub-%03d', parameters.sim_path, sim_id); + if parameters.path.subject_subfolder + parameters.outputs_folder = sprintf('%s/sub-%03d', parameters.path.sim, sim_id); else - parameters.outputs_folder = sprintf('%s', parameters.sim_path); + parameters.outputs_folder = sprintf('%s', parameters.path.sim); end % Copy calibration settings to relevant entries in simulation config sim_param = parameters; - sim_param.submit_medium = parameters.calibration.submit_medium; - - % Manage the submission setup - sim_param.simulation_medium = 'water'; - sim_param.savemat = 1; % always save water results + % Force water medium + sim_param.simulation.medium = 'water'; + % Force save result matrices + sim_param.io.save_matrices = 1; + % Overwrite transducer kwavearray modeling (if specified) if isfield(parameters.calibration, 'force_kwavearray') && ... parameters.calibration.force_kwavearray == 1 - sim_param.use_kwavearray = 1; % force to run with kwavearray setup + sim_param.grid.use_kWaveArray = 1; % force to run with kwavearray setup + end + % Convert from default 3D to 2D axisymmetric simulation (if requested) + if isfield(parameters.calibration, 'axisymmetric2D') && ... + parameters.calibration.axisymmetric2D == 1 + parameters.grid.axisymmetric = 1; + if numel(parameters.grid.default_dims)==3 + parameters.grid.default_dims(2) = []; + end end - sim_param.overwrite_files = 'always'; - sim_param.interactive = 0; + % Force deactivate interactive mode + sim_param.simulation.interactive = 0; % Run the simulation based on the submission method - switch sim_param.submit_medium - case 'qsub' - single_subject_pipeline_with_qsub(sim_id, sim_param, true); - case 'slurm' - single_subject_pipeline_with_slurm(sim_id, sim_param, true); - case 'matlab' - single_subject_pipeline(sim_id, sim_param); - otherwise - error('Submit medium does not correspond to available options.'); - end + sim_param.subject_id = sim_id; + sim_param.hpc.wait_for_job = true; + prestus_pipeline_start(sim_param); %% Load initial results initial_res = load(sprintf('%s/sub-%03d_water_results%s.mat', ... - sim_param.outputs_folder, sim_id, sim_param.results_filename_affix)); + sim_param.outputs_folder, sim_id, sim_param.io.output_affix)); initial_params = initial_res.acoustic_info.parameters; + initial_params.calibration.prefix = 'Initial_'; - %% Get maximum pressure + %% Extract simulated intensity along the focal axis - p_max = gather(initial_res.sensor_data.p_max_all); % transform from GPU array to normal array - - %% Plot 2D intensity map + [profile_sim] = extract_simulated_profile(initial_res, initial_params); - figure; - p_distance = (1:size(p_max, 1)) * initial_params.grid_step_mm; - p_width = (1:size(p_max, sim_param.n_sim_dims)) * initial_params.grid_step_mm; - if sim_param.n_sim_dims == 2 - p_axialprofile = squeeze(p_max(:, :))'; - elseif sim_param.n_sim_dims == 3 - p_axialprofile = squeeze(p_max(:, initial_params.transducer.trans_pos(2), :))'; - end - imagesc(p_distance, p_width, p_axialprofile); - clear p_distance p_width p_axialprofile; - axis image; - colormap(getColorMap); - xlabel('Lateral Position [mm]'); - ylabel('Axial Position [mm]'); - axis image; - cb = colorbar; - title('Pressure for the focal plane') - - % Save the intensity map - fig_path = fullfile(sim_param.outputs_folder, ... - strcat('Initial_Intensity_map_2D_at_F_', num2str(profile_empirical.focus_wrt_exit_plane), ... - '_at_I_', num2str(desired_intensity), '_', equipment_name, '.png')); - saveas(gcf, fig_path); - close(gcf); - %% Optimization - - % Extract simulated pressure along the focal axis - if sim_param.n_sim_dims == 2 - i_x = initial_params.transducer.trans_pos(1); - pred_axial_pressure = squeeze(p_max(i_x,:)); - clear i_x; - elseif sim_param.n_sim_dims == 3 - i_x = initial_params.transducer.trans_pos(1); - i_y = initial_params.transducer.trans_pos(2); - pred_axial_pressure = squeeze(p_max(i_x, i_y,:)); - clear i_x i_y; - end - - % Scale the profile to the desired intensity - profile_opt.adjusted_profile_focus = scale_real_intensity_profile(... - initial_params, ... - desired_intensity, ... - profile_empirical.profile_focus); - % Compute O'Neil solution and related parameters - [p_axial_oneil, simulated_grid_adj_factor, velocity, axial_position] = ... + % Compute analytical O'Neil solution and scaling factor to simulated intensity + [profile_oneil, simulated_analytical_scaling] = ... compute_oneil_solution(... initial_params, ... - pred_axial_pressure, ... - profile_empirical.dist_from_tran, ... - profile_opt.adjusted_profile_focus, ... - profile_empirical.focus_wrt_exit_plane, ... - desired_intensity, ... - equipment_name); + profile_sim, ... + profile_target); % Optimize phases [rad] and source amplitude to match real profile [opt_phases, opt_velocity, min_err] = ... perform_global_search(... initial_params, ... - profile_empirical.dist_from_tran, ... - profile_opt.adjusted_profile_focus', ... - velocity); + profile_target, ... + profile_sim.velocity); + + % Fit velocity to match desired peak intensity exactly + if ~isfield(parameters.calibration, 'fit_velocity_to_intensity') || ... + parameters.calibration.fit_velocity_to_intensity + [opt_velocity, ~, ~] = fit_velocity_to_intensity(... + initial_params, profile_oneil, opt_phases, opt_velocity, ... + parameters.calibration.desired_intensity, simulated_analytical_scaling); + end % Recalculate analytical solution with optimized phases and velocity - p_axial_oneil_opt = ... - recalculate_analytical_sol(... + profile_oneil_opt = ... + recompute_oneil_solution(... initial_params, ... - p_axial_oneil, ... + profile_oneil, ... + profile_target, ... opt_phases, ... - opt_velocity, ... - profile_empirical.dist_from_tran, ... - profile_opt.adjusted_profile_focus, ... - axial_position, ... - profile_empirical.focus_wrt_exit_plane, ... - desired_intensity, ... - equipment_name); + opt_velocity); % Calculate optimized source amplitude - opt_source_amp = round(opt_velocity / velocity * ... + opt_source_amp = round(opt_velocity / profile_sim.velocity * ... initial_params.transducer.source_amp / ... - simulated_grid_adj_factor); + simulated_analytical_scaling); % Collect phases opt_source_phase_rad = opt_phases; @@ -189,36 +160,32 @@ opt_param.transducer.source_phase_deg = opt_source_phase_deg; opt_param.results_filename_affix = '_optimized'; - switch sim_param.submit_medium - case 'qsub' - single_subject_pipeline_with_qsub(sim_id, opt_param, true); - case 'slurm' - single_subject_pipeline_with_slurm(sim_id, opt_param, true); - case 'matlab' - single_subject_pipeline(sim_id, opt_param); - otherwise - error('Submit medium does not correspond to available options.'); - end + opt_param.subject_id = sim_id; + opt_param.hpc.wait_for_job = true; + prestus_pipeline_start(opt_param); + + %% Load optimized simulation results + opt_res = load(sprintf('%s/sub-%03d_water_results%s.mat', ... + opt_param.outputs_folder, sim_id, opt_param.results_filename_affix)); + + opt_params = opt_res.acoustic_info.parameters; + opt_params.calibration.prefix = 'Opt_'; + + %% Extract simulated intensity along the focal axis + [profile_sim_opt] = extract_simulated_profile(opt_res, opt_params); % Plot optimized simulation results plot_opt_sim_results(... opt_param, ... - sim_id, ... - axial_position, ... - profile_empirical.dist_from_tran, ... - profile_opt.adjusted_profile_focus, ... - p_axial_oneil_opt, ... - p_axial_oneil, ... - profile_empirical.focus_wrt_exit_plane, ... - desired_intensity, ... - equipment_name, ... + profile_target, ... + profile_oneil, ... + profile_oneil_opt, ... + profile_sim, ... + profile_sim_opt, ... min_err) % Save optimized values save_optimized_values(... - opt_param, ... - profile_empirical.focus_wrt_exit_plane, ... - desired_intensity, ... - equipment_name); + opt_param); end diff --git a/functions/calibration/compute_oneil_solution.m b/functions/calibration/compute_oneil_solution.m index 0be97070..a78e5cd2 100644 --- a/functions/calibration/compute_oneil_solution.m +++ b/functions/calibration/compute_oneil_solution.m @@ -1,80 +1,85 @@ -function [p_axial_oneil, simulated_grid_adj_factor, velocity, axial_position] = ... - compute_oneil_solution(parameters, pred_axial_pressure, dist_exit_plane, ... - adjusted_profile_focus, focus_wrt_exit_plane, desired_intensity, equipment_name) +function [profile_oneil, simulated_oneil_scaling] = compute_oneil_solution(parameters, profile_sim, profile_target) + % Compute O'Neil solution and plot it along with comparisons % % Arguments: % - parameters: Structure containing simulation and transducer parameters. - % parameters.calibration.path_output: Directory for saving results and figures. - % - pred_axial_pressure: Predicted pressure along the beam axis [Pa]. - % - dist_exit_plane: Axial distance from the transducer exit plane [mm]. - % - adjusted_profile_focus: Adjusted intensity profile for the focus. - % - focus_wrt_exit_plane: Focal distance relative to the exit plane [mm]. - % - desired_intensity: Desired intensity at the focal point [W/cm^2]. - % - equipment_name: Name of the equipment for labeling results. + % .calibration.path_output: Directory for saving results and figures. + % .calibration.desired_focal_distance_ep: Focal distance relative to the exit plane [mm]. Will be used only for labelling. + % .calibration.desired_intensity: Desired intensity at the focal point [W/cm^2]. + % .calibration.equipment_name: Name of the equipment for labeling results. + % - profile_sim.axial_intensity: Simulated axial intensity [W/cm2]. + % - profile_sim.axial_distance_bowl: Axial distance of simulated pressure [mm from bowl] + % - profile_sim.velocity: Particle profile_sim.velocity [m/s]. + % - profile_target.axial_intensity: Adjusted intensity profile for the focus. % % Returns: - % - p_axial_oneil: Computed O'Neil solution for pressure along the beam axis [Pa]. - % - simulated_grid_adj_factor: Adjustment factor to align simulated pressure with analytical solution. - % - velocity: Particle velocity [m/s]. - % - axial_position: Axial position vector [mm]. - - % Compute particle velocity [m/s] - velocity = parameters.transducer.source_amp(1) / ... - (parameters.medium.water.density * parameters.medium.water.sound_speed); + % - profile_oneil.axial_intensity: Computed O'Neil solution for pressure along the beam axis [Pa]. + % - profile_oneil.axial_distance_bowl: Axial position vector [mm from bowl]. + % - simulated_oneil_scaling: Adjustment factor to align simulated intensity with analytical solution. % Define the axial position vector [mm] - axial_position = (1:parameters.default_grid_dims(end)) * parameters.grid_step_mm; + axial_position = profile_sim.axial_distance_bowl; + + % Flip the desired profile + profile_target.axial_intensity = profile_target.axial_intensity'; % Compute O'Neil analytical solution for pressure along the beam axis [Pa] p_axial_oneil = focusedAnnulusONeil(... parameters.transducer.curv_radius_mm / 1e3, ... % Convert radius to meters [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm] / 1e3, ... % Element dimensions in meters - repmat(velocity, 1, parameters.transducer.n_elements), ... % Velocity array + repmat(profile_sim.velocity, 1, parameters.transducer.n_elements), ... % Velocity array parameters.transducer.source_phase_rad, ... % Source phases [radians] parameters.transducer.source_freq_hz, ... % Source frequency [Hz] - parameters.medium.water.sound_speed, ... % Sound speed in water [m/s] - parameters.medium.water.density, ... % Water density [kg/m^3] + parameters.medium_properties.water.sound_speed, ... % Sound speed in water [m/s] + parameters.medium_properties.water.density, ... % Water density [kg/m^3] (axial_position - 0.5) * 1e-3); % Axial positions (adjusted, in meters) - % Convert pressures to intensities [W/cm^2] - i_axial_oneil = p_axial_oneil .^ 2 / (2 * parameters.medium.water.sound_speed * parameters.medium.water.density) * 1e-4; - pred_axial_intensity = pred_axial_pressure .^ 2 / (2 * parameters.medium.water.sound_speed * parameters.medium.water.density) * 1e-4; + % Convert pressure to intensities [W/cm^2] + i_axial_oneil = p_axial_oneil .^ 2 / (2 * parameters.medium_properties.water.sound_speed * parameters.medium_properties.water.density) * 1e-4; % Plot intensity along the beam axis figure('Position', [10, 10, 900, 500]); plot(axial_position, i_axial_oneil, ... 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'O''Neil Analytical Solution'); hold on; - plot(axial_position - (parameters.transducer.trans_pos(end) - 1) * parameters.grid_step_mm, pred_axial_intensity, ... + plot(axial_position, profile_sim.axial_intensity, ... '--', 'LineWidth', 1.5, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Inital Simulated Intensity'); - plot(dist_exit_plane, adjusted_profile_focus, ... + plot(profile_target.axial_distance_bowl, profile_target.axial_intensity, ... 'LineWidth', 2, 'Color', [1 0 0], 'DisplayName', 'Desired Profile'); - if isfield(parameters, 'expected_focal_distance_EP_mm') - xline(parameters.expected_focal_distance_EP_mm, '--', ... - 'LineWidth', 1.2, 'DisplayName', 'Expected Focal Distance (mm from EP)'); + if isfield(parameters, 'expected_focal_distance_bowl') + xline(parameters.expected_focal_distance_bowl, '--', ... + 'LineWidth', 1.2, 'DisplayName', 'Expected Focal Distance (mm from bowl)', 'Color', [1 0 0]); + end + if isfield(parameters.transducer, 'focal_distance_offset') + xline(parameters.transducer.focal_distance_offset, '--', ... + 'LineWidth', 1.2, 'DisplayName', 'Exit Plane'); end hold off; - xlabel('Distance w.r.t. Exit Plane [mm]'); + xlabel('Distance w.r.t. Transducer Bowl [mm]'); ylabel('Intensity [W/cm^2]'); legend('show'); title('Intensity Along the Beam Axis'); grid on; ylim([0 inf]); - xlim([-5 inf]); + xlim([0 inf]); % Save the figure fig_path = fullfile(parameters.outputs_folder, sprintf('Initial_Simulation_F_%.2f_I_%.2f_%s.png', ... - focus_wrt_exit_plane, desired_intensity, equipment_name)); + parameters.calibration.desired_focal_distance_ep, parameters.calibration.desired_intensity, parameters.calibration.equipment_name)); saveas(gcf, fig_path); close(gcf); - % Report the estimated distance to the point of maximum pressure - [~, max_idx] = max(p_axial_oneil); - fprintf('Estimated distance to maximum pressure: %.2f mm\n', axial_position(max_idx)); + % Report the estimated distance to the point of maximum intensity + [~, max_idx] = max(i_axial_oneil); + fprintf('Estimated distance to maximum intensity: %.2f mm\n', axial_position(max_idx)); + + % Compute adjustment factor to align simulated and analytical intensities + simulated_oneil_scaling = max(profile_sim.axial_intensity(:)) / max(i_axial_oneil(:)); - % Compute adjustment factor to align simulated and analytical pressures - simulated_grid_adj_factor = max(pred_axial_pressure(:)) / max(p_axial_oneil(:)); + %% Collect outputs + profile_oneil.axial_intensity = i_axial_oneil; + profile_oneil.axial_distance_bowl = axial_position; end \ No newline at end of file diff --git a/functions/calibration/compute_phases.m b/functions/calibration/compute_phases.m index 4fe4ef87..7a1745f0 100644 --- a/functions/calibration/compute_phases.m +++ b/functions/calibration/compute_phases.m @@ -1,10 +1,10 @@ -function phases = compute_phases(SOUND_SPEED_WATER, tran, focus_wrt_exit_plane, tran_ini_data) +function phases = compute_phases(SOUND_SPEED_WATER, tran, desired_focal_distance_ep, tran_ini_data) % Computes the phases necessary to aim at the specified focal point. % % Arguments: % - SOUND_SPEED_WATER: Speed of sound in water [m/s]. % - tran: Structure containing transducer parameters and element data. - % - focus_wrt_exit_plane: Focal depth relative to the transducer exit plane [mm]. + % - desired_focal_distance_ep: Focal depth relative to the transducer exit plane [mm]. % - tran_ini_data: Ini data structure containing transducer element positions. % % Returns: @@ -18,7 +18,7 @@ % Convert focal distance relative to the exit plane to relative to the mid-bowl dist_to_mid_bowl = tran.prestus.transducer.curv_radius_mm - tran.prestus.transducer.dist_to_plane_mm; - focus_wrt_mid_bowl = focus_wrt_exit_plane + dist_to_mid_bowl; + focus_wrt_mid_bowl = desired_focal_distance_ep + dist_to_mid_bowl; % Compute the target point in relation to the natural focus [mm] aim_wrt_natural_focus = tran.prestus.transducer.curv_radius_mm - focus_wrt_mid_bowl; diff --git a/functions/calibration/extract_real_intensity_profile.m b/functions/calibration/extract_real_intensity_profile.m index 4d159aa0..ffa7de35 100644 --- a/functions/calibration/extract_real_intensity_profile.m +++ b/functions/calibration/extract_real_intensity_profile.m @@ -1,10 +1,10 @@ function [norm_profile_focus, max_intens] = extract_real_intensity_profile(... parameters,... available_foci_wrt_exit_plane, ... - focus_wrt_exit_plane, ... + desired_focal_distance_ep, ... intens_data, ... equipment_name, ... - dist_from_tran) + dist_from_exit_plane) % Extracts or interpolates the intensity profile at a specific focal depth. % @@ -13,25 +13,25 @@ % parameters.calibration.skip_front_peak_mm: Distance to skip near-field peaks when finding the maximum intensity [mm]. % parameters.calibration.path_output_profiles: Directory path for saving results. % - available_foci_wrt_exit_plane: Array of available focal depths relative to the exit plane [mm]. - % - focus_wrt_exit_plane: Desired focal depth relative to the exit plane [mm]. + % - desired_focal_distance_ep: Desired focal depth relative to the exit plane [mm]. % - intens_data: Matrix containing intensity profiles for different focal depths. % - equipment_name: Name of the equipment for labeling plots. - % - dist_from_tran: Distance vector from the transducer [mm]. + % - dist_from_exit_plane: Distance vector from the transducer [mm]. % % Returns: % - profile_focus: Extracted or interpolated intensity profile at the desired focal depth. % - max_intens: Maximum intensity in the profile beyond the specified skip distance. % Check if the exact focal depth is available - col_index = find(available_foci_wrt_exit_plane == focus_wrt_exit_plane); + col_index = find(available_foci_wrt_exit_plane == desired_focal_distance_ep); if isempty(col_index) % Perform linear interpolation if the exact focus is not available - [~, closestIndex] = min(abs(available_foci_wrt_exit_plane - focus_wrt_exit_plane)); + [~, closestIndex] = min(abs(available_foci_wrt_exit_plane - desired_focal_distance_ep)); closest_foci_wrt_exit_plane = available_foci_wrt_exit_plane(closestIndex); % Determine neighboring focal depths for interpolation - if closest_foci_wrt_exit_plane > focus_wrt_exit_plane + if closest_foci_wrt_exit_plane > desired_focal_distance_ep closestIndex2 = closestIndex; % Higher focus closestIndex1 = closestIndex2 - 1; % Lower focus else @@ -54,44 +54,44 @@ [~, idx1] = max(profile_1); [~, idx2] = max(profile_2); - x1_norm = dist_from_tran - dist_from_tran(idx1); % Align peak of profile 1 to 0 - x2_norm = dist_from_tran - dist_from_tran(idx2); % Align peak of profile 2 to 0 + x1_norm = dist_from_exit_plane - dist_from_exit_plane(idx1); % Align peak of profile 1 to 0 + x2_norm = dist_from_exit_plane - dist_from_exit_plane(idx2); % Align peak of profile 2 to 0 % Define normalized common x-array x_common_norm = linspace(min(min(x1_norm), min(x2_norm)), ... - max(max(x1_norm), max(x2_norm)), length(dist_from_tran)); + max(max(x1_norm), max(x2_norm)), length(dist_from_exit_plane)); % Interpolate profiles in normalized space y1_interp_norm = interp1(x1_norm, profile_1, x_common_norm, 'spline', 'extrap'); y2_interp_norm = interp1(x2_norm, profile_2, x_common_norm, 'spline', 'extrap'); % Calculate weight (alpha) for interpolation based on the relative focal depths - alpha = (focus_wrt_exit_plane - focus_wrt_exit_plane_1) / ... + alpha = (desired_focal_distance_ep - focus_wrt_exit_plane_1) / ... (focus_wrt_exit_plane_2 - focus_wrt_exit_plane_1); % Interpolate the profiles with the weight alpha norm_profile_focus = (1-alpha) * y1_interp_norm + alpha * y2_interp_norm; - % Map back to the original dist_from_tran + % Map back to the original dist_from_exit_plane x1_2_norm = x_common_norm + (alpha * x2_norm(idx2) + (1-alpha) * x1_norm(idx1)); % Interpolate the final focused profile in the normalized space back to the original space mapped_profile_focus = interp1(x_common_norm, norm_profile_focus, x1_2_norm, 'spline', 0); - % Calculate the offset (max_loc) to align the profile focus with dist_from_tran - max_loc = abs(mean(x1_2_norm - dist_from_tran')); + % Calculate the offset (max_loc) to align the profile focus with dist_from_exit_plane + max_loc = abs(mean(x1_2_norm - dist_from_exit_plane')); - profile_focus = interp1(x1_2_norm + max_loc, mapped_profile_focus, dist_from_tran, 'spline', 0); + profile_focus = interp1(x1_2_norm + max_loc, mapped_profile_focus, dist_from_exit_plane, 'spline', 0); % Plot the profiles and the interpolated result figure; - plot(dist_from_tran, profile_1, '-x', 'DisplayName', ... + plot(dist_from_exit_plane, profile_1, '-x', 'DisplayName', ... ['Measurement 1, focus at ' num2str(focus_wrt_exit_plane_1)]); hold on; - plot(dist_from_tran, profile_2, '-x', 'DisplayName', ... + plot(dist_from_exit_plane, profile_2, '-x', 'DisplayName', ... ['Measurement 2, focus at ' num2str(focus_wrt_exit_plane_2)]); - plot(dist_from_tran, profile_focus, '-x', 'DisplayName', ... - ['Interpolated, focus at ' num2str(focus_wrt_exit_plane)]); + plot(dist_from_exit_plane, profile_focus, '-x', 'DisplayName', ... + ['Interpolated, focus at ' num2str(desired_focal_distance_ep)]); legend; xlabel('Distance wrt exit plane [mm]'); ylabel('Intensity [W/cm^2]'); @@ -103,22 +103,22 @@ % Plot the exact profile figure; - plot(dist_from_tran, norm_profile_focus, '-o'); + plot(dist_from_exit_plane, norm_profile_focus, '-o'); xlabel('Distance wrt exit plane [mm]'); ylabel('Intensity [W/cm^2]'); - title(['Axial Profile at Focus wrt Exit Plane: ' num2str(focus_wrt_exit_plane) ' [mm]']); + title(['Axial Profile at Focus wrt Exit Plane: ' num2str(desired_focal_distance_ep) ' [mm]']); end % Create output profile if it does not yet exist if ~exist(parameters.calibration.path_output_profiles); mkdir(parameters.calibration.path_output_profiles); end % Save the plot to the specified directory fig_path = fullfile(parameters.calibration.path_output_profiles, ... - strcat('Interpolation_at_F_', num2str(focus_wrt_exit_plane), '_', equipment_name, '.png')); + strcat('Interpolation_at_F_', num2str(desired_focal_distance_ep), '_', equipment_name, '.png')); saveas(gcf, fig_path); close(gcf); % Determine the maximum intensity beyond the specified skip distance % for scaling to prevent catching max peak in near field peak. - [~, closestIndex] = min(abs(dist_from_tran - parameters.calibration.skip_front_peak_mm)); + [~, closestIndex] = min(abs(dist_from_exit_plane - parameters.calibration.skip_front_peak_mm)); max_intens = max(norm_profile_focus(closestIndex:end)); end \ No newline at end of file diff --git a/functions/calibration/extract_simulated_profile.m b/functions/calibration/extract_simulated_profile.m new file mode 100644 index 00000000..5d92e44e --- /dev/null +++ b/functions/calibration/extract_simulated_profile.m @@ -0,0 +1,122 @@ +function [profile_sim] = extract_simulated_profile(initial_res, parameters) +%-------------------------------------------------------------------------- +% +% PURPOSE: +% This function extracts and visualizes simulated acoustic pressure data +% (e.g., from a k-Wave simulation). It generates a 2D intensity map at +% the focal plane and extracts the axial pressure profile along the +% acoustic axis passing through the transducer center. +% +% INPUTS: +% initial_res - simulation results structure (contains sensor_data) +% parameters - structure containing simulation and grid parameters +% .calibration.desired_focal_distance_ep - desired focal distance (for labeling) +% .calibration.desired_intensity - numeric target intensity (for output naming) +% .calibration.equipment_name - string identifying the experimental setup +% .calibration.prefix - prefix for figure output +% +% OUTPUT: +% profile_sim.axial_intensity - simulated intensity values along the focal axis (from transducer bowl) [W/cm^2] +% profile_sim.axial_distance_bowl - corresponding distances from transducer bowl [mm] +% profile_sim.velocity - simulated particle velocity [m/s] +% +% NOTE: +% The code works for both 2D and 3D simulations +%-------------------------------------------------------------------------- + + %% Retrieve and prepare pressure data + + % The simulation may output data as a GPU array, so gather converts it + % into a standard MATLAB CPU array for post-processing. + p_max = gather(initial_res.sensor_data.p_max_all); + + %% Prepare and plot a 2D map of the focal-plane pressure distribution + + figure; + + % Convert grid indices to physical distances in millimeters. + p_distance = (1:size(p_max, 1)) * parameters.grid.resolution_mm; + p_width = (1:size(p_max, numel(parameters.grid.dims))) * parameters.grid.resolution_mm; + + % Depending on the dimensionality, select an appropriate plane: + if numel(parameters.grid.dims) == 2 + % 2D simulation -> directly available pressure field. + p_axialprofile = squeeze(p_max(:, :))'; + elseif numel(parameters.grid.dims) == 3 + % 3D simulation -> take the central slice at the transducer’s lateral position. + p_axialprofile = squeeze(p_max(:, parameters.transducer.trans_pos(2), :))'; + else + error('Unsupported simulation dimensionality: expected 2 or 3.'); + end + + % Location of the transducer bowl, exit plane, and focus (in mm) + i_bowl = parameters.transducer.trans_pos(end)*parameters.grid.resolution_mm; + i_ep = round(parameters.transducer.trans_pos(end)*parameters.grid.resolution_mm+... + parameters.transducer.focal_distance_offset); + i_focus = parameters.transducer.focus_pos(end)*parameters.grid.resolution_mm; + + % Plot the pressure map + imagesc(p_distance, p_width, p_axialprofile); + axis image; + hold on; + yline(i_bowl, 'Color',[0 0 0]) + yline(i_ep, 'Color',[1 0 0]) + yline(i_focus, 'Color',[1 1 1]) + colormap(getColorMap); + xlabel('Lateral Position [mm]'); + ylabel('Axial Position in water medium (incl. PML) [mm]'); + colorbar; + title('Pressure for the focal plane'); + + clear p_distance p_width p_axialprofile; + + fig_path = fullfile(parameters.outputs_folder, ... + strcat(parameters.calibration.prefix, 'Intensity_map_2D_at_F_', ... + num2str(parameters.calibration.desired_focal_distance_ep), ... + '_at_I_', num2str(parameters.calibration.desired_intensity), ... + '_', parameters.calibration.equipment_name, '.png')); + + % Save and close the figure. + saveas(gcf, fig_path); + close(gcf); + + %% Extract the simulated pressure profile along the focal axis + + % This section isolates the 1D pressure distribution along the acoustic beam. + if numel(parameters.grid.dims) == 2 + % For 2D: extract the axial profile along the transducer's horizontal position. + i_x = parameters.transducer.trans_pos(1); + i_axis = parameters.transducer.trans_pos(end); + pred_axial_pressure = squeeze(p_max(i_x, i_axis:end)); + clear i_x; + + elseif numel(parameters.grid.dims) == 3 + % For 3D: extract along both lateral center coordinates. + i_x = parameters.transducer.trans_pos(1); + i_y = parameters.transducer.trans_pos(2); + i_axis = parameters.transducer.trans_pos(end); + pred_axial_pressure = squeeze(p_max(i_x, i_y, i_axis:end)); + clear i_x i_y; + end + + % Convert pressure to intensities [W/cm^2] + pred_axial_intensity = pred_axial_pressure .^ 2 / (2 * parameters.medium_properties.water.sound_speed * parameters.medium_properties.water.density) * 1e-4; + + % Compute particle velocity [m/s] + velocity = parameters.transducer.source_amp(1) / ... + (parameters.medium_properties.water.density * parameters.medium_properties.water.sound_speed); + + %% Collect profile and distance + + % caluclated distances in mm in the grid + axial_trans_pos_mm = parameters.transducer.trans_pos(end)* parameters.grid.resolution_mm; + axial_end_pos_mm = parameters.grid.default_dims(end) * parameters.grid.resolution_mm; + axial_position_sim_mm = axial_trans_pos_mm:parameters.grid.resolution_mm:axial_end_pos_mm; + % for distance from bowl, remove PML/transducer position + axial_distance_bowl = axial_position_sim_mm-axial_position_sim_mm(1); + + profile_sim.axial_intensity = pred_axial_intensity; + profile_sim.axial_distance_bowl = axial_distance_bowl; + profile_sim.velocity = velocity; + +end diff --git a/functions/calibration/fit_velocity_to_intensity.m b/functions/calibration/fit_velocity_to_intensity.m new file mode 100644 index 00000000..3793c388 --- /dev/null +++ b/functions/calibration/fit_velocity_to_intensity.m @@ -0,0 +1,84 @@ +function [corrected_velocity, I_peak_before, I_peak_after] = fit_velocity_to_intensity(... + parameters, profile_oneil, opt_phases, opt_velocity, desired_intensity, simulated_analytical_scaling) +% fit_velocity_to_intensity - Correct velocity so peak intensity matches desired intensity. +% +% After global search optimizes profile shape (phases + velocity), the peak +% intensity may not exactly equal desired_intensity. Since I ∝ vΒ², we can +% analytically correct velocity: v_new = v_old * sqrt(I_desired / I_peak). +% +% Because opt_source_amp divides by simulated_analytical_scaling, the +% analytical target must be desired_intensity * scaling so that the final +% simulation's Isppa equals desired_intensity. +% +% Arguments: +% parameters - Structure with transducer and medium parameters. +% profile_oneil - Structure with .axial_distance_bowl (mm from bowl). +% opt_phases - Optimized phases for each element [rad]. +% opt_velocity - Optimized particle velocity from global search [m/s]. +% desired_intensity - Target peak intensity [W/cm^2]. +% simulated_analytical_scaling - Ratio of simulated to analytical peak intensity. +% +% Returns: +% corrected_velocity - Velocity adjusted to yield desired peak intensity [m/s]. +% I_peak_before - Peak intensity before correction [W/cm^2]. +% I_peak_after - Peak intensity after correction [W/cm^2]. + + axial_position = profile_oneil.axial_distance_bowl; + + % Compute analytical pressure profile with current optimized parameters + p_axial = focusedAnnulusONeil(... + parameters.transducer.curv_radius_mm / 1e3, ... + [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm] / 1e3, ... + repmat(opt_velocity, 1, parameters.transducer.n_elements), ... + [opt_phases], ... + parameters.transducer.source_freq_hz, ... + parameters.medium_properties.water.sound_speed, ... + parameters.medium_properties.water.density, ... + (axial_position - 0.5) * 1e-3); + + % Convert pressure to intensity [W/cm^2] + I_axial = p_axial .^ 2 / (2 * parameters.medium_properties.water.sound_speed * parameters.medium_properties.water.density) * 1e-4; + + % Exclude near-field if requested + I_filtered = I_axial; + axial_pos_filtered = axial_position; + if parameters.calibration.skip_front_peak_mm ~= 0 + i_remove = axial_pos_filtered <= parameters.calibration.skip_front_peak_mm; + axial_pos_filtered(i_remove) = []; + I_filtered(i_remove) = []; + end + + % Find peak intensity + I_peak_before = max(I_filtered); + + % Guard against zero/near-zero peak + if I_peak_before < eps + warning('fit_velocity_to_intensity: Peak intensity is near zero. Returning original velocity.'); + corrected_velocity = opt_velocity; + I_peak_after = I_peak_before; + return; + end + + % Analytical target accounts for scaling: simulation divides velocity by + % scaling, so analytical peak must overshoot by that factor. + analytical_target = desired_intensity * simulated_analytical_scaling; + + % Correct velocity analytically: I ∝ vΒ² => v_new = v_old * sqrt(I_target / I_peak) + correction_factor = sqrt(analytical_target / I_peak_before); + corrected_velocity = opt_velocity * correction_factor; + I_peak_after = analytical_target; + + % Warn if corrected velocity exceeds upper bound + if isfield(parameters.calibration, 'opt_upper_velocity') && ... + corrected_velocity > parameters.calibration.opt_upper_velocity + warning('fit_velocity_to_intensity: Corrected velocity (%.4f m/s) exceeds opt_upper_velocity (%.4f m/s).', ... + corrected_velocity, parameters.calibration.opt_upper_velocity); + end + + fprintf('Velocity correction: %.4f -> %.4f m/s (factor: %.4f)\n', ... + opt_velocity, corrected_velocity, correction_factor); + fprintf('Analytical target: %.2f W/cm^2 (desired_intensity * scaling = %.2f * %.4f)\n', ... + analytical_target, desired_intensity, simulated_analytical_scaling); + fprintf('Expected Isppa in simulation: %.2f W/cm^2\n', desired_intensity); + +end diff --git a/functions/calibration/perform_global_search.m b/functions/calibration/perform_global_search.m index a2f197f7..c716df39 100644 --- a/functions/calibration/perform_global_search.m +++ b/functions/calibration/perform_global_search.m @@ -1,10 +1,10 @@ -function [opt_phases, opt_velocity, min_err] = perform_global_search(parameters, axial_position, adjusted_profile_focus, velocity) +function [opt_phases, opt_velocity, min_err] = perform_global_search(parameters, profile_target, velocity) % Perform a global search to optimize transducer phases and particle velocity. % % Arguments: % - parameters: Structure containing simulation and transducer parameters. - % - axial_position: Distance vector for the intensity profile [mm from transducer bowl]. - % - adjusted_profile_focus: Adjusted desired intensity profile [W/cm^2]. + % - profile_target.axial_distance_bowl: Distance vector for the intensity profile [mm from transducer bowl]. + % - profile_target.axial_intensity: Adjusted desired intensity profile [W/cm^2]. % - velocity: Initial particle velocity estimate [m/s]. % % Returns: @@ -12,16 +12,20 @@ % - opt_velocity: Optimized particle velocity [m/s]. % - min_err: Minimum error achieved during optimization. - if ~isfield(parameters.calibration, 'opt_limits') - opt_limits = [1, max(axial_position)]; + if ~isfield(parameters.calibration, 'opt_limits') || isempty(parameters.calibration.opt_limits) + % By default set to min and max distance with non-NAN intensity + min_available = min(profile_target.axial_distance_bowl(~isnan(profile_target.axial_intensity))); + max_available = max(profile_target.axial_distance_bowl(~isnan(profile_target.axial_intensity))); + opt_limits = [min_available, max_available]; + else opt_limits = parameters.calibration.opt_limits; end - if ~isfield(parameters.calibration, 'weights') - weights = 1; + if ~isfield(parameters.calibration, 'opt_weights') + weights = 0; % uniform weighting else - weights = parameters.calibration.weights; + weights = parameters.calibration.opt_weights; end % Define the objective function for optimization. @@ -30,23 +34,26 @@ phases_and_velocity(1:end-1), ... % Phases for transducer elements parameters, ... % Simulation and transducer parameters phases_and_velocity(end), ... % Particle velocity - axial_position, ... % Distance vector - adjusted_profile_focus,... % Desired intensity profile + profile_target.axial_distance_bowl, ... % Distance vector + profile_target.axial_intensity,... % Desired intensity profile 0, ... % Disable plotting opt_limits, ... weights); % Set a random seed for reproducibility. - if isfield(parameters.calibration, 'seed') - rng(parameters.calibration.seed, 'twister'); + if isfield(parameters.calibration, 'opt_seed') + rng(parameters.calibration.opt_seed, 'twister'); end % Define initial guess, bounds, and options for the optimization problem. + if ~isfield(parameters.calibration, 'opt_upper_velocity') || isempty(parameters.calibration.opt_upper_velocity) + parameters.calibration.opt_upper_velocity = 0.2; % set default for upper velocity to 20 mm/s; + end initial_guess = [randi(360, [1, parameters.transducer.n_elements]) / 180 * pi, velocity]; - lower_bounds = zeros(1, parameters.transducer.n_elements+1); % Lower bounds: [0 rad, 0 m/s] - upper_bounds = [2 * pi * ones(1, parameters.transducer.n_elements), 0.2]; % Upper bounds: [2pi rad, 0.2 m/s] + lower_bounds = [zeros(1, parameters.transducer.n_elements), 0.001]; % Lower bounds: [0 rad, 1 mm/s] + upper_bounds = [2 * pi * ones(1, parameters.transducer.n_elements), parameters.calibration.opt_upper_velocity]; % Upper bounds: [2pi rad, 200 mm/s] - if ~isfield(parameters.calibration, 'optmethod') || strcmp(parameters.calibration.optmethod, 'FEXminimize') + if ~isfield(parameters.calibration, 'opt_method') || strcmp(parameters.calibration.opt_method, 'FEXminimize') % by default use FEXminimize func = optimize_phases; options = setoptimoptions('popsize', 5000, 'FinDiffType', 'central', 'TolCon', 1e-8); @@ -63,7 +70,7 @@ upper_bounds, ... [], ... options); - elseif strcmp(parameters.calibration.optmethod, 'GlobalSearch') + elseif strcmp(parameters.calibration.opt_method, 'GlobalSearch') % The GlobalSearch functionality is part of MATLAB's Global Optimization Toolbox. % Initialize a GlobalSearch object to perform the optimization. gs = GlobalSearch; @@ -87,12 +94,17 @@ opt_phases, ... % Optimized phases parameters, ... % Simulation and transducer parameters opt_velocity, ... % Optimized velocity - axial_position, ... % Distance vector - adjusted_profile_focus,... % Desired intensity profile + profile_target.axial_distance_bowl, ... % Distance vector + profile_target.axial_intensity,... % Desired intensity profile 1, ... % Enable plotting opt_limits, ... weights); + % Save the figure + fig_path = fullfile(parameters.outputs_folder, sprintf('GlobalSearch.png')); + saveas(gcf, fig_path); + close(gcf); + % The left plot above shows the real and the fitted profiles along with the % cost function used for fitting, while the right shows the error in fitting (the % squared difference between the real and the fitted profile weighted by the cost diff --git a/functions/calibration/phase_optimization_annulus.m b/functions/calibration/phase_optimization_annulus.m index 6f56274b..a45c2e1d 100644 --- a/functions/calibration/phase_optimization_annulus.m +++ b/functions/calibration/phase_optimization_annulus.m @@ -24,8 +24,8 @@ [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm] / 1e3, ... repmat(velocity, 1, parameters.transducer.n_elements), ... [0 phase], parameters.transducer.source_freq_hz, ... - parameters.medium.water.sound_speed, ... - parameters.medium.water.density, ... + parameters.medium_properties.water.sound_speed, ... + parameters.medium_properties.water.density, ... (axial_position - 0.5) * 1e-3); %% Identify focal distance based on FWHM diff --git a/functions/calibration/phase_optimization_annulus_full_curve.m b/functions/calibration/phase_optimization_annulus_full_curve.m index f14d1d44..c3d34424 100644 --- a/functions/calibration/phase_optimization_annulus_full_curve.m +++ b/functions/calibration/phase_optimization_annulus_full_curve.m @@ -43,21 +43,26 @@ [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm] / 1e3, ... repmat(velocity, 1, parameters.transducer.n_elements), ... [phase], parameters.transducer.source_freq_hz, ... - parameters.medium.water.sound_speed, ... - parameters.medium.water.density, ... + parameters.medium_properties.water.sound_speed, ... + parameters.medium_properties.water.density, ... (axial_position - 0.5) * 1e-3); % Convert pressure profile to intensity profile - i_axial_oneil = p_axial_oneil.^2 / (2 * parameters.medium.water.sound_speed * parameters.medium.water.density) * 1e-4; + i_axial_oneil = p_axial_oneil.^2 / (2 * parameters.medium_properties.water.sound_speed * parameters.medium_properties.water.density) * 1e-4; %% Generate weights if not provided + if weights == 0 - % Find FLHM center and generate Gaussian weights around it - [flhm_center, flhm_center_index] = get_flhm_center_position(axial_position, desired_intensity_curve); - weights = normpdf(axial_position, axial_position(flhm_center_index) + 0.5, axial_position(flhm_center_index) / 3); + % UNIFORM: equal weight everywhere (optimize entire profile) + weights = ones(size(axial_position)); + elseif weights >= 1 + % FWHM GAUSSIAN: weights controls narrowness + [~, flhm_center_index] = get_flhm_center_position(axial_position, desired_intensity_curve); + center_pos = axial_position(flhm_center_index); + sigma = center_pos / weights; % 1=wide FWHM, 10=narrow peak + weights = normpdf(axial_position, center_pos, sigma); end - - % Normalize weights to sum to 1 + % Weights always sum to one. weights = weights / sum(weights); %% Calculate error metric @@ -80,7 +85,6 @@ hold off; legend(["fitted profile", "real profile", "cost function", "error"], 'Location', 'South'); legend('boxoff') - ylim([-10 inf]); % Plot error values within optimization limits ax2 = subplot(1, 2, 2); diff --git a/functions/calibration/plot_opt_sim_results.m b/functions/calibration/plot_opt_sim_results.m index 364f2d5d..928d8ea7 100644 --- a/functions/calibration/plot_opt_sim_results.m +++ b/functions/calibration/plot_opt_sim_results.m @@ -1,93 +1,59 @@ -function plot_opt_sim_results(opt_param, sim_id, axial_position, dist_exit_plane, adjusted_profile_focus, p_axial_oneil_opt, p_axial_oneil, focus_wrt_exit_plane, desired_intensity, equipment_name, min_err) +function plot_opt_sim_results(parameters, profile_target, profile_oneil, profile_oneil_opt, profile_sim, profile_sim_opt, min_err) % Plot optimized simulation results and compare with desired profiles % % Arguments: - % - opt_param: Structure containing optimized parameters. - % opt_param.calibration.path_output: Directory for saving output. - % opt_param.calibration.save_in_calibration_folder: Option to save data in the general PRESTUS output folder. - % opt_param.outputs_folder: Directory containing output simulation results. - % - sim_id: Simulation ID for loading specific results. - % - axial_position: Axial position vector [mm]. - % - dist_exit_plane: Distance vector for desired profile focus [mm]. - % - adjusted_profile_focus: Adjusted desired intensity profile [W/cm^2]. - % - p_axial_oneil_opt: Optimized O'Neil solution for pressure [Pa]. - % - p_axial_oneil: Original O'Neil solution for pressure [Pa]. - % - focus_wrt_exit_plane: Focal depth with respect to the transducer exit plane [mm]. - % - desired_intensity: Target intensity for optimization [W/cm^2]. - % - equipment_name: Name of the equipment used. + % - parameters: Structure containing optimized parameters. + % .calibration.path_output: Directory for saving output. + % .calibration.save_in_calibration_folder: Option to save data in the general PRESTUS output folder. + % .calibration.desired_focal_distance_ep: Focal depth with respect to the transducer exit plane [mm]. + % .calibration.desired_intensity: Target intensity for optimization [W/cm^2]. + % .calibration.equipment_name: Name of the equipment used. + % .outputs_folder: Directory containing output simulation results. + % - profile_target + % .axial_distance_bowl: Axial position vector [mm from bowl]. + % .axial_intensity: Adjusted desired intensity profile [W/cm^2]. + % - profile_oneil + % .axial_intensity: Original O'Neil solution for intensity [W/cm^2]. + % - profile_oneil_opt + % .axial_intensity: Optimized O'Neil solution for intensity [W/cm^2]. + % - profile_sim + % .axial_intensity: Original simulation intensity profile [W/cm^2]. + % - profile_sim_opt + % .axial_intensity: Optimized simulation intensity profile [W/cm^2]. % - min_err: Minimum optimization error. - % Load optimized simulation results - opt_res = load(sprintf('%s/sub-%03d_water_results%s.mat', ... - opt_param.outputs_folder, sim_id, opt_param.results_filename_affix)); - - opt_res_param = opt_res.acoustic_info.parameters; - - % Extract maximum pressure profile - p_max = gather(opt_res.sensor_data.p_max_all); - - p_distance = (1:size(p_max, 1)) * opt_res_param.grid_step_mm; - p_width = (1:size(p_max, opt_res_param.n_sim_dims)) * opt_res_param.grid_step_mm; - if opt_res_param.n_sim_dims == 2 - p_axialprofile = squeeze(p_max(:, :))'; - elseif opt_res_param.n_sim_dims == 3 - p_axialprofile = squeeze(p_max(:, opt_res_param.transducer.trans_pos(2), :))'; - end - - % Simulated pressure along the focal axis - pred_axial_pressure_opt = p_axialprofile(:,opt_res_param.transducer.trans_pos(1)); - - % Compute focal position relative to the mid-bowl of the transducer - focus_wrt_mid_bowl = focus_wrt_exit_plane + (opt_res_param.transducer.curv_radius_mm - opt_res_param.transducer.dist_to_plane_mm); - - %% Plot 2D intensity map for the focal plane - - figure; - imagesc(p_distance, p_width, p_axialprofile); - clear p_distance p_width; - axis image; - colormap(getColorMap()); - xlabel('Lateral Position [mm]'); - ylabel('Axial Position [mm]'); - colorbar; - title('Pressure for the Focal Plane') - - % Save the 2D intensity map figure - fig_path = fullfile(opt_param.outputs_folder, strcat('Opt_intensity_map_2D_at_F_', num2str(focus_wrt_exit_plane), '_at_I_', num2str(desired_intensity), '_', equipment_name, '.png')); - saveas(gcf, fig_path); - close(gcf); - %% Plot comparison of profiles figure('Position', [10, 10, 900, 500]); hold on; - plot(axial_position, p_axial_oneil .^ 2 / (2 * opt_param.medium.water.sound_speed * opt_param.medium.water.density) * 1e-4, ... - 'LineWidth', 1, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Original Simulation'); - plot(axial_position, p_axial_oneil_opt .^ 2 / (2 * opt_param.medium.water.sound_speed * opt_param.medium.water.density) * 1e-4, ... + plot(profile_target.axial_distance_bowl, profile_target.axial_intensity, ... + 'LineWidth', 2, 'Color', 'r', 'DisplayName', 'Target Profile'); + plot(profile_oneil.axial_distance_bowl, profile_oneil.axial_intensity, ... + 'LineWidth', 1, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Original (Analytical))'); + plot(profile_oneil_opt.axial_distance_bowl, profile_oneil_opt.axial_intensity, ... 'LineWidth', 1, 'Color', [0 0 0], 'LineStyle', ':', 'DisplayName', 'Optimized (Analytical)'); - - % Adjust axial position for simulated results - sim_res_axial_position = axial_position - (opt_res_param.transducer.trans_pos(end) - 1) * opt_res_param.grid_step_mm; - plot(sim_res_axial_position, pred_axial_pressure_opt .^ 2 / (2 * opt_param.medium.water.sound_speed * opt_param.medium.water.density) * 1e-4, ... + plot(profile_sim.axial_distance_bowl, profile_sim.axial_intensity, ... + 'LineWidth', 1, 'Color', [0.75 0.75 0.75], 'DisplayName', 'Original (Simulated))'); + plot(profile_sim_opt.axial_distance_bowl, profile_sim_opt.axial_intensity, ... 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'Optimized (Simulated)'); - plot(dist_exit_plane, adjusted_profile_focus, ... - 'LineWidth', 2, 'Color', 'r', 'DisplayName', 'Desired Profile'); hold off; % Add focus and intensity reference lines - xline(focus_wrt_mid_bowl, '--', 'DisplayName', 'Focal Point wrt mid-bowl'); - yline(desired_intensity, '--', 'DisplayName', 'Desired Intensity'); + xline(parameters.expected_focal_distance_bowl, '--', 'DisplayName', 'Focal Point wrt mid-bowl'); + yline(parameters.calibration.desired_intensity, '--', 'DisplayName', 'Desired Intensity'); xlabel('Distance wrt Mid-Bowl of Transducer [mm]'); ylabel('Intensity [W/cm^2]'); legend('Location', 'best'); title(sprintf('Desired vs Optimized Profiles - Optimization Error: %.4f', min_err)); ylim([0 inf]); - xlim([-5 inf]); + xlim([0 inf]); % Save the profile comparison figure - fig_path = fullfile(opt_param.outputs_folder, ... - strcat('Opt_simulation_at_F_', num2str(focus_wrt_exit_plane), '_at_I_', num2str(desired_intensity), '_', equipment_name, '.png')); + fig_path = fullfile(parameters.outputs_folder, ... + strcat('Opt_simulation_at_F_', num2str(parameters.calibration.desired_focal_distance_ep), ... + '_at_I_', num2str(parameters.calibration.desired_intensity), ... + '_', parameters.calibration.equipment_name, '.png')); saveas(gcf, fig_path); close(gcf); @@ -95,31 +61,52 @@ function plot_opt_sim_results(opt_param, sim_id, axial_position, dist_exit_plane figure('Position', [10, 10, 900, 500]); hold on; - % Adjust axial position for simulated results - sim_res_axial_position = axial_position - (opt_res_param.transducer.trans_pos(end) - 1) * opt_res_param.grid_step_mm; - plot(sim_res_axial_position, pred_axial_pressure_opt .^ 2 / (2 * opt_param.medium.water.sound_speed * opt_param.medium.water.density) * 1e-4, ... + plot(profile_sim_opt.axial_distance_bowl, profile_sim_opt.axial_intensity, ... 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'Optimized (Simulated)'); - plot(dist_exit_plane, adjusted_profile_focus, ... + plot(profile_target.axial_distance_bowl, profile_target.axial_intensity, ... 'LineWidth', 2, 'Color', 'r', 'DisplayName', 'Desired Profile'); hold off; % Add focus and intensity reference lines - xline(focus_wrt_mid_bowl, '--', 'DisplayName', 'Focal Point wrt mid-bowl'); - yline(desired_intensity, '--', 'DisplayName', 'Desired Intensity'); + xline(parameters.expected_focal_distance_bowl, '--', 'DisplayName', 'Focal Point wrt mid-bowl'); + yline(parameters.calibration.desired_intensity, '--', 'DisplayName', 'Desired Intensity'); + % add expected distance + if isfield(parameters, 'expected_focal_distance_bowl') + xline(parameters.expected_focal_distance_bowl, '--', ... + 'LineWidth', 1.2, 'DisplayName', 'Expected Focal Distance (mm from bowl)', 'Color', [1 0 0]); + end + % add exit plane + if isfield(parameters.transducer, 'focal_distance_offset') + xline(parameters.transducer.focal_distance_offset, '--', ... + 'LineWidth', 1.2, 'DisplayName', 'Exit Plane'); + end xlabel('Distance wrt Mid-Bowl of Transducer [mm]'); ylabel('Intensity [W/cm^2]'); legend('Location', 'best'); ylim([0 inf]); - xlim([-5 inf]); + xlim([0 inf]); % Save the profile comparison figure - fig_path = fullfile(opt_param.calibration.path_output_profiles, ... - strcat('OptProfile_at_F_', num2str(focus_wrt_exit_plane), '_at_I_', num2str(desired_intensity), '_', equipment_name, '.png')); + fig_path = fullfile(parameters.calibration.path_output_profiles, ... + strcat('OptProfile_at_F_', num2str(parameters.calibration.desired_focal_distance_ep), ... + '_at_I_', num2str(parameters.calibration.desired_intensity), ... + '_', parameters.calibration.equipment_name, '.png')); saveas(gcf, fig_path); close(gcf); - %% display summary + %% Display summary + + parameters = focal_distance_calculation(parameters); + + max_intensity_index = find(profile_sim_opt.axial_intensity == max(profile_sim_opt.axial_intensity), 1); + fprintf('Expected distance from bowl to the point of maximum intensity: %.2f mm\n', ... + parameters.expected_focal_distance_bowl); + fprintf('Estimated distance from bowl to the point of maximum intensity: %.2f mm\n', ... + profile_sim_opt.axial_distance_bowl(max_intensity_index)); + + fprintf('Expected distance from exit plane to the point of maximum intensity: %.2f mm\n', ... + parameters.expected_focal_distance_ep); + fprintf('Estimated distance from exit plane to the point of maximum intensity: %.2f mm\n', ... + profile_sim_opt.axial_distance_bowl(max_intensity_index)-parameters.transducer.focal_distance_offset); - max_pressure_index = find(pred_axial_pressure_opt == max(pred_axial_pressure_opt), 1); - fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n', sim_res_axial_position(max_pressure_index)); end \ No newline at end of file diff --git a/functions/calibration/recalculate_analytical_sol.m b/functions/calibration/recalculate_analytical_sol.m deleted file mode 100644 index 63670eef..00000000 --- a/functions/calibration/recalculate_analytical_sol.m +++ /dev/null @@ -1,82 +0,0 @@ -function p_axial_oneil_opt = recalculate_analytical_sol(parameters, p_axial_oneil, opt_phases, opt_velocity, dist_from_tran, adjusted_profile_focus, axial_position, focus_wrt_exit_plane, desired_intensity, equipment_name) - % Recalculate analytical solution based on optimized phases and velocity. - % - % Arguments: - % - parameters: Structure containing simulation and transducer parameters. - % - p_axial_oneil: Initial O'Neil solution for pressure [Pa]. - % - opt_phases: Optimized phases for each transducer element [rad]. - % - opt_velocity: Optimized particle velocity [m/s]. - % - dist_from_tran: Distance vector for desired profile focus [mm]. - % - adjusted_profile_focus: Adjusted desired intensity profile [W/cm^2]. - % - axial_position: Axial position vector [mm]. - % - focus_wrt_exit_plane: Focal depth with respect to the transducer exit plane [mm]. - % - desired_intensity: Target intensity for optimization [W/cm^2]. - % - equipment_name: Name of the equipment used. - % - % Returns: - % - p_axial_oneil_opt: Optimized O'Neil solution for pressure along the beam axis [Pa]. - - % Compute optimized analytical pressure profile - p_axial_oneil_opt = focusedAnnulusONeil(... - parameters.transducer.curv_radius_mm / 1e3, ... - [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm] / 1e3, ... - repmat(opt_velocity, 1, parameters.transducer.n_elements), ... - [opt_phases], ... - parameters.transducer.source_freq_hz, ... - parameters.medium.water.sound_speed, ... - parameters.medium.water.density, ... - (axial_position - 0.5) * 1e-3); - - % Convert pressure to intensity - i_axial_oneil = p_axial_oneil .^ 2 / (2 * parameters.medium.water.sound_speed * parameters.medium.water.density) * 1e-4; - i_axial_oneil_opt = p_axial_oneil_opt .^ 2 / (2 * parameters.medium.water.sound_speed * parameters.medium.water.density) * 1e-4; - - % Compute focal position relative to the mid-bowl of the transducer - focus_wrt_mid_bowl = focus_wrt_exit_plane + (parameters.transducer.curv_radius_mm - parameters.transducer.dist_to_plane_mm); - - % Plot comparison of profiles - figure('Position', [10, 10, 900, 500]); - plot(axial_position, i_axial_oneil, ... - 'LineWidth', 1, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Original Profile (Analytical)'); - hold on; - plot(axial_position, i_axial_oneil_opt, ... - 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'Optimized Profile (Analytical)'); - plot(dist_from_tran, adjusted_profile_focus, ... - 'LineWidth', 2, 'Color', [1 0 0], 'DisplayName', 'Desired Profile'); - hold off; - xline(focus_wrt_mid_bowl, '--', 'DisplayName', 'Focal Point wrt Mid-Bowl'); - yline(desired_intensity, '--', 'DisplayName', 'Desired Intensity'); - - xlabel('Distance wrt Mid-Bowl of Transducer [mm]'); - ylabel('Intensity [W/cm^2]'); - legend('Location', 'NorthEast'); - legend('boxoff') - title('Pressure Along the Beam Axis'); - ylim([0 inf]); - xlim([-5 inf]); - - % Save the profile comparison figure - fig_path = fullfile(parameters.outputs_folder, ... - strcat('Recalculated_oneil_at_F_', num2str(focus_wrt_exit_plane), ... - '_at_I_', num2str(desired_intensity), '_', equipment_name, '.png')); - saveas(gcf, fig_path); - close(gcf); - - % exclude the near-field if requested - p_axial_oneil_opt_summary = p_axial_oneil_opt; - if parameters.calibration.skip_front_peak_mm ~=0 - i_remove = axial_position <= parameters.calibration.skip_front_peak_mm; - axial_position(i_remove) = []; - p_axial_oneil_opt_summary(i_remove) = []; - end - - fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n', ... - axial_position(p_axial_oneil_opt_summary == max(p_axial_oneil_opt_summary))) - try - fprintf('Estimated distance to the center of half-maximum range: %.2f mm\n', ... - get_flhm_center_position(axial_position, p_axial_oneil_opt_summary)) - catch - warning('Could not estimated distance to the center of half-maximum range'); - end - -end \ No newline at end of file diff --git a/functions/calibration/recompute_oneil_solution.m b/functions/calibration/recompute_oneil_solution.m new file mode 100644 index 00000000..77edeebd --- /dev/null +++ b/functions/calibration/recompute_oneil_solution.m @@ -0,0 +1,90 @@ +function profile_oneil_opt = recompute_oneil_solution(parameters, profile_oneil, profile_target, opt_phases, opt_velocity) + % Recalculate analytical solution based on optimized phases and velocity. + % + % Arguments: + % - parameters: Structure containing simulation and transducer parameters. + % - .calibration.desired_focal_distance_ep: Focal depth with respect to the transducer exit plane [mm]. + % - .calibration.desired_intensity: Target intensity for optimization [W/cm^2]. + % - .calibration.equipment_name: Name of the equipment used. + % - profile_oneil.axial_distance_bowl: Axial position vector [mm from bowl]. + % - profile_oneil.axial_intensity: Initial O'Neil solution for intensity [W/cm2]. + % - profile_target.axial_intensity: Adjusted desired intensity profile [W/cm^2]. + % - opt_phases: Optimized phases for each transducer element [rad]. + % - opt_velocity: Optimized particle velocity [m/s]. + % + % Returns: + % - profile_oneil_opt.axial_intensity: Optimized O'Neil solution for intensity along the beam axis [W/cm2]. + % - profile_oneil_opt.axial_distance_bowl: Axial position vector [mm from bowl]. + + % Get axial position + axial_position = profile_oneil.axial_distance_bowl; + + % Compute optimized analytical pressure profile + p_axial_oneil_opt = focusedAnnulusONeil(... + parameters.transducer.curv_radius_mm / 1e3, ... + [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm] / 1e3, ... + repmat(opt_velocity, 1, parameters.transducer.n_elements), ... + [opt_phases], ... + parameters.transducer.source_freq_hz, ... + parameters.medium_properties.water.sound_speed, ... + parameters.medium_properties.water.density, ... + (axial_position - 0.5) * 1e-3); + + % Convert pressure to intensity + i_axial_oneil = profile_oneil.axial_intensity; + i_axial_oneil_opt = p_axial_oneil_opt .^ 2 / (2 * parameters.medium_properties.water.sound_speed * parameters.medium_properties.water.density) * 1e-4; + + % Plot comparison of profiles + figure('Position', [10, 10, 900, 500]); + plot(axial_position, i_axial_oneil, ... + 'LineWidth', 1, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Original Profile (Analytical)'); + hold on; + plot(axial_position, i_axial_oneil_opt, ... + 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'Optimized Profile (Analytical)'); + plot(profile_target.axial_distance_bowl, profile_target.axial_intensity, ... + 'LineWidth', 2, 'Color', [1 0 0], 'DisplayName', 'Desired Profile'); + hold off; + xline(parameters.expected_focal_distance_bowl, '--', 'DisplayName', 'Focal Point wrt Mid-Bowl'); + yline(parameters.calibration.desired_intensity, '--', 'DisplayName', 'Desired Intensity'); + + xlabel('Distance wrt Mid-Bowl of Transducer [mm]'); + ylabel('Intensity [W/cm^2]'); + legend('Location', 'NorthEast'); + legend('boxoff') + title('Pressure Along the Beam Axis'); + ylim([0 inf]); + xlim([-5 inf]); + + % Save the profile comparison figure + fig_path = fullfile(parameters.outputs_folder, ... + strcat('Recalculated_oneil_at_F_', num2str(parameters.calibration.desired_focal_distance_ep), ... + '_at_I_', num2str(parameters.calibration.desired_intensity), ... + '_', parameters.calibration.equipment_name, '.png')); + saveas(gcf, fig_path); + close(gcf); + + % exclude the near-field if requested + i_axial_oneil_opt_summary = i_axial_oneil_opt; + axial_position_adj = axial_position; + if parameters.calibration.skip_front_peak_mm ~=0 + i_remove = axial_position_adj <= parameters.calibration.skip_front_peak_mm; + axial_position_adj(i_remove) = []; + i_axial_oneil_opt_summary(i_remove) = []; + end + + fprintf('Estimated distance to the point of maximum intensity: %.2f mm\n', ... + axial_position_adj(i_axial_oneil_opt_summary == max(i_axial_oneil_opt_summary))) + try + fprintf('Estimated distance to the center of half-maximum range: %.2f mm\n', ... + get_flhm_center_position(axial_position_adj, i_axial_oneil_opt_summary)) + catch + warning('Could not estimated distance to the center of half-maximum range'); + end + + %% Collect in structure + + profile_oneil_opt.axial_intensity = i_axial_oneil_opt; + profile_oneil_opt.axial_distance_bowl = axial_position; + + +end \ No newline at end of file diff --git a/functions/calibration/save_optimized_values.m b/functions/calibration/save_optimized_values.m index 295e2427..1c47b91d 100644 --- a/functions/calibration/save_optimized_values.m +++ b/functions/calibration/save_optimized_values.m @@ -1,12 +1,12 @@ -function save_optimized_values(parameters, focus_wrt_exit_plane, desired_intensity, equipment_name) +function save_optimized_values(parameters) % Save optimized phases and amplitude values to a CSV file. % % Arguments: % - parameters: Structure containing optimized parameters, including transducer values. - % parameters.calibration.path_output: Path to the output for saving optimized values. - % - focus_wrt_exit_plane: Target focal distance with respect to the exit plane [mm]. - % - desired_intensity: Target intensity for optimization [W/cm^2]. - % - equipment_name: Serial number of the driving system & transducer. + % .calibration.path_output: Path to the output for saving optimized values. + % .calibration.desired_focal_distance_ep: Target focal distance with respect to the exit plane [mm]. + % .calibration.desired_intensity: Target intensity for optimization [W/cm^2]. + % .calibration.equipment_name: Serial number of the driving system & transducer. disp('Saving optimized values to CSV file...'); @@ -27,19 +27,19 @@ function save_optimized_values(parameters, focus_wrt_exit_plane, desired_intensi prestus_int = cell2mat(virtual_data(2:end, 1)); % Find or add the focal distance column - col_index_foc = find(prestus_foci_wrt_exit_plane == focus_wrt_exit_plane, 1); + col_index_foc = find(prestus_foci_wrt_exit_plane == parameters.calibration.desired_focal_distance_ep, 1); if isempty(col_index_foc) col_index_foc = size(virtual_data, 2) + 1; - virtual_data{1, col_index_foc} = focus_wrt_exit_plane; + virtual_data{1, col_index_foc} = parameters.calibration.desired_focal_distance_ep; else col_index_foc = col_index_foc + 1; % Adjust for header row end % Find or add the desired intensity row - row_index_int = find(prestus_int == desired_intensity, 1); + row_index_int = find(prestus_int == parameters.calibration.desired_intensity, 1); if isempty(row_index_int) row_index_int = size(virtual_data, 1) + 1; - virtual_data{row_index_int, 1} = desired_intensity; + virtual_data{row_index_int, 1} = parameters.calibration.desired_intensity; else row_index_int = row_index_int + 1; % Adjust for header row end @@ -66,8 +66,8 @@ function save_optimized_values(parameters, focus_wrt_exit_plane, desired_intensi else % Create a new file virtual_data = { - 'Desired Intensity [W/cm^2] - Focus wrt Exit Plane [mm]', focus_wrt_exit_plane; - desired_intensity, mat2str([opt_phases, source_amp]) + 'Desired Intensity [W/cm^2] - Focus wrt Exit Plane [mm]', parameters.calibration.desired_focal_distance_ep; + parameters.calibration.desired_intensity, mat2str([opt_phases, source_amp]) }; writecell(virtual_data, output_file_path, 'FileType', 'text'); @@ -75,15 +75,17 @@ function save_optimized_values(parameters, focus_wrt_exit_plane, desired_intensi % Save optimized transducer parameters to a YAML file for easy % integration in PRESTUS config file - if mod(focus_wrt_exit_plane,1) == 0 - yaml_file = sprintf('%s-F%.0fmm-I%.0fwpercm2.yaml', equipment_name, focus_wrt_exit_plane, desired_intensity); + if mod(parameters.calibration.desired_focal_distance_ep,1) == 0 + yaml_file = sprintf('%s-F%.0fmm-I%.0fwpercm2.yaml', parameters.calibration.equipment_name, ... + parameters.calibration.desired_focal_distance_ep, parameters.calibration.desired_intensity); else - yaml_file = sprintf('%s-F%.1fmm-I%.0fwpercm2.yaml', equipment_name, focus_wrt_exit_plane, desired_intensity); + yaml_file = sprintf('%s-F%.1fmm-I%.0fwpercm2.yaml', parameters.calibration.equipment_name, ... + parameters.calibration.desired_focal_distance_ep, parameters.calibration.desired_intensity); end yaml_path = fullfile(parameters.calibration.path_output_profiles, yaml_file); - parameters.transducer.set_focus_wrt_exit_plane_mm = focus_wrt_exit_plane; - parameters.transducer.set_intensity_w_per_cm2 = desired_intensity; + parameters.transducer.set_focus_wrt_exit_plane_mm = parameters.calibration.desired_focal_distance_ep; + parameters.transducer.set_intensity_w_per_cm2 = parameters.calibration.desired_intensity; % Wrap transducer parameters in a parent structure for YAML data = struct('transducer', parameters.transducer); diff --git a/functions/calibration/scale_real_intensity_profile.m b/functions/calibration/scale_real_intensity_profile.m index 600858bb..594a88c4 100644 --- a/functions/calibration/scale_real_intensity_profile.m +++ b/functions/calibration/scale_real_intensity_profile.m @@ -1,25 +1,29 @@ -function adjusted_profile_focus = scale_real_intensity_profile(parameters, desired_intensity, profile_focus) +function [profile_target, parameters] = scale_real_intensity_profile(parameters, profile_empirical, desired_intensity) % Scale the real intensity profile to match the desired maximum intensity. + % Match the length of the target profile to the simulation axis length. % % Arguments: % - parameters: Simulation parameters, including transducer properties. % .medium.water.density: Density of water [kg/m^3]. % .medium.water.sound_speed: Speed of sound in water [m/s]. % .transducer: transducer information + % - profile_empirical.axial_intensity: Measured or theoretical intensity profile along beam axis [W/cm^2] + % - profile_empirical.axial_distance_bowl: Distance from transducer reference point (mm) % - desired_intensity: Desired maximum intensity for the profile [W/cm^2]. - % - profile_focus: Measured intensity profile at the focus [W/cm^2]. % % Returns: - % - adjusted_profile_focus: Adjusted intensity profile to match the desired intensity [W/cm^2]. + % - profile_target.axial_intensity: Adjusted intensity profile to match the desired intensity [W/cm^2]. + % - profile_target.axial_distance_bowl: Distance from transducer reference point (mm) + % - parameters: updated with scaled parameters.transducer.source_amp % Calculate maximum intensity and log adjustment details - max_intens = max(profile_focus); + max_intens = max(profile_empirical.axial_intensity); fprintf('Current maximum intensity: %.2f \nDesired maximum intensity: %.2f \nAdjust profile to match desired maximum intensity.\n', max_intens, desired_intensity); % Calculate the corresponding pressure amplitude for the desired intensity - DENSITY_WATER = parameters.medium.water.density; - SOUND_SPEED_WATER = parameters.medium.water.sound_speed; + DENSITY_WATER = parameters.medium_properties.water.density; + SOUND_SPEED_WATER = parameters.medium_properties.water.sound_speed; p_pa = sqrt(2 * desired_intensity * 1e4 * DENSITY_WATER * SOUND_SPEED_WATER); % Update source amplitude in simulation parameters @@ -27,6 +31,29 @@ % Linearly scale the intensity profile adjustment_factor_intensity = max_intens / desired_intensity; - adjusted_profile_focus = profile_focus' ./ adjustment_factor_intensity; + adjusted_axial_intensity = profile_empirical.axial_intensity' ./ adjustment_factor_intensity; + + profile_target.axial_intensity = adjusted_axial_intensity; + profile_target.axial_distance_bowl = profile_empirical.axial_distance_bowl'; + + % Calculate size of the simulation domain + sim_axis_mm = (parameters.grid.default_dims(end)-parameters.grid.pml_size-1)*parameters.grid.resolution_mm; + + % Dynamically truncate or pad to match simulation domain + if profile_empirical.axial_distance_bowl(end) > sim_axis_mm + % Case 1: Remove values beyond simulation length + valid_idx = profile_target.axial_distance_bowl <= sim_axis_mm; + profile_target.axial_distance_bowl = profile_target.axial_distance_bowl(valid_idx); + profile_target.axial_intensity = profile_target.axial_intensity(valid_idx); + else + % Case 2: Pad with NaN to simulation length + pad_length = round((sim_axis_mm - profile_target.axial_distance_bowl(end)) / parameters.grid.resolution_mm); + if pad_length > 0 + pad_dist = profile_target.axial_distance_bowl(end) + ... + (1:pad_length) * parameters.grid.resolution_mm; + profile_target.axial_distance_bowl = [profile_target.axial_distance_bowl, pad_dist]; + profile_target.axial_intensity = [profile_target.axial_intensity, NaN(1, pad_length)]; + end + end end \ No newline at end of file diff --git a/functions/calibration/set_real_phases.m b/functions/calibration/set_real_phases.m index 0e24b19b..3d159f9d 100644 --- a/functions/calibration/set_real_phases.m +++ b/functions/calibration/set_real_phases.m @@ -1,10 +1,10 @@ -function source_phase_deg = set_real_phases(phase_table, tran, focus_wrt_exit_plane, parameters) +function source_phase_deg = set_real_phases(phase_table, tran, focal_distance_ep, parameters) % Set the manufacturer-specified phases of the transducer for a given focal depth. % % Arguments: % - phase_table: Additional data for phase calculations. % - tran: Transducer structure containing manufacturer and element details. - % - focus_wrt_exit_plane: Desired focal depth with respect to the transducer exit plane [mm]. + % - focal_distance_ep: Desired focal depth with respect to the transducer exit plane [mm]. % - parameters % .medium.water.sound_speed: Speed of sound in water [m/s]. % @@ -17,11 +17,11 @@ distance = phase_table.Distance; phases = phase_table(:, 2:end-1); % Remove non-phase columns - foc_index = find(distance == focus_wrt_exit_plane, 1); + foc_index = find(distance == focal_distance_ep, 1); init_phases = table2array(phases(foc_index, :)); elseif isequal(tran.manufact, "Imasonic") % Compute phases based on the focal depth - init_phases = compute_phases(parameters.medium.water.sound_speed, tran, focus_wrt_exit_plane, phase_table); + init_phases = compute_phases(parameters.medium_properties.water.sound_speed, tran, focal_distance_ep, phase_table); else error('Unsupported transducer manufacturer: %s', tran.manufact); end diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index 2fc0db10..7fb792c0 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -1,4 +1,4 @@ -function report_path = generate_simulation_report(parameters, results_acoustic, results_heating, highlighted_pos) +function report_path = generate_simulation_report(parameters) % GENERATE_SIMULATION_REPORT creates a self-contained HTML report consolidating % all PRESTUS simulation outputs (CSV data, images, logs) into a single portable file. % @@ -7,24 +7,15 @@ % % Use as: % report_path = generate_simulation_report(parameters) -% report_path = generate_simulation_report(parameters, results_acoustic) -% report_path = generate_simulation_report(parameters, results_acoustic, results_heating) -% report_path = generate_simulation_report(parameters, results_acoustic, results_heating, highlighted_pos) % % Inputs: % parameters - PRESTUS parameters struct (required) -% results_acoustic - Acoustic simulation results (optional) -% results_heating - Thermal simulation results (optional) -% highlighted_pos - Highlighted position coordinates (optional) % % Output: % report_path - Path to the generated HTML file arguments parameters struct - results_acoustic = [] - results_heating = [] - highlighted_pos = [] end report_path = ''; @@ -32,21 +23,21 @@ try %% Determine output path subject_id = parameters.subject_id; - medium = parameters.simulation_medium; + medium = parameters.simulation.medium; is_layered = contains(medium, {'layered', 'phantom'}); affix = ''; - if isfield(parameters, 'results_filename_affix') - affix = parameters.results_filename_affix; + if isfield(parameters.io, 'output_affix') + affix = parameters.io.output_affix; end report_filename = sprintf('sub-%03d_%s_report%s.html', subject_id, medium, affix); - report_path = fullfile(parameters.output_dir, report_filename); + report_path = fullfile(parameters.io.output_dir, report_filename); %% Load CSV data if available csv_table = []; - if isfield(parameters, 'filename_output_table') && isfile(parameters.filename_output_table) + if isfield(parameters.io, 'filename_output_table') && isfile(parameters.io.filename_output_table) try - csv_table = readtable(parameters.filename_output_table, 'VariableNamingRule', 'preserve'); + csv_table = readtable(parameters.io.filename_output_table, 'VariableNamingRule', 'preserve'); catch csv_table = []; end @@ -113,7 +104,18 @@ end end - % Section 6: Positioning (open by default) + % Section 6: pseudoCT [Optional] (collapsed by default) + try + if isfield(parameters, 'pct') && isfield(parameters.pct, 'enabled') && parameters.pct.enabled + html_parts{end+1} = collapsible_section('pseudo-CT', ... + build_pseudoCT_section(parameters), ... + false, 'pseudoCT'); + end + catch ME + html_parts{end+1} = section_error('pseudo-CT', ME); + end + + % Section 7: Positioning (open by default) try html_parts{end+1} = collapsible_section('Transducer Positioning', ... build_positioning_section(parameters, subject_id, affix), true, 'positioning'); @@ -121,7 +123,7 @@ html_parts{end+1} = section_error('Positioning', ME); end - % Section 7: Acoustic Results (open by default) + % Section 8: Acoustic Results (open by default) try html_parts{end+1} = collapsible_section('Acoustic Results', ... build_acoustic_section(csv_table, parameters, subject_id, medium, affix, is_layered), true, 'acoustic'); @@ -129,9 +131,9 @@ html_parts{end+1} = section_error('Acoustic Results', ME); end - % Section 8: Thermal Results (open by default, conditional) + % Section 9: Thermal Results (open by default, conditional) try - if isfield(parameters, 'run_heating_sims') && parameters.run_heating_sims + if isfield(parameters.modules, 'run_heating_sims') && parameters.modules.run_heating_sims html_parts{end+1} = collapsible_section('Thermal Results', ... build_thermal_section(csv_table, parameters, subject_id, medium, affix, is_layered), true, 'thermal'); end @@ -139,9 +141,9 @@ html_parts{end+1} = section_error('Thermal Results', ME); end - % Section 9: Debug Information (collapsed by default, layered only) + % Section 10: Debug Information (collapsed by default, layered only) try - if isfield(parameters, 'debug') && parameters.debug && is_layered + if isfield(parameters.simulation, 'debug') && parameters.simulation.debug && is_layered html_parts{end+1} = collapsible_section('Debug Information', ... build_debug_section(parameters, subject_id, medium, affix), false, 'debug'); end @@ -149,7 +151,7 @@ html_parts{end+1} = section_error('Debug Information', ME); end - % Section 10: Post-hoc Water Simulation (collapsed by default, layered only) + % Section 11: Post-hoc Water Simulation (collapsed by default, layered only) if is_layered try html_parts{end+1} = collapsible_section('Post-hoc Water Simulation', ... @@ -159,7 +161,7 @@ end end - % Section 11: Performance (collapsed by default) + % Section 12: Performance (collapsed by default) try html_parts{end+1} = collapsible_section('Performance', ... build_performance_section(parameters, subject_id, medium, affix), false, 'performance'); @@ -167,7 +169,7 @@ html_parts{end+1} = section_error('Performance', ME); end - % Section 12: Log (collapsed by default) + % Section 13: Log (collapsed by default) try html_parts{end+1} = collapsible_section('Log', ... build_log_section(parameters, subject_id, medium, affix), false, 'log'); @@ -215,9 +217,10 @@ limits = struct(); % MI limits (ITRUSST: MI <= 1.9) - limits.max_MI_brain = struct('label', 'MI (brain)', 'limit', 1.9, 'unit', ''); - limits.max_MI_skull = struct('label', 'MI (skull)', 'limit', 1.9, 'unit', ''); - limits.max_MI_skin = struct('label', 'MI (skin)', 'limit', 1.9, 'unit', ''); + limits.MI_tc = struct('label', 'MI (transcranial)', 'limit', 1.9, 'unit', ''); + limits.MI_brain = struct('label', 'MI (brain)', 'limit', 1.9, 'unit', ''); + limits.MI_skull = struct('label', 'MI (skull)', 'limit', 1.9, 'unit', ''); + limits.MI_skin = struct('label', 'MI (skin)', 'limit', 1.9, 'unit', ''); % Temperature rise limits (ITRUSST: <= 2 C rise) limits.riseT_brain = struct('label', 'Temp rise (brain)', 'limit', 2.0, 'unit', [char(176) 'C']); @@ -235,19 +238,17 @@ limits.maxT_skin = struct('label', 'Max temp (skin)', 'limit', 39.0, 'unit', [char(176) 'C']); % ISPPA (informational, no ITRUSST limit) - limits.max_Isppa_brain = struct('label', 'ISPPA (brain)', 'limit', Inf, 'unit', 'W/cmΒ²'); - limits.max_Isppa_skull = struct('label', 'ISPPA (skull)', 'limit', Inf, 'unit', 'W/cmΒ²'); - limits.max_Isppa_skin = struct('label', 'ISPPA (skin)', 'limit', Inf, 'unit', 'W/cmΒ²'); + limits.Isppa_brain = struct('label', 'ISPPA (brain)', 'limit', Inf, 'unit', 'W/cmΒ²'); + limits.Isppa_skull = struct('label', 'ISPPA (skull)', 'limit', Inf, 'unit', 'W/cmΒ²'); + limits.Isppa_skin = struct('label', 'ISPPA (skin)', 'limit', Inf, 'unit', 'W/cmΒ²'); end function limits = get_safety_limits_water() % Returns global-only safety limits for water/free-field simulations. % Tissue-specific limits are not applicable. limits = struct(); - limits.max_Isppa = struct('label', 'ISPPA (global)', 'limit', Inf, 'unit', 'W/cmΒ²'); - limits.max_pressure_Pa = struct('label', 'Max pressure', 'limit', Inf, 'unit', 'Pa'); - limits.maxT = struct('label', 'Max temperature', 'limit', 39.0, 'unit', [char(176) 'C']); - limits.maxCEM43 = struct('label', 'CEM43 (global)', 'limit', Inf, 'unit', 'min'); + limits.Isppa = struct('label', 'ISPPA (global)', 'limit', Inf, 'unit', 'W/cmΒ²'); + limits.Psptp = struct('label', 'Psptp', 'limit', Inf, 'unit', 'Pa'); end function color = safety_color(value, limit) @@ -291,6 +292,7 @@ % Extract value from CSV value = NaN; + display_unit = info.unit; if ~isempty(csv_table) && ismember(name, csv_table.Properties.VariableNames) val = csv_table.(name); if isnumeric(val) && ~isempty(val) @@ -298,6 +300,11 @@ end end + % Special handling for pressure: use dynamic unit scaling + if strcmp(name, 'Psptp') && ~isnan(value) + [value, display_unit] = scale_pressure(value); + end + % Determine color color = safety_color(value, info.limit); @@ -311,9 +318,9 @@ else html = [html sprintf('
%.3g
', value)]; if isinf(info.limit) - html = [html sprintf('
%s (informational)
', info.unit)]; + html = [html sprintf('
%s (informational)
', display_unit)]; else - html = [html sprintf('
Limit: %.3g %s
', info.limit, info.unit)]; + html = [html sprintf('
Limit: %.3g %s
', info.limit, display_unit)]; % Progress bar showing value as % of limit pct = min(100, max(0, (value / info.limit) * 100)); html = [html sprintf('
', pct)]; @@ -345,7 +352,9 @@ end html = [html '']; html = [html sprintf('', subject_id)]; - if is_layered + if is_layered && isfield(parameters, 'pct') && isfield(parameters.pct, 'enabled') && parameters.pct.enabled + html = [html sprintf('', html_escape(medium))]; + elseif is_layered html = [html sprintf('', html_escape(medium))]; else html = [html sprintf('', html_escape(medium))]; @@ -355,7 +364,7 @@ end html = [html sprintf('', datestr(now, 'yyyy-mm-dd HH:MM:SS'))]; if isfield(parameters, 'output_dir') - html = [html sprintf('', html_escape(parameters.output_dir))]; + html = [html sprintf('', html_escape(parameters.io.output_dir))]; end html = [html '
Subjectsub-%03d
Medium%s Layered (Realistic Head) pseudo-CT (Continuous Skull)
Medium%s Layered (Realistic Head)
Medium%s Water / Free-field
Generated%s
Output dir%s
Output dir%s
']; html = [html '']; @@ -367,20 +376,21 @@ html = [html '

Simulation Summary

']; html = [html '
']; - % Always show: ISPPA at target, focal distance, max ISPPA - html = [html summary_card('ISPPA at target', csv_value(csv_table, 'isppa_at_target'), 'W/cmΒ²')]; + % Always show: IPA at target, focal distance, max ISPPA + html = [html summary_card('Ipa at target', csv_value(csv_table, 'Ipa_target'), 'W/cmΒ²')]; html = [html summary_card('Focal distance', csv_value(csv_table, 'real_focal_distance_mm'), 'mm')]; - html = [html summary_card('Max ISPPA', csv_value(csv_table, 'max_Isppa'), 'W/cmΒ²')]; + html = [html summary_card('ISPPA', csv_value(csv_table, 'Isppa'), 'W/cmΒ²')]; if is_layered - html = [html summary_card('ISPPA brain', csv_value(csv_table, 'max_Isppa_brain'), 'W/cmΒ²')]; - html = [html summary_card('-6dB vol. brain', csv_value(csv_table, 'minus6dB_volume_brain_mm3'), 'mmΒ³')]; + html = [html summary_card('ISPPA brain', csv_value(csv_table, 'Isppa_brain'), 'W/cmΒ²')]; + html = [html summary_card('-6dB vol. brain', csv_value(csv_table, 'halfmax_ISPPA_volume_brain_mm3'), 'mmΒ³')]; else - html = [html summary_card('Max pressure', csv_value(csv_table, 'max_pressure_Pa'), 'Pa')]; + [p_val, p_unit] = scale_pressure(csv_value(csv_table, 'Psptp')); + html = [html summary_card('Psptp', p_val, p_unit)]; end % Thermal summary if available - if isfield(parameters, 'run_heating_sims') && parameters.run_heating_sims + if is_layered && isfield(parameters.modules, 'run_heating_sims') && parameters.modules.run_heating_sims html = [html summary_card('Max temp.', csv_value(csv_table, 'maxT'), [char(176) 'C'])]; end @@ -402,6 +412,23 @@ html = [html '
']; end +function [val, unit] = scale_pressure(val_Pa) +% Auto-scale pressure value to Pa, kPa, or MPa for display. + if isnan(val_Pa) + val = NaN; + unit = 'Pa'; + elseif abs(val_Pa) >= 1e6 + val = val_Pa / 1e6; + unit = 'MPa'; + elseif abs(val_Pa) >= 1e3 + val = val_Pa / 1e3; + unit = 'kPa'; + else + val = val_Pa; + unit = 'Pa'; + end +end + function val = csv_value(csv_table, col_name) % Extract last-row numeric value from CSV table, or NaN if missing. val = NaN; @@ -420,10 +447,10 @@ return end - med = parameters.medium; + med = parameters.simulation.medium; layer_names = fieldnames(parameters.layers); - % === CORRECT FILTER: layers in BOTH parameters.layers AND parameters.medium === + % === CORRECT FILTER: layers in BOTH parameters.layers AND parameters.simulation.medium === valid_tissues = {}; tissue_labels = {}; for i = 1:length(layer_names) @@ -489,19 +516,19 @@ html = ''; % Simulation type - html = config_row(html, 'Simulation medium', safe_field(parameters, 'simulation_medium', 'N/A')); - html = config_row(html, 'Dimensions', sprintf('%dD', safe_field(parameters, 'n_sim_dims', 3))); + html = config_row(html, 'Simulation medium', safe_field(parameters, 'medium', 'N/A')); + html = config_row(html, 'Dimensions', sprintf('%dD', safe_field(parameters.grid, 'n_dims', 3))); % Grid - grid_step = safe_field(parameters, 'grid_step_mm', NaN); + grid_step = safe_field(parameters.grid, 'resolution_mm', NaN); if ~isnan(grid_step) html = config_row(html, 'Grid step', sprintf('%.2f mm', grid_step)); end - grid_dims = safe_field(parameters, 'default_grid_dims', []); + grid_dims = safe_field(parameters.grid, 'default_dims', []); if ~isempty(grid_dims) html = config_row(html, 'Grid dims', sprintf('[%s]', strtrim(sprintf('%g ', grid_dims)))); end - pml = safe_field(parameters, 'pml_size', NaN); + pml = safe_field(parameters.grid, 'pml_size', NaN); if ~isnan(pml) html = config_row(html, 'PML size', sprintf('%d', pml)); end @@ -514,7 +541,7 @@ html = config_row(html, 'Curvature radius', sprintf('%.0f mm', safe_field(td, 'curv_radius_mm', NaN))); html = config_row(html, 'Source amplitude', sprintf('%.1f Pa', safe_field(td, 'source_amp', NaN))); end - focal = safe_field(parameters, 'expected_focal_distance_mm', NaN); + focal = safe_field(parameters, 'expected_focal_distance_bowl', NaN); if ~isnan(focal) html = config_row(html, 'Expected focal distance', sprintf('%.1f mm', focal)); end @@ -562,7 +589,7 @@ found_any = false; html = [html '
']; for t = 1:n_trans - img_path = fullfile(parameters.output_dir, ... + img_path = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_positioning_T%02d%s.png', subject_id, t, affix)); img_html = embed_image(img_path, sprintf('Positioning T%02d', t), ... sprintf('Transducer %d positioning', t)); @@ -593,6 +620,16 @@ avail_cols = intersect(water_cols, csv_table.Properties.VariableNames, 'stable'); if ~isempty(avail_cols) sub_table = csv_table(:, avail_cols); + % Scale pressure values dynamically (Pa -> kPa/MPa based on magnitude) + if ismember('Psptp', sub_table.Properties.VariableNames) + pressure_vals = sub_table{:, 'Psptp'}; + if ~all(isnan(pressure_vals)) + [scaled_vals, display_unit] = scale_pressure(pressure_vals); + sub_table{:, 'Psptp'} = scaled_vals; + % Rename column header to reflect actual unit + sub_table.Properties.VariableNames{'Psptp'} = ['Psptp_' display_unit]; + end + end html = [html table2html(sub_table, struct(), {})]; else html = [html '

No acoustic columns found in CSV.

']; @@ -602,7 +639,7 @@ html = [html '

No acoustic CSV data found.

']; end - % ISPPA images + % Intensity images n_trans = 1; if isfield(parameters, 'transducer') n_trans = numel(parameters.transducer); @@ -610,20 +647,23 @@ html = [html '
']; for t = 1:n_trans - % ISPPA on segmentation - img_path = fullfile(parameters.output_dir, ... - sprintf('sub-%03d_%s_isppa_T%02d%s.png', subject_id, medium, t, affix)); - img_html = embed_image(img_path, sprintf('ISPPA on segmentation T%02d', t), ... - sprintf('ISPPA overlay (segmentation) β€” T%02d', t)); + trans_suffix = ''; + if n_trans > 1; trans_suffix = sprintf('_T%02d', t); end + + % Intensity on segmentation + img_path = fullfile(parameters.io.output_dir, ... + sprintf('sub-%03d_%s_intensity%s%s.png', subject_id, medium, trans_suffix, affix)); + img_html = embed_image(img_path, sprintf('Intensity on segmentation%s', trans_suffix), ... + sprintf('Intensity overlay (segmentation)%s', trans_suffix)); if ~isempty(img_html) html = [html img_html]; end - % ISPPA on T1 - img_path = fullfile(parameters.output_dir, ... - sprintf('sub-%03d_%s_isppa_t1_T%02d%s.png', subject_id, medium, t, affix)); - img_html = embed_image(img_path, sprintf('ISPPA on T1 T%02d', t), ... - sprintf('ISPPA overlay (T1) β€” T%02d', t)); + % Intensity on T1 + img_path = fullfile(parameters.io.output_dir, ... + sprintf('sub-%03d_%s_intensity_t1%s%s.png', subject_id, medium, trans_suffix, affix)); + img_html = embed_image(img_path, sprintf('Intensity on T1%s', trans_suffix), ... + sprintf('Intensity overlay (T1)%s', trans_suffix)); if ~isempty(img_html) html = [html img_html]; end @@ -670,7 +710,7 @@ html = [html '
']; for i = 1:size(thermal_images, 1) - img_path = fullfile(parameters.output_dir, ... + img_path = fullfile(parameters.io.output_dir, ... sprintf(thermal_images{i,1}, subject_id, medium, affix)); img_html = embed_image(img_path, thermal_images{i,2}, thermal_images{i,2}); if ~isempty(img_html) @@ -680,7 +720,7 @@ html = [html '
']; % Note about heating animation - avi_path = fullfile(parameters.output_dir, ... + avi_path = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_%s_heating_animation%s.avi', subject_id, medium, affix)); if isfile(avi_path) html = [html sprintf('

Heating animation available: %s

', ... @@ -694,7 +734,7 @@ debug_dir = ''; if isfield(parameters, 'debug_dir') - debug_dir = parameters.debug_dir; + debug_dir = parameters.io.debug_dir; end if isempty(debug_dir) || ~isfolder(debug_dir) html = '

Debug directory not found.

'; @@ -745,7 +785,7 @@ end function html = build_posthoc_section(parameters) - if isfield(parameters, 'run_posthoc_water_sims') && parameters.run_posthoc_water_sims + if isfield(parameters.modules, 'run_posthoc_water_sims') && parameters.modules.run_posthoc_water_sims html = 'Requested'; else html = 'Not requested'; @@ -811,6 +851,84 @@ html = [html '
' html_escape(log_text) '
']; end +function html = build_pseudoCT_section(parameters) + html = ''; + + % Extract from parameters + debug_dir = parameters.io.output_dir; + affix = ''; + if isfield(parameters.io, 'output_affix') + affix = parameters.io.output_affix; + end + debug_path = fullfile(debug_dir, 'debug'); + + % 1. MAPPING ALGORITHMS TABLE FIRST + html = [html, '
']; + seg_fields = {'density', 'soundspeed', 'attenuation'}; + labels = {'Density', 'Sound speed', 'Attenuation'}; + + for i = 1:3 + if isfield(parameters, 'pct') && isfield(parameters.pct, ['mapping_' seg_fields{i}]) + val = html_escape(char(parameters.pct.(['mapping_' seg_fields{i}]))); + html = [html, sprintf('
%s mapping: %s
', labels{i}, val)]; + end + end + html = [html, '
']; + + % NEW LINE BEFORE IMAGES + html = [html, '
']; + + % 2. pCT histograms + pct_hist_path = fullfile(debug_path, sprintf('pCT_histograms%s.png', affix)); + if isfile(pct_hist_path) + rel_path = sprintf('debug/pCT_histograms%s.png', affix); + html = [html, sprintf('
')]; + html = [html, sprintf('pCT histograms', 1)]; + html = [html, sprintf('
pCT histograms [%s]
', affix)]; + + % NEW LINE AFTER histogram image + html = [html, '
']; + end + + % 3. KPLAN mapping - ONLY when pct_mapping_density == 'k-plan' + lightbox_idx = 2; + if isfield(parameters, 'pct') && isfield(parameters.pct, 'mapping_density') && strcmp(parameters.pct.mapping_density, 'k-plan') + kplan_path = fullfile(debug_path, 'pCT_hounsfield-density_kplan.png'); + if isfile(kplan_path) + html = [html, sprintf('
')]; + html = [html, sprintf('KPLAN mapping', lightbox_idx)]; + html = [html, sprintf('
Hounsfield β†’ Density (KPLAN)
')]; + lightbox_idx = lightbox_idx + 1; + + % NEW LINE AFTER KPLAN image + html = [html, '
']; + end + end + + % 4. k-Wave mapping - ONLY when pct_mapping_density == 'k-wave' + if isfield(parameters, 'pct') && isfield(parameters.pct, 'mapping_density') && strcmp(parameters.pct.mapping_density, 'k-wave') + kwave_path = fullfile(debug_path, 'pCT_hounsfield-density_kwave.png'); + if isfile(kwave_path) + html = [html, sprintf('
')]; + html = [html, sprintf('k-Wave mapping', lightbox_idx)]; + html = [html, sprintf('
Hounsfield β†’ Density (k-Wave)
')]; + + % NEW LINE AFTER k-Wave image + html = [html, '
']; + end + end + + % Fallback if no images + if isempty(strtrim(html)) && ~any(ismember(fields, fieldnames(parameters))) + html = '

No pCT configuration or images found

'; + end + + html = [html, '

pCT: Hounsfield β†’ acoustic properties via mappings

']; +end + %% ======================================================================== % IMAGE ENCODING % ======================================================================== @@ -917,8 +1035,9 @@ % ======================================================================== function cols = get_acoustic_columns() - cols = {'max_MI_brain', 'max_MI_skull', 'max_MI_skin', ... - 'max_Isppa_brain', 'max_Isppa_skull', 'max_Isppa_skin'}; + cols = {'MI_tc', 'MI_brain', 'MI_skull', 'MI_skin', ... + 'Isppa_brain', 'Isppa_skull', 'Isppa_skin', ... + 'Psptp_brain', 'Psptp_skull', 'Psptp_skin', 'Ptp_target'}; end function cols = get_thermal_columns() @@ -932,9 +1051,9 @@ end function cols = get_acoustic_columns_water() - cols = {'subject_id', 'max_Isppa', 'max_Isppa_after_exitplane', ... - 'max_pressure_Pa', 'real_focal_distance_mm', ... - 'isppa_at_target', 'avg_isppa_around_target'}; + cols = {'subject_id', 'freq_Hz', 'Isppa', 'Isppa_after_exitplane', ... + 'Psptp', 'Ptp_target', 'real_focal_distance_mm', ... + 'Ipa_target', 'Ipa_target_radius'}; end function cols = get_thermal_columns_water() @@ -948,12 +1067,12 @@ function log_path = find_log_file(parameters, subject_id, medium, affix) % Find the most recent diary log file. log_path = ''; - if ~isfield(parameters, 'output_dir') || ~isfolder(parameters.output_dir) + if ~isfield(parameters, 'output_dir') || ~isfolder(parameters.io.output_dir) return end pattern = sprintf('sub-%03d_%s%s_*.txt', subject_id, medium, affix); - files = dir(fullfile(parameters.output_dir, pattern)); + files = dir(fullfile(parameters.io.output_dir, pattern)); if isempty(files), return; end [~, idx] = max([files.datenum]); @@ -1028,8 +1147,8 @@ % Safety status dot: determine worst-case color worst = 'green'; try - if isfield(parameters, 'filename_output_table') && isfile(parameters.filename_output_table) - csv_table = readtable(parameters.filename_output_table, 'VariableNamingRule', 'preserve'); + if isfield(parameters.io, 'filename_output_table') && isfile(parameters.io.filename_output_table) + csv_table = readtable(parameters.io.filename_output_table, 'VariableNamingRule', 'preserve'); if is_layered limits = get_safety_limits(); else @@ -1072,14 +1191,19 @@ html = [html 'Medium']; end + % pCT (pCT only) + if isfield(parameters, 'pct') && isfield(parameters.pct, 'enabled') && parameters.pct.enabled + html = [html 'pseudo-CT']; + end + html = [html 'Positioning']; html = [html 'Acoustic']; % Conditional links - if isfield(parameters, 'run_heating_sims') && parameters.run_heating_sims + if isfield(parameters.modules, 'run_heating_sims') && parameters.modules.run_heating_sims html = [html 'Thermal']; end - if isfield(parameters, 'debug') && parameters.debug && is_layered + if isfield(parameters.simulation, 'debug') && parameters.simulation.debug && is_layered html = [html 'Debug']; end if is_layered diff --git a/functions/core/load_parameters.m b/functions/core/load_parameters.m index 382b5b84..a0548916 100644 --- a/functions/core/load_parameters.m +++ b/functions/core/load_parameters.m @@ -2,10 +2,10 @@ % LOAD_PARAMETERS Loads and merges configuration files for simulation parameters. % -% This function loads a default configuration file and optionally merges it with -% additional configuration files or structures provided as input arguments. It -% performs checks on the loaded parameters, calculates derived values, and sanitizes -% the output file affix. The function also verifies paths and transducer settings +% This function loads a default configuration file and optionally merges it with +% additional configuration files or structures provided as input arguments. It +% performs checks on the loaded parameters, calculates derived values, and sanitizes +% the output file affix. The function also verifies paths and transducer settings % required for simulations. % % Input: @@ -27,8 +27,8 @@ if isstruct(extra_config_file) extra_parameters = extra_config_file; % Use provided struct directly else - extra_parameters = yaml.loadFile(fullfile(extra_config_file), "ConvertToArray", true); - end + extra_parameters = yaml.loadFile(fullfile(extra_config_file), "ConvertToArray", true); + end parameters = MergeStruct(parameters, extra_parameters); elseif nargin == 2 extra_config_file = varargin{1}; @@ -51,13 +51,13 @@ %% Check interactive mode requirements - assert(parameters.interactive == 0 || usejava('desktop'), ... - 'MATLAB should run in desktop mode if parameters.interactive is enabled in PRESTUS config'); + assert(parameters.simulation.interactive == 0 || usejava('desktop'), ... + 'MATLAB should run in desktop mode if parameters.simulation.interactive is enabled in PRESTUS config'); %% Transducer settings validation and derived calculations for t_i = 1:numel(parameters.transducer) - + % Ensure source phase is set in radians or degrees if ~isfield(parameters.transducer(t_i), 'source_phase_rad') assert(isfield(parameters.transducer(t_i), 'source_phase_deg'), ... @@ -73,6 +73,11 @@ parameters.transducer(t_i).dist_to_plane_mm); end + % Calculate distance between target and ep/bowl is not provided + if ~isfield(parameters, 'expected_focal_distance_bowl') || ~isfield(parameters, 'expected_focal_distance_ep') + parameters = focal_distance_calculation(parameters); + end + % Ensure source amplitude matches number of transducer elements if length(parameters.transducer(t_i).source_amp) == 1 && parameters.transducer(t_i).n_elements > 1 parameters.transducer(t_i).source_amp = repmat(parameters.transducer(t_i).source_amp, [1, parameters.transducer(t_i).n_elements]); @@ -95,90 +100,75 @@ end - %% Derived grid settings - - % Set simulation dimensions based on default grid dimensions or fallback to 3D - if ~isfield(parameters, 'n_sim_dims') - if isfield(parameters, 'default_grid_dims') - parameters.n_sim_dims = length(parameters.default_grid_dims); - else - parameters.n_sim_dims = 3; - end - end - %% Validate thermal simulation settings % Request no timing overview here - if parameters.run_heating_sims + if parameters.modules.run_heating_sims thermal_parameters(parameters, true); end %% Output file settings validation and sanitization - + % Sanitize output file affix to ensure valid characters only - sanitized_affix = regexprep(parameters.results_filename_affix, '[^a-zA-Z0-9_]', '_'); - if ~strcmp(sanitized_affix, parameters.results_filename_affix) + sanitized_affix = regexprep(parameters.io.output_affix, '[^a-zA-Z0-9_]', '_'); + if ~strcmp(sanitized_affix, parameters.io.output_affix) fprintf('The original `results_filename_affix` was sanitized. "%s" will be used instead of "%s"\n', ... - sanitized_affix, parameters.results_filename_affix); - parameters.results_filename_affix = sanitized_affix; + sanitized_affix, parameters.io.output_affix); + parameters.io.output_affix = sanitized_affix; end % Set output directory based on absolute or relative path - if isfield(parameters, 'output_location') - javaFileObj = java.io.File(parameters.output_location); % Check path type (absolute/relative) + if isfield(parameters.path, 'sim') && ~strcmp(parameters.path.sim, '') + javaFileObj = java.io.File(parameters.path.sim); % Check path type (absolute/relative) if javaFileObj.isAbsolute() - parameters.sim_path = fullfile(parameters.output_location); + parameters.path.sim = fullfile(parameters.path.sim); else - parameters.sim_path = fullfile(parameters.data_path, parameters.output_location); + parameters.path.sim = fullfile(parameters.path.anat, parameters.path.sim); end else % Default output directory within data path - parameters.sim_path = fullfile(parameters.data_path, 'sim_outputs/'); + parameters.path.sim = fullfile(parameters.path.anat, 'tussim'); end - + %% Validate paths for required libraries and binaries - + % Check LD_LIBRARY_PATH existence and warn user if missing - if isfield(parameters, 'ld_library_path') && ~strcmp(parameters.ld_library_path, "") && ~exist(parameters.ld_library_path, 'dir') - assert(all(confirmation_dlg('The path `ld_library_path` has been specified but does not exist. Do you want to continue?', ... + if isfield(parameters, 'hpc') && isfield(parameters.hpc, 'ld_library_path') && ... + ~strcmp(parameters.hpc.ld_library_path, "") && ~exist(parameters.hpc.ld_library_path, 'dir') + assert(all(confirmation_dlg('The path `hpc.ld_library_path` has been specified but does not exist. Do you want to continue?', ... 'Yes', 'No')), 'Exiting'); end % Check segmentation software path existence and warn user if missing - if isfield(parameters, 'simnibs_bin_path') && ~strcmp(parameters.simnibs_bin_path, "") && ~exist(fullfile(parameters.simnibs_bin_path, parameters.segmentation_software), 'file') - assert(all(confirmation_dlg(sprintf('The segmentation software (%s) does not exist at %s. Do you want to continue?', ... - parameters.segmentation_software, parameters.simnibs_bin_path), ... + if isfield(parameters.startup, 'simnibs_bin_path') && ~strcmp(parameters.startup.simnibs_bin_path, "") && ... + ~exist(fullfile(parameters.startup.simnibs_bin_path, 'charm'), 'file') + assert(all(confirmation_dlg(sprintf('charm does not exist at %s. Do you want to continue?', ... + parameters.startup.simnibs_bin_path), ... 'Yes', 'No')), 'Exiting'); - elseif (~isfield(parameters, 'simnibs_bin_path') || strcmp(parameters.simnibs_bin_path, "")) && contains(parameters.simulation_medium, {'layered'}) + elseif (~isfield(parameters.startup, 'simnibs_bin_path') || strcmp(parameters.startup.simnibs_bin_path, "")) && contains(parameters.simulation.medium, {'layered'}) warning('No path to SimNIBS binaries provided. Segmentation and MNI-conversion may fail...'); end %% Default segmentation path fallback - if ~isfield(parameters, 'seg_path') || isempty(parameters.seg_path) - parameters.seg_path = parameters.data_path; + if ~isfield(parameters.path, 'seg') || isempty(parameters.path.seg) || strcmp(parameters.path.seg, '') + parameters.path.seg = parameters.path.anat; end - %% Default: deactivate pseudoCT unless specified - - if ~isfield(parameters, 'usepseudoCT') - parameters.usepseudoCT = 0; - end - %% Convert additional paths into cell arrays for processing - + % Convert `paths_to_add` into cell array format (split by semicolon) - if isfield(parameters, 'paths_to_add') && ~isempty(parameters.paths_to_add) - parameters.paths_to_add = cellstr(strsplit(parameters.paths_to_add, ';')); + if isfield(parameters.startup, 'paths_to_add') && ~isempty(parameters.startup.paths_to_add) + parameters.startup.paths_to_add = cellstr(strsplit(parameters.startup.paths_to_add, ';')); end - + % Convert `subpaths_to_add` into cell array format (split by semicolon) - if isfield(parameters, 'subpaths_to_add') && ~isempty(parameters.subpaths_to_add) - parameters.subpaths_to_add = cellstr(strsplit(parameters.subpaths_to_add, ';')); + if isfield(parameters.startup, 'subpaths_to_add') && ~isempty(parameters.startup.subpaths_to_add) + parameters.startup.subpaths_to_add = cellstr(strsplit(parameters.startup.subpaths_to_add, ';')); end - + %% MATLAB version check for compatibility - + % Warn user about outdated MATLAB versions (< R2022b) if verLessThan('matlab', '9.13') assert(all(confirmation_dlg('MATLAB appears to be outdated. Please update before continuing. Do you want to continue?', ... @@ -189,7 +179,7 @@ % With debugging off, the parameters are saved to the log at the % start of pipeline execution (see path_log_setup). - if parameters.debug == 1 + if parameters.simulation.debug == 1 fprintf('PRELIMINARY specified parameters. Note that this may not be the final specification...\n'); print_parameter_summary(parameters) end diff --git a/functions/core/path_log_setup.m b/functions/core/path_log_setup.m index 7c931b30..8583fd44 100644 --- a/functions/core/path_log_setup.m +++ b/functions/core/path_log_setup.m @@ -1,4 +1,4 @@ -function [parameters] = path_log_setup(parameters, prestus_path, subject_id) +function [parameters] = path_log_setup(parameters, prestus_path) % currentLoc | Root of PRESTUS' 'functions' folder @@ -24,18 +24,18 @@ end % If there are paths to be added, add them; this is mostly for batch runs - if isfield(parameters,'paths_to_add') && ~isempty(parameters.paths_to_add) - for nPaths = 1:length(parameters.paths_to_add) - addpath(parameters.paths_to_add{nPaths}) - disp(['Adding ', parameters.paths_to_add{nPaths}]); + if isfield(parameters.startup, 'paths_to_add') && ~isempty(parameters.startup.paths_to_add) + for nPaths = 1:length(parameters.startup.paths_to_add) + addpath(parameters.startup.paths_to_add{nPaths}) + disp(['Adding ', parameters.startup.paths_to_add{nPaths}]); end end % If the path and subpaths need to be added, use this instead - if isfield(parameters,'subpaths_to_add') && ~isempty(parameters.subpaths_to_add) - for nPaths = 1:length(parameters.subpaths_to_add) - addpath(genpath(parameters.subpaths_to_add{nPaths})) - disp(['Adding ', parameters.subpaths_to_add{nPaths}, 'and subfolders']); + if isfield(parameters.startup, 'subpaths_to_add') && ~isempty(parameters.startup.subpaths_to_add) + for nPaths = 1:length(parameters.startup.subpaths_to_add) + addpath(genpath(parameters.startup.subpaths_to_add{nPaths})) + disp(['Adding ', parameters.startup.subpaths_to_add{nPaths}, 'and subfolders']); end end @@ -52,38 +52,52 @@ % return to PRESTUS path cd(prestus_path); - % Make subfolder (if enabled) and check if directory exists - if isfield(parameters,'subject_subfolder') && parameters.subject_subfolder == 1 - parameters.output_dir = fullfile(parameters.sim_path, sprintf('sub-%03d', subject_id)); - else - parameters.output_dir = parameters.sim_path; + subject_id = parameters.subject_id; + + % [SIMULATION OUTPUT] Make subfolder (if enabled) and check if directory exists + if isfield(parameters.path, 'subject_subfolder') && parameters.path.subject_subfolder == 1 + parameters.io.output_dir = fullfile(parameters.path.sim, sprintf('sub-%03d', subject_id)); + if ~exist(parameters.io.output_dir); mkdir(parameters.io.output_dir); end; + else + parameters.io.output_dir = parameters.path.sim; + end + + % [LOCALITE OUTPUT] Make subfolder (if enabled) and check if directory exists + if (isfield(parameters.path, 'localite') && ~isempty(parameters.path.localite)) && ... + isfield(parameters.path, 'subject_subfolder') && parameters.path.subject_subfolder == 1 + parameters.path.localite = fullfile(parameters.path.localite, sprintf('sub-%03d', subject_id)); + if ~exist(parameters.path.localite); mkdir(parameters.path.localite); end; + else + if ~isfield(parameters.path, 'localite') || isempty(parameters.path.localite) + parameters.path.localite = parameters.io.output_dir; + end end % specify dedicated subfolder for debugging contents - parameters.debug_dir = fullfile(parameters.output_dir, 'debug'); + parameters.io.debug_dir = fullfile(parameters.io.output_dir, 'debug'); - if ~isfolder(parameters.output_dir) - mkdir(parameters.output_dir); + if ~isfolder(parameters.io.output_dir) + mkdir(parameters.io.output_dir); end - if ~isfolder(parameters.debug_dir) - mkdir(parameters.debug_dir); + if ~isfolder(parameters.io.debug_dir) + mkdir(parameters.io.debug_dir); end - if isfield(parameters,'seg_path') && ~isfolder(parameters.seg_path) - mkdir(parameters.seg_path); + if isfield(parameters.path, 'seg') && ~isfolder(parameters.path.seg) + mkdir(parameters.path.seg); end % Save parameters - filename_parameters = fullfile(parameters.output_dir, ... + filename_parameters = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_parameters_%s%s_%s.mat', ... - subject_id, parameters.simulation_medium, parameters.results_filename_affix, ... + subject_id, parameters.simulation.medium, parameters.io.output_affix, ... string(datetime('now'), 'yyMMdd_HHmm'))); save(filename_parameters, 'parameters'); clear filename_parameters; % Create a log - filename_log = fullfile(parameters.output_dir, ... + filename_log = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_%s%s_%s.txt', ... - subject_id, parameters.simulation_medium, parameters.results_filename_affix, ... + subject_id, parameters.simulation.medium, parameters.io.output_affix, ... string(datetime('now'), 'yyMMdd_HHmm'))); diary(filename_log); @@ -91,18 +105,15 @@ print_parameter_summary(parameters) % Define the filename of the summary table - parameters.filename_output_table = ... - fullfile(parameters.output_dir,sprintf('sub-%03d_%s_output_table%s.csv', ... - subject_id, parameters.simulation_medium, parameters.results_filename_affix)); - - % Add subject_id to parameters - parameters.subject_id = subject_id; - + parameters.io.filename_output_table = ... + fullfile(parameters.io.output_dir,sprintf('sub-%03d_%s_output_table%s.csv', ... + subject_id, parameters.simulation.medium, parameters.io.output_affix)); + % suppress unneccessary warnings from export_fig when running without OpenGL warning('off','MATLAB:prnRenderer:opengl'); % display GPU information (if requested) - if strcmp(parameters.code_type, 'cpp_gpu') || strcmp(parameters.code_type, 'matlab_gpu') + if strcmp(parameters.simulation.code_type, 'cpp_gpu') || strcmp(parameters.simulation.code_type, 'matlab_gpu') fprintf('========================================\n'); fprintf('GPU INFO \n'); fprintf('========================================\n\n'); @@ -111,4 +122,4 @@ end % set initial time, RAM, GB state - log_timer('start','single_subject_pipeline', parameters.output_dir); \ No newline at end of file + log_timer('start','prestus_pipeline', parameters.io.output_dir); \ No newline at end of file diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m new file mode 100644 index 00000000..0db5bcf0 --- /dev/null +++ b/functions/core/prestus_pipeline_start.m @@ -0,0 +1,96 @@ +function prestus_pipeline_start(parameters, options) +%% PRESTUS_PIPELINE_START Universal PRESTUS pipeline launcher +% +% prestus_pipeline_start(parameters) +% prestus_pipeline_start(parameters, options) +% +% Auto-detects platform and handles direct MATLAB, SLURM, or qsub execution. +% Subject ID must be set as parameters.subject_id before calling. +% +% Inputs: +% parameters - Struct; must contain parameters.subject_id +% options - Struct with sequential_configs (default: empty) + + arguments + parameters struct + options struct = struct() + end + + if ~isfield(parameters, 'subject_id') + error('parameters.subject_id must be set before calling prestus_pipeline_start.'); + end + subject_id = parameters.subject_id; + + % Ensure helper functions are accessible + helpers_path = fileparts(mfilename('fullpath')); + if ~contains(path, helpers_path) + addpath(helpers_path); + end + + % ========== PLATFORM SELECTION ========== + if ~isfield(parameters, 'hpc') || ~isfield(parameters, 'platform') || strcmp(parameters.platform, 'auto') + platform = hpc_detect_system(); + parameters.platform = platform; + fprintf('➀ auto-detected: %s\n', upper(platform)); + else + platform = parameters.platform; + fprintf('➀ deploying: %s\n', upper(platform)); + end + + % ========== DISPATCH EXECUTION ========== + switch parameters.platform + case 'matlab' + fprintf('πŸ–₯️ Running in MATLAB\n\n'); + prestus_pipeline(parameters, options); + + case {'slurm', 'qsub'} + % ========== HPC EXECUTION ========== + hpc_validate_parameters(parameters, platform); + [log_dir, prestus_path, temp_data_path, temp_m_path, temp_m_file] = ... + hpc_setup_temp_files(parameters); + + % Create job files + save(temp_data_path, 'parameters'); + + % Generate MATLAB call + fid = fopen(temp_m_path, 'w+'); + fprintf(fid, 'load(''%s'');\n', temp_data_path); + fprintf(fid, 'addpath(genpath(''%s''));\n', prestus_path); + if ismember(fieldnames(options), 'sequential_configs') + sequential_configs = options.sequential_configs; + save(temp_data_path, 'sequential_configs', '-append'); + fprintf(fid, 'prestus_pipeline(parameters, options);\n'); + else + fprintf(fid, 'prestus_pipeline(parameters);\n'); + end + fprintf(fid, 'delete(''%s'');\n', temp_data_path); + fprintf(fid, 'delete(''%s'');\n', temp_m_path); + fclose(fid); + + % Job name + job_name = hpc_job_name(parameters); + + % Submit job + job_id = hpc_submit_job(platform, temp_m_file, parameters, log_dir); + + % Display job info + job_info = hpc_job_info(platform, job_id, job_name, ... + parameters.hpc.memorylimit, parameters.hpc.timelimit, log_dir, true); + + % Optional wait + if isfield(parameters.hpc, 'wait_for_job') && parameters.hpc.wait_for_job + fprintf('⏳ Waiting for job completion...\n'); + fprintf('═══════════════════════════════\n'); + hpc_wait_for_completion(job_id, platform, parameters.hpc.max_wait_checks); + else + fprintf('➑️ Continuing in MATLAB ...\n\n'); + end + + % Save job ID for chaining + parameters.hpc.job_id = job_id; + + otherwise + error('Unknown platform: %s. Use ''matlab'', ''slurm'', ''qsub'', or ''auto''.', ... + parameters.platform); + end +end \ No newline at end of file diff --git a/functions/core/print_parameter_summary.m b/functions/core/print_parameter_summary.m index 11de4f5e..6cd005d3 100644 --- a/functions/core/print_parameter_summary.m +++ b/functions/core/print_parameter_summary.m @@ -18,44 +18,50 @@ function print_parameter_summary(parameters) %% 1. I/O Management fprintf('πŸ“ I/O MANAGEMENT\n'); -print_if_field(parameters, 'data_path', '%s'); -print_if_field(parameters, 'seg_path', '%s'); -print_if_field(parameters, 'sim_path', '%s'); -print_if_field(parameters, 'simnibs_bin_path', '%s'); -print_if_field(parameters, 'results_filename_affix', '%s'); -print_overwrite_pair(parameters); +print_if_field(parameters.io, 'data_path', '%s'); +print_if_field(parameters.io, 'seg_path', '%s'); +print_if_field(parameters.io, 'sim_path', '%s'); +print_if_field(parameters.io, 'simnibs_bin_path', '%s'); +print_if_field(parameters.io, 'results_filename_affix', '%s'); +print_overwrite_pair(parameters.io); fprintf('\n'); %% 2. Simulation Type fprintf('βš™οΈ SIMULATION TYPE\n'); -print_if_field(parameters, 'simulation_medium', '%s'); -print_if_field(parameters, 'n_sim_dims', '%dD'); -print_flag(parameters, 'axisymmetric'); -print_modules(parameters); +print_if_field(parameters.simulation, 'medium', '%s'); +print_if_field(parameters.grid, 'n_dims', '%dD'); +print_flag(parameters.grid, 'axisymmetric'); +print_modules(parameters.modules); fprintf('\n'); %% 3. Simulation Grid fprintf('πŸ“ SIMULATION GRID\n'); -print_if_field(parameters, 'grid_step_mm', '%.2f mm'); -print_if_field(parameters, 'default_grid_dims', '%s'); -print_if_field(parameters, 'pml_size', '%d'); -print_if_field(parameters, 'grid_max_expand', '%.1f'); +grid = get_struct_or_default(parameters, 'grid'); +print_if_field(grid, 'resolution_mm', '%.2f mm'); +print_if_field(grid, 'default_dims', '%s'); +print_if_field(grid, 'pml_size', '%d'); +print_if_field(grid, 'max_expand', '%.1f'); print_if_field(parameters, 'precision', '%s'); fprintf('\n'); %% 4. Transducer Specification fprintf('🎯 TRANSDUCER SPECIFICATION\n'); -td = get_struct_or_default(parameters, 'transducer'); +if isfield(parameters, 'transducer') && ~isempty(parameters.transducer) + td = parameters.transducer(1); +else + td = struct(); +end print_if_field(td, 'source_freq_hz', '%.1f Hz'); print_if_field(td, 'n_elements', '%d'); print_if_field(td, 'curv_radius_mm', '%.0f mm'); print_if_field(td, 'source_amp', '%.1f Pa'); print_if_field(td, 'trans_pos', '[%.1f %.1f %.1f]'); print_if_field(td, 'focus_pos', '[%.1f %.1f %.1f]'); -print_if_field(parameters, 'expected_focal_distance_mm', '%.1f'); +print_if_field(td, 'expected_focal_distance_ep', '%.1f mm'); +print_if_field(td, 'expected_focal_distance_bowl', '%.1f mm'); fprintf('\n'); %% 5. Medium Properties (requested layers only) @@ -81,7 +87,7 @@ function print_parameter_summary(parameters) % Handle 'tissues' wildcard β†’ all tissue props if strcmp(tissue, 'tissues') - tissue_fields = fieldnames(parameters.medium); + tissue_fields = fieldnames(parameters.medium_properties); tissue_list = tissue_fields(startsWith(tissue_fields, 'tissue_') | ... startsWith(tissue_fields, 'brain') | ... startsWith(tissue_fields, 'csf')); @@ -97,23 +103,25 @@ function print_parameter_summary(parameters) %% 6. Thermal Sequence -if isfield(parameters, 'thermal') && parameters.run_heating_sims == 1 +if isfield(parameters, 'thermal') && parameters.modules.run_heating_sims == 1 fprintf('πŸ”₯ THERMAL SEQUENCE\n'); - th = parameters.thermal; - print_if_field(th, 'pd', '%.3fs', 'Pulse Duration'); - print_if_field(th, 'pri', '%.3fs', 'Pulse Repetition Interval'); - print_if_field(th, 'ptd', '%.3fs', 'Pulse Train Duration'); - print_if_field(th, 'ptri', '%.3fs', 'Pulse Train Repetition Interval'); - print_if_field(th, 'ptrd', '%.3fs', 'Pulse Train Repetition Duration'); - print_if_field(th, 'post_ptri_dur', '%.3fs', 'Steady-State Duration'); + tm = get_struct_or_default(parameters, 'timing'); + print_if_field(tm, 'pd', '%.3fs', 'Pulse Duration'); + print_if_field(tm, 'pri', '%.3fs', 'Pulse Repetition Interval'); + print_if_field(tm, 'ptd', '%.3fs', 'Pulse Train Duration'); + print_if_field(tm, 'ptri', '%.3fs', 'Pulse Train Repetition Interval'); + print_if_field(tm, 'ptrd', '%.3fs', 'Pulse Train Repetition Duration'); + print_if_field(tm, 'post_ptri_dur', '%.3fs', 'Steady-State Duration'); fprintf('\n'); end %% 7. HPC/GPU fprintf('πŸ’» HPC/GPU OPTIONS\n'); +print_if_field(parameters, 'platform', '%s'); print_if_field(parameters, 'code_type', '%s'); -print_if_field(parameters, 'hpc_partition', '%s'); +hpc = get_struct_or_default(parameters, 'hpc'); +print_if_field(hpc, 'partition', '%s'); fprintf('\n'); fprintf('========================================\n'); @@ -134,7 +142,7 @@ function print_if_field(s, field, fmt, alternate_name) val = s.(field); % SPECIAL HANDLING: Vector dimensions - if strcmp(field, 'default_grid_dims') || strcmp(field, 'grid_dims') + if strcmp(field, 'default_dims') || strcmp(field, 'grid_dims') if ~isempty(val) && all(isfinite(val(:))) && ~any(isnan(val(:))) if length(val) <= 10 grid_str = sprintf('%g ', val); @@ -222,17 +230,17 @@ function print_flag(s, field) end function print_modules(s) - modules = { + mod_list = { 'run_source_setup', 'Source'; - 'run_acoustic_sims', 'Acoustic'; + 'run_acoustic_sims', 'Acoustic'; 'run_heating_sims', 'Thermal'; 'run_posthoc_water_sims', 'Post-water' }; - + fprintf('\nπŸ”§ MODULES\n'); - for i = 1:size(modules, 1) - mod_field = modules{i, 1}; - mod_label = modules{i, 2}; + for i = 1:size(mod_list, 1) + mod_field = mod_list{i, 1}; + mod_label = mod_list{i, 2}; status = getfield_or_default(s, mod_field, 0); if status mark = ' βœ“'; diff --git a/functions/core/simulation_nifti.m b/functions/core/simulation_nifti.m index 94b7db78..fa395bf9 100644 --- a/functions/core/simulation_nifti.m +++ b/functions/core/simulation_nifti.m @@ -1,4 +1,4 @@ -function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, acoustic_MI, acoustic_pressure, ... +function simulation_nifti(parameters, planimg, results_acoustic, acoustic_Ipa, acoustic_MI, acoustic_pressure, ... medium_masks, results_heating, kwave_medium, highlighted_pos) % SIMULATION_NIFTI - Export k-Wave simulation results to NIfTI (native + MNI space). @@ -10,7 +10,7 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, % - parameters (struct) - Simulation config: output_dir, simulation_medium, % results_filename_affix, run_heating_sims, etc. % - planimg (struct) - SimNibs planning: t1_image_orig, inv_transf, t1_header -% - acoustic_isppa (array) - Peak spatial-average intensity [W/cmΒ²], from acoustic_analysis +% - acoustic_Ipa (array) - Peak spatial-average intensity [W/cmΒ²], from acoustic_analysis % - acoustic_MI (array) - Mechanical Index grid % - acoustic_pressure (array) - Peak pressure [Pa] % - medium_masks (array) - Layer label mask (uint8) @@ -18,25 +18,25 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, % - results_heating.CEM43 (array) - Cumulative Equivalent Minutes at 43Β°C % - kwave_medium (struct) - k-Wave medium (for temp_0) - if contains(parameters.simulation_medium, {'layered'; 'phantom'}) + if contains(parameters.simulation.medium, {'layered'; 'phantom'}) data_types = "medium_masks"; - if parameters.acoustics_available == 1 - data_types = [data_types, "isppa","MI","pressure"]; + if parameters.state.acoustics_available == 1 + data_types = [data_types, "intensity","MI","pressure"]; end - if parameters.heating_available == 1 + if parameters.state.heating_available == 1 data_types = [data_types, "heating", "heating_end", "heatrise", "heatrise_end", "CEM43", "CEM43_end"]; end for data_type = data_types - orig_file = fullfile(parameters.output_dir, sprintf('sub-%03d_final_%s_orig_coord%s',... - parameters.subject_id, data_type, parameters.results_filename_affix)); - mni_file = fullfile(parameters.output_dir, sprintf('sub-%03d_final_%s_MNI%s.nii.gz',... - parameters.subject_id, data_type, parameters.results_filename_affix)); + orig_file = fullfile(parameters.io.output_dir, sprintf('sub-%03d_final_%s_orig_coord%s',... + parameters.subject_id, data_type, parameters.io.output_affix)); + mni_file = fullfile(parameters.io.output_dir, sprintf('sub-%03d_final_%s_MNI%s.nii.gz',... + parameters.subject_id, data_type, parameters.io.output_affix)); if strcmp(data_type, "medium_masks") data = medium_masks; - elseif strcmp(data_type, "isppa") - data = single(acoustic_isppa); + elseif strcmp(data_type, "intensity") + data = single(acoustic_Ipa); elseif strcmp(data_type, "MI") data = single(acoustic_MI); elseif strcmp(data_type, "pressure") @@ -57,7 +57,7 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, orig_file_with_ext = strcat(orig_file, '.nii.gz'); if confirm_overwriting(orig_file_with_ext, parameters) - if ~strcmp(parameters.simulation_medium, 'phantom') + if ~strcmp(parameters.simulation.medium, 'phantom') % Transforms the data to original T1 image dimensions and orientation orig_hdr = planimg.t1_header; @@ -109,18 +109,18 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, data_backtransf = niftiread(orig_file_with_ext); end - if strcmp(data_type, "isppa") && ~strcmp(parameters.simulation_medium, 'phantom') + if strcmp(data_type, "intensity") && ~strcmp(parameters.simulation.medium, 'phantom') max_plots = min(2, numel(parameters.transducer)); if numel(parameters.transducer) > max_plots - warning('More than two transducers: ISPPA-over-T1 plots will be created only for the first 2 transducers'); + warning('More than two transducers: intensity-over-T1 plots will be created only for the first 2 transducers'); end % define the maximum value to plot - if isfield(results_acoustic, 'max_Isppa_brain') && ~isempty(results_acoustic.max_Isppa_brain) && ~isnan(results_acoustic.max_Isppa_brain) - max_val = results_acoustic.max_Isppa_brain; + if isfield(results_acoustic, 'Isppa_brain') && ~isempty(results_acoustic.Isppa_brain) && ~isnan(results_acoustic.Isppa_brain) + max_val = results_acoustic.Isppa_brain; else - max_val = results_acoustic.max_Isppa; + max_val = results_acoustic.Isppa; end for ti = 1:max_plots @@ -140,7 +140,7 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, size(planimg.t1_image_orig), ... planimg.t1_header.PixelDimensions(1)); - % Plots the Isppa over the untransformed image + % Plots the intensity over the untransformed image [~,~,~,~,~,~,~,h]=plot_overlay(... data_backtransf, ... planimg.t1_image_orig, ... @@ -157,36 +157,30 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, 'overlay_threshold_high', max_val, ... 'rotation', 0); % rotation = 90 not implemented for transducer overlay - output_plot_filename = fullfile(parameters.output_dir, ... - sprintf('sub-%03d_%s_isppa_t1_T%02d%s.png', ... - parameters.subject_id, parameters.simulation_medium, ti, ... - parameters.results_filename_affix)); + trans_suffix = ''; + if max_plots > 1; trans_suffix = sprintf('_T%02d', ti); end + output_plot_filename = fullfile(parameters.io.output_dir, ... + sprintf('sub-%03d_%s_intensity_t1%s%s.png', ... + parameters.subject_id, parameters.simulation.medium, trans_suffix, ... + parameters.io.output_affix)); saveas(h, output_plot_filename, 'png') close(h); end end - m2m_folder= fullfile(parameters.seg_path, sprintf('m2m_sub-%03d', parameters.subject_id)); + m2m_folder= fullfile(parameters.path.seg, sprintf('m2m_sub-%03d', parameters.subject_id)); % transform outputs to MNI space (using SimNibs or applying transformation matrix) - if ~confirm_overwriting(mni_file, parameters) || strcmp(parameters.simulation_medium, 'phantom') + if ~confirm_overwriting(mni_file, parameters) || strcmp(parameters.simulation.medium, 'phantom') continue end - if strcmp(parameters.segmentation_software, 'headreco') - if strcmp(data_type, "medium_masks") - convert_final_to_MNI_matlab(data, m2m_folder, planimg.inv_transf, parameters, 'nifti_filename', mni_file, 'nifti_data_type', 'uint8', 'BitsPerPixel', 8); - else - convert_final_to_MNI_matlab(data, m2m_folder, planimg.inv_transf, parameters, 'nifti_filename', mni_file); - end - elseif strcmp(parameters.segmentation_software, 'charm') - convert_final_to_MNI_simnibs(orig_file_with_ext , m2m_folder, mni_file, parameters, 'interpolation_order', 0); - end + convert_final_to_MNI_simnibs(orig_file_with_ext , m2m_folder, mni_file, parameters, 'interpolation_order', 0); clear data; end % Since charm does not transform the T1 into MNI space, one is manually created here - if strcmp(parameters.segmentation_software, 'charm') && ~strcmp(parameters.simulation_medium, 'phantom') + if ~strcmp(parameters.simulation.medium, 'phantom') path_to_input_img = fullfile(m2m_folder,'T1.nii.gz'); path_to_output_img = fullfile(m2m_folder,'toMNI','T1_to_MNI_post-hoc.nii.gz'); diff --git a/functions/group/combine_plots_by_suffix.m b/functions/group/combine_plots_by_suffix.m index 13f907be..71b37776 100644 --- a/functions/group/combine_plots_by_suffix.m +++ b/functions/group/combine_plots_by_suffix.m @@ -35,7 +35,7 @@ function combine_plots_by_suffix(suffix, outputs_path, subject_list, parameters, % Loop through each subject in the subject list for subject_i = 1:length(subject_list) % Determine the path to the subject's output directory - if parameters.subject_subfolder + if parameters.path.subject_subfolder sub_path = fullfile(outputs_path, sprintf('sub-%03i', subject_list(subject_i))); else sub_path = fullfile(outputs_path); diff --git a/functions/group/create_group_MNI_plots.m b/functions/group/create_group_MNI_plots.m index c8c84dca..a26522a8 100644 --- a/functions/group/create_group_MNI_plots.m +++ b/functions/group/create_group_MNI_plots.m @@ -10,8 +10,8 @@ function create_group_MNI_plots(subject_list, parameters, options) % masking and statistical operations (e.g. FWHM, ROI overlap) are applied. % % Supported features: -% - Wildcard support in filenames using `parameters.results_filename_affix` -% - Optional use of subfolders per subject (`parameters.subject_subfolder`) +% - Wildcard support in filenames using `parameters.io.output_affix` +% - Optional use of subfolders per subject (`parameters.path.subject_subfolder`) % - Heatmap plotting, FWHM mask logic, ROI overlays and statistics % % INPUTS @@ -21,11 +21,10 @@ function create_group_MNI_plots(subject_list, parameters, options) % - temp_output_dir [string] : Path to output folder for subject images % - layers.brain [array] : Label values of brain tissue (for binary mask) % - layers.water [array] : Label values of water tissue (mask heating) -% - segmentation_software [string] : 'headreco' or 'simnibs' (for T1 path) -% - results_filename_affix [string/wildcard]: e.g., '_ses-*'. +% - results_filename_affix [string/wildcard]: e.g., '_ses-*'. % - simulation_medium [string] : Used in table filenames % - subject_subfolder [bool, optional] : If files are in subfolders like sub-001/sub-001_... -% - seg_path / data_path [string] : Path to segmentation (headreco) data +% - seg_path / data_path [string] : Path to SimNIBS m2m segmentation data % - thermal.temp_0 [scalar or struct] : Baseline temp, or struct with .skin and .water % % options : struct with additional parameters, including: @@ -36,7 +35,7 @@ function create_group_MNI_plots(subject_list, parameters, options) % - rotation (deg) : Rotation of image for display % - plot_heating (bool) : Whether to include heating map plots % - outputs_suffix (string) : Suffix for output files -% - isppa_thresholds ([low high]) : Manual ISPPA colorbar range +% - intensity_thresholds ([low high]) : Manual ISPPA colorbar range % - add_FWHM_boundary (bool) : Outline region with ISPPA > half max % - add_ROI_boundary (bool) : Overlap and ROI stats/outline % - skip_missing (bool) : If true, skip subjects @@ -61,7 +60,7 @@ function create_group_MNI_plots(subject_list, parameters, options) options.rotation = 90; options.plot_heating = 1 options.outputs_suffix = '' - options.isppa_thresholds = [] + options.intensity_thresholds = [] options.add_FWHM_boundary = 0 options.add_ROI_boundary = 1 options.skip_missing = 0 @@ -75,7 +74,7 @@ function create_group_MNI_plots(subject_list, parameters, options) outputs_path = parameters.temp_output_dir; bg_range_to_use = []; -isppa_range_to_use = [5, 6]; +intensity_range_to_use = [5, 6]; if isstruct(parameters.thermal.temp_0) temp_range_to_use = [parameters.thermal.temp_0.skin, parameters.thermal.temp_0.water + 0.5]; temp_0_min = parameters.thermal.temp_0.skin; @@ -91,7 +90,7 @@ function create_group_MNI_plots(subject_list, parameters, options) subject_id = full_subject_list(subject_i); % -- Setup subject-specific directory and prefix logic -- - if isfield(parameters, 'subject_subfolder') && parameters.subject_subfolder == 1 + if isfield(parameters, 'io') && isfield(parameters.path, 'subject_subfolder') && parameters.path.subject_subfolder == 1 subject_dir = sprintf('sub-%03d', subject_id); file_base = sprintf('sub-%03d', subject_id); data_dir = fullfile(outputs_path, subject_dir); @@ -102,36 +101,32 @@ function create_group_MNI_plots(subject_list, parameters, options) fprintf('Subject %i, first pass\n', subject_id) - % -- Determine headreco folder for T1 reference -- - if isfield(parameters, 'seg_path') && ~isempty(parameters.seg_path) - headreco_folder = fullfile(parameters.seg_path, sprintf('m2m_sub-%03d', subject_id)); + % -- Determine m2m folder for T1 reference -- + if isfield(parameters.path, 'seg') && ~isempty(parameters.path.seg) + m2m_folder = fullfile(parameters.path.seg, sprintf('m2m_sub-%03d', subject_id)); else - headreco_folder = fullfile(parameters.data_path, sprintf('m2m_sub-%03d', subject_id)); + m2m_folder = fullfile(parameters.path.anat, sprintf('m2m_sub-%03d', subject_id)); end % -- Construct filename patterns (with possible wildcards) -- - pattern_isppa = fullfile(data_dir, sprintf('%s_final_isppa_MNI%s.nii.gz', file_base, parameters.results_filename_affix)); - pattern_segmented = fullfile(data_dir, sprintf('%s_final_medium_masks_MNI%s.nii.gz', file_base, parameters.results_filename_affix)); - pattern_pressure = fullfile(data_dir, sprintf('%s_final_pressure_MNI%s.nii.gz', file_base, parameters.results_filename_affix)); - pattern_output_tbl = fullfile(data_dir, sprintf('%s_%s_output_table%s.csv', file_base, parameters.simulation_medium, parameters.results_filename_affix)); - pattern_heating = fullfile(data_dir, sprintf('%s_final_heating_MNI%s.nii.gz', file_base, parameters.results_filename_affix)); + pattern_intensity = fullfile(data_dir, sprintf('%s_final_intensity_MNI%s.nii.gz', file_base, parameters.io.output_affix)); + pattern_segmented = fullfile(data_dir, sprintf('%s_final_medium_masks_MNI%s.nii.gz', file_base, parameters.io.output_affix)); + pattern_pressure = fullfile(data_dir, sprintf('%s_final_pressure_MNI%s.nii.gz', file_base, parameters.io.output_affix)); + pattern_output_tbl = fullfile(data_dir, sprintf('%s_%s_output_table%s.csv', file_base, parameters.simulation.medium, parameters.io.output_affix)); + pattern_heating = fullfile(data_dir, sprintf('%s_final_heating_MNI%s.nii.gz', file_base, parameters.io.output_affix)); % -- Find files matching patterns -- - files_isppa = dir(pattern_isppa); + files_intensity = dir(pattern_intensity); files_segmented = dir(pattern_segmented); files_pressure = dir(pattern_pressure); files_outtbl = dir(pattern_output_tbl); files_heating = dir(pattern_heating); % -- T1 anatomical reference, not wildcard -- - if strcmp(parameters.segmentation_software, 'headreco') - t1_mni_file = fullfile(headreco_folder, 'toMNI','T1fs_nu_12DOF_MNI.nii.gz'); - else - t1_mni_file = fullfile(headreco_folder, 'toMNI','final_tissues_MNI.nii.gz'); - end + t1_mni_file = fullfile(m2m_folder, 'toMNI','final_tissues_MNI.nii.gz'); files_to_check = {t1_mni_file}; - if isempty(files_isppa), files_to_check{end+1} = pattern_isppa; end + if isempty(files_intensity), files_to_check{end+1} = pattern_intensity; end if isempty(files_segmented), files_to_check{end+1} = pattern_segmented; end if isempty(files_pressure), files_to_check{end+1} = pattern_pressure; end if isempty(files_outtbl), files_to_check{end+1} = pattern_output_tbl; end @@ -148,8 +143,8 @@ function create_group_MNI_plots(subject_list, parameters, options) end % -- SUPPORT MULTIPLE FILES PER SUBJECT: loop over all matches -- - for fidx = 1:numel(files_isppa) - isppa_map_mni_file = fullfile(data_dir, files_isppa(fidx).name); + for fidx = 1:numel(files_intensity) + intensity_map_mni_file = fullfile(data_dir, files_intensity(fidx).name); segmented_image_mni_file= fullfile(data_dir, files_segmented(min(fidx, numel(files_segmented))).name); max_pressure_mni_file = fullfile(data_dir, files_pressure(min(fidx, numel(files_pressure))).name); output_pressure_file = fullfile(data_dir, files_outtbl(min(fidx, numel(files_outtbl))).name); @@ -159,7 +154,7 @@ function create_group_MNI_plots(subject_list, parameters, options) % -- Read volumetric NIFTI and CSV data -- t1_mni = niftiread(t1_mni_file); - Isppa_map_mni = niftiread(isppa_map_mni_file); + intensity_map_mni = niftiread(intensity_map_mni_file); segmented_image_mni= niftiread(segmented_image_mni_file); max_pressure_map_mni = niftiread(max_pressure_mni_file); @@ -171,7 +166,7 @@ function create_group_MNI_plots(subject_list, parameters, options) results_mask = logical(results_mask_original); % -- Mask application -- - Isppa_map_mni = Isppa_map_mni .* results_mask; + intensity_map_mni = intensity_map_mni .* results_mask; max_pressure_map_mni = max_pressure_map_mni .* results_mask; max_pressure = max(max_pressure_map_mni,[],'all'); @@ -179,15 +174,15 @@ function create_group_MNI_plots(subject_list, parameters, options) if options.slice_to_plot slice_n = options.slice_to_plot; else - [~, I] = max(Isppa_map_mni(:)); - [Px, Py, Pz] = ind2sub(size(Isppa_map_mni), I); + [~, I] = max(intensity_map_mni(:)); + [Px, Py, Pz] = ind2sub(size(intensity_map_mni), I); max_focus_MNI_grid = [Px, Py, Pz]; slice_n = max_focus_MNI_grid(strcmp(slice_labels,options.slice_label)); disp(max_focus_MNI_grid) end t1_slice = get_slice_by_label(t1_mni, options.slice_label, slice_n); - isppa_slice = get_slice_by_label(Isppa_map_mni, options.slice_label, slice_n); + intensity_slice = get_slice_by_label(intensity_map_mni, options.slice_label, slice_n); max_pressure_slice = get_slice_by_label(max_pressure_map_mni, options.slice_label, slice_n); bg_min = min(t1_slice,[],'all'); bg_max = max(t1_slice,[],'all'); @@ -201,14 +196,14 @@ function create_group_MNI_plots(subject_list, parameters, options) bg_range_to_use(2) = bg_max; end end - max_isppa = max(isppa_slice(:)); - overlay_threshold_low = min(isppa_slice(max_pressure_slice>=(max_pressure*0.4))); + max_isppa = max(intensity_slice(:)); + overlay_threshold_low = min(intensity_slice(max_pressure_slice>=(max_pressure*0.4))); - if overlay_threshold_low < isppa_range_to_use(1) - isppa_range_to_use(1) = overlay_threshold_low; + if overlay_threshold_low < intensity_range_to_use(1) + intensity_range_to_use(1) = overlay_threshold_low; end - if max_isppa > isppa_range_to_use(2) - isppa_range_to_use(2) = max_isppa; + if max_isppa > intensity_range_to_use(2) + intensity_range_to_use(2) = max_isppa; end if isfield(options,'plot_heating') && options.plot_heating == 1 && exist('heating_data_mni_file','var') @@ -235,7 +230,7 @@ function create_group_MNI_plots(subject_list, parameters, options) for subject_i = 1:length(subject_list) subject_id = subject_list(subject_i); - if isfield(parameters, 'subject_subfolder') && parameters.subject_subfolder == 1 + if isfield(parameters, 'io') && isfield(parameters.path, 'subject_subfolder') && parameters.path.subject_subfolder == 1 subject_dir = sprintf('sub-%03d', subject_id); file_base = sprintf('sub-%03d', subject_id); data_dir = fullfile(outputs_path, subject_dir); @@ -246,28 +241,28 @@ function create_group_MNI_plots(subject_list, parameters, options) fprintf('Subject %i, second pass\n', subject_id) - if isfield(parameters, 'seg_path') && ~isempty(parameters.seg_path) - headreco_folder = fullfile(parameters.seg_path, sprintf('m2m_sub-%03d', subject_id)); + if isfield(parameters.path, 'seg') && ~isempty(parameters.path.seg) + m2m_folder = fullfile(parameters.path.seg, sprintf('m2m_sub-%03d', subject_id)); else - headreco_folder = fullfile(parameters.data_path, sprintf('m2m_sub-%03d', subject_id)); + m2m_folder = fullfile(parameters.path.anat, sprintf('m2m_sub-%03d', subject_id)); end % -- Patterns for batch processing, as before -- - pattern_isppa = fullfile(data_dir, sprintf('%s_final_isppa_MNI%s.nii.gz', file_base, parameters.results_filename_affix)); - pattern_segmented = fullfile(data_dir, sprintf('%s_final_medium_masks_MNI%s.nii.gz', file_base, parameters.results_filename_affix)); - pattern_pressure = fullfile(data_dir, sprintf('%s_final_pressure_MNI%s.nii.gz', file_base, parameters.results_filename_affix)); - pattern_output_tbl = fullfile(data_dir, sprintf('%s_%s_output_table%s.csv', file_base, parameters.simulation_medium, parameters.results_filename_affix)); - pattern_output_tbl_roi= fullfile(data_dir, sprintf('%s_%s_output_table_with_ROI_analysis%s.csv',file_base, parameters.simulation_medium, parameters.results_filename_affix)); - pattern_heating = fullfile(data_dir, sprintf('%s_final_heating_MNI%s.nii.gz', file_base, parameters.results_filename_affix)); - - files_isppa = dir(pattern_isppa); + pattern_intensity = fullfile(data_dir, sprintf('%s_final_intensity_MNI%s.nii.gz', file_base, parameters.io.output_affix)); + pattern_segmented = fullfile(data_dir, sprintf('%s_final_medium_masks_MNI%s.nii.gz', file_base, parameters.io.output_affix)); + pattern_pressure = fullfile(data_dir, sprintf('%s_final_pressure_MNI%s.nii.gz', file_base, parameters.io.output_affix)); + pattern_output_tbl = fullfile(data_dir, sprintf('%s_%s_output_table%s.csv', file_base, parameters.simulation.medium, parameters.io.output_affix)); + pattern_output_tbl_roi= fullfile(data_dir, sprintf('%s_%s_output_table_with_ROI_analysis%s.csv',file_base, parameters.simulation.medium, parameters.io.output_affix)); + pattern_heating = fullfile(data_dir, sprintf('%s_final_heating_MNI%s.nii.gz', file_base, parameters.io.output_affix)); + + files_intensity = dir(pattern_intensity); files_segmented = dir(pattern_segmented); files_pressure = dir(pattern_pressure); files_outtbl = dir(pattern_output_tbl); files_outtbl_roi= dir(pattern_output_tbl_roi); files_heating = dir(pattern_heating); - if isempty(files_isppa) || isempty(files_segmented) || isempty(files_pressure) || isempty(files_outtbl) + if isempty(files_intensity) || isempty(files_segmented) || isempty(files_pressure) || isempty(files_outtbl) if options.skip_missing fprintf('Missing files for subject %d; skipping this subject\n', subject_id); continue; @@ -276,8 +271,8 @@ function create_group_MNI_plots(subject_list, parameters, options) end end - for fidx = 1:numel(files_isppa) - isppa_map_mni_file = fullfile(data_dir, files_isppa(fidx).name); + for fidx = 1:numel(files_intensity) + intensity_map_mni_file = fullfile(data_dir, files_intensity(fidx).name); segmented_image_mni_file = fullfile(data_dir, files_segmented(min(fidx, numel(files_segmented))).name); max_pressure_mni_file = fullfile(data_dir, files_pressure(min(fidx, numel(files_pressure))).name); output_pressure_file = fullfile(data_dir, files_outtbl(min(fidx, numel(files_outtbl))).name); @@ -290,11 +285,7 @@ function create_group_MNI_plots(subject_list, parameters, options) heating_data_mni_file = fullfile(data_dir, files_heating(min(fidx, numel(files_heating))).name); end - if strcmp(parameters.segmentation_software, 'headreco') - t1_mni_file = fullfile(headreco_folder, 'toMNI','T1fs_nu_12DOF_MNI.nii.gz'); - else - t1_mni_file = fullfile(headreco_folder, 'toMNI','final_tissues_MNI.nii.gz'); - end + t1_mni_file = fullfile(m2m_folder, 'toMNI','final_tissues_MNI.nii.gz'); if ~exist(t1_mni_file, 'file') if options.skip_missing fprintf('Missing T1 file for subject %d; skipping this subject\n', subject_id); @@ -307,7 +298,7 @@ function create_group_MNI_plots(subject_list, parameters, options) % -- Full image/statistics logic from your original code -- t1_mni = niftiread(t1_mni_file); t1_mni_hdr = niftiinfo(t1_mni_file); - Isppa_map_mni = niftiread(isppa_map_mni_file); + intensity_map_mni = niftiread(intensity_map_mni_file); segmented_image_mni = niftiread(segmented_image_mni_file); max_pressure_map_mni = niftiread(max_pressure_mni_file); @@ -329,16 +320,16 @@ function create_group_MNI_plots(subject_list, parameters, options) results_mask_original(results_mask_original > max(brain_ind)) = 0; results_mask = logical(results_mask_original); - Isppa_map_mni = Isppa_map_mni .* results_mask; + intensity_map_mni = intensity_map_mni .* results_mask; max_pressure_map_mni = max_pressure_map_mni .* results_mask; max_pressure = max(max_pressure_map_mni,[],'all'); - if isempty(options.isppa_thresholds) - overlay_threshold_high = min(Isppa_map_mni(max_pressure_map_mni >= max_pressure*0.5)); - overlay_threshold_low = min(Isppa_map_mni(max_pressure_map_mni >= max_pressure*0.4)); + if isempty(options.intensity_thresholds) + overlay_threshold_high = min(intensity_map_mni(max_pressure_map_mni >= max_pressure*0.5)); + overlay_threshold_low = min(intensity_map_mni(max_pressure_map_mni >= max_pressure*0.4)); else - overlay_threshold_high = options.isppa_thresholds(2); - overlay_threshold_low = options.isppa_thresholds(1); + overlay_threshold_high = options.intensity_thresholds(2); + overlay_threshold_low = options.intensity_thresholds(1); end if isfield(options,'plot_heating') && options.plot_heating == 1 && exist('heating_data_mni_file','var') @@ -353,8 +344,8 @@ function create_group_MNI_plots(subject_list, parameters, options) if options.slice_to_plot slice_n = options.slice_to_plot; else - [~, I] = max(Isppa_map_mni(:)); - [Px, Py, Pz] = ind2sub(size(Isppa_map_mni), I); + [~, I] = max(intensity_map_mni(:)); + [Px, Py, Pz] = ind2sub(size(intensity_map_mni), I); max_focus_MNI_grid = [Px, Py, Pz]; slice_n = max_focus_MNI_grid(strcmp(slice_labels,options.slice_label)); end @@ -375,18 +366,18 @@ function create_group_MNI_plots(subject_list, parameters, options) % -- ROI and statistics if isfield(options,'ROI_MNI_mask') && options.add_ROI_boundary == 1 roi_size = sum(options.ROI_MNI_mask,'all'); - avg_isppa_within_roi = mean(Isppa_map_mni(logical(options.ROI_MNI_mask)),'all'); + avg_intensity_within_roi = mean(intensity_map_mni(logical(options.ROI_MNI_mask)),'all'); if ~isequal((logical(options.ROI_MNI_mask.*fwhm_mask)), zeros(size(options.ROI_MNI_mask))) - avg_isppa_within_fwhm_and_roi_overlap = mean(Isppa_map_mni(logical(options.ROI_MNI_mask.*fwhm_mask)),'all'); + avg_intensity_within_fwhm_and_roi_overlap = mean(intensity_map_mni(logical(options.ROI_MNI_mask.*fwhm_mask)),'all'); else - avg_isppa_within_fwhm_and_roi_overlap = mean(avg_isppa_within_roi, 'all'); + avg_intensity_within_fwhm_and_roi_overlap = mean(avg_intensity_within_roi, 'all'); end - output_table.(sprintf('avg_isppa_within_fwhm_and_roi_overlap%s', options.outputs_suffix)) = avg_isppa_within_fwhm_and_roi_overlap; + output_table.(sprintf('avg_intensity_within_fwhm_and_roi_overlap%s', options.outputs_suffix)) = avg_intensity_within_fwhm_and_roi_overlap; n_voxels_within_roi_above_thresh = sum(options.ROI_MNI_mask & (max_pressure_map_mni >= max_pressure/2),'all'); props = regionprops(true(size(options.ROI_MNI_mask)), options.ROI_MNI_mask, 'WeightedCentroid'); - dist_between_Isppa_and_center_of_ROI = norm(max_focus_MNI_grid - props.WeightedCentroid); - output_table.(sprintf('dist_between_Isppa_and_center_of_ROI%s', options.outputs_suffix)) = dist_between_Isppa_and_center_of_ROI; - output_table.(sprintf('avg_isppa_within_roi%s', options.outputs_suffix)) = avg_isppa_within_roi; + dist_between_intensity_and_center_of_ROI = norm(max_focus_MNI_grid - props.WeightedCentroid); + output_table.(sprintf('dist_between_intensity_and_center_of_ROI%s', options.outputs_suffix)) = dist_between_intensity_and_center_of_ROI; + output_table.(sprintf('avg_intensity_within_roi%s', options.outputs_suffix)) = avg_intensity_within_roi; output_table.(sprintf('perc_voxels_within_roi%s', options.outputs_suffix)) = n_voxels_within_roi_above_thresh/roi_size; output_table.(sprintf('perc_voxels_within_fwhm%s', options.outputs_suffix)) = n_voxels_within_roi_above_thresh/fwhm_size; output_table.(sprintf('roi_size%s', options.outputs_suffix)) = roi_size; @@ -395,7 +386,7 @@ function create_group_MNI_plots(subject_list, parameters, options) % -- Plot creation step -- plot_overlay(... - Isppa_map_mni, ... + intensity_map_mni, ... t1_mni, ... zeros(size(t1_mni)), ... struct(), ... @@ -406,7 +397,7 @@ function create_group_MNI_plots(subject_list, parameters, options) 'overlay_threshold_low', overlay_threshold_low, ... 'overlay_threshold_high', overlay_threshold_high, ... 'show_rectangles', 0, ... - 'overlay_color_range', isppa_range_to_use, ... + 'overlay_color_range', intensity_range_to_use, ... 'grid_step', t1_mni_hdr.PixelDimensions(1), ... 'overlay_segmented', 0, ... 'rotation', current_rotation, ... @@ -427,7 +418,7 @@ function create_group_MNI_plots(subject_list, parameters, options) visboundaries(imrotate(mask_im, current_rotation), 'Color', 'white','LineStyle', '--','LineWidth',0.5,'EnhanceVisibility',0); end - export_fig(fullfile(data_dir, sprintf('%s_final_isppa_MNI%s%s', file_base, parameters.results_filename_affix, options.outputs_suffix)),'-silent','-r320'); + export_fig(fullfile(data_dir, sprintf('%s_final_intensity_MNI%s%s', file_base, parameters.io.output_affix, options.outputs_suffix)),'-silent','-r320'); close if isfield(options,'plot_heating') && options.plot_heating == 1 && exist('heating_data_mni_file','var') @@ -441,7 +432,7 @@ function create_group_MNI_plots(subject_list, parameters, options) visboundaries(imrotate(mask_im, current_rotation)) end export_fig(fullfile(data_dir, sprintf('%s_maxT_MNI%s%s',... - file_base, parameters.results_filename_affix, options.outputs_suffix)),'-silent','-r320'); + file_base, parameters.io.output_affix, options.outputs_suffix)),'-silent','-r320'); close end end @@ -449,10 +440,10 @@ function create_group_MNI_plots(subject_list, parameters, options) % --- Assemble output images into montages --- if isfield(options,'plot_heating') && options.plot_heating == 1 - suffix_list = {sprintf('maxT_MNI%s%s', parameters.results_filename_affix, options.outputs_suffix),... - sprintf('final_isppa_MNI%s%s', parameters.results_filename_affix, options.outputs_suffix)}; + suffix_list = {sprintf('maxT_MNI%s%s', parameters.io.output_affix, options.outputs_suffix),... + sprintf('final_intensity_MNI%s%s', parameters.io.output_affix, options.outputs_suffix)}; else - suffix_list = {sprintf('final_isppa_MNI%s%s', parameters.results_filename_affix, options.outputs_suffix)}; + suffix_list = {sprintf('final_intensity_MNI%s%s', parameters.io.output_affix, options.outputs_suffix)}; end for suffix_cell = suffix_list diff --git a/functions/head/head_smooth_and_crop.m b/functions/head/head_smooth_and_crop.m index f6f92bd9..13b35961 100644 --- a/functions/head/head_smooth_and_crop.m +++ b/functions/head/head_smooth_and_crop.m @@ -11,37 +11,35 @@ % This function turns the original `layered` segmentations into medium masks such % that the setup_medium.m function can fill in the tissue-dependent parameters. + % Tissue masks will contain IDs according to the order of tissues in parameters.medium_properties. - % Note that tissue masks will assume the labels specified in parameters.layers. - - grid_step_mm = parameters.grid_step_mm; - - labels = fieldnames(parameters.layers); + grid.resolution_mm = parameters.grid.resolution_mm; % Segmentations will be postprocessed. % Incl. smoothing layer transitions & filling potential skull segmentation gaps. % create "medium_masks" that contains indices according to the label order in parameters.layers % each mask will be smoothed in the process - log_timer('start','preproc_medium_mask', parameters.output_dir); + log_timer('start','preproc_medium_mask', parameters.io.output_dir); [medium_masks] = preproc_medium_mask(segmentation, parameters); log_timer('stop','preproc_medium_mask'); % Fill gaps in skull mask - if any(contains(labels, 'skull')) + requested_layers = fieldnames(parameters.layers); + if any(contains(requested_layers, 'skull')) [medium_masks, ~] = skull_fill_holes(parameters, ... - medium_masks, labels, focus_pos_grid, segmentation); + medium_masks, focus_pos_grid, segmentation); end % [DEBUG] Plot segmentation and smoothed medium mask - if parameters.debug == 1 + if parameters.simulation.debug == 1 h = figure; imshowpair(label2rgb(squeeze(segmentation(:,trans_pos_grid(2),:))), ... label2rgb(squeeze(medium_masks(:,trans_pos_grid(2),:))), 'montage') title('Original segmentation (left) and smoothed medium mask (right)') - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_%s_segmented_img_smoothing_changes%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png') close(h); end @@ -51,15 +49,15 @@ preproc_crop_grid(parameters, medium_masks, segmentation, bone_img, trans_pos_grid, focus_pos_grid); % [DEBUG] plot the smoothed and unsmoothed skull segmentation with transducer and focus locations - if parameters.debug == 1 + if parameters.simulation.debug == 1 h = figure; - imshowpair(plot_t1_with_transducer(segmentation, grid_step_mm, trans_pos_grid, focus_pos_grid, parameters), ... - plot_t1_with_transducer(medium_masks, grid_step_mm, trans_pos_final, focus_pos_final, parameters),... + imshowpair(plot_t1_with_transducer(segmentation, grid.resolution_mm, trans_pos_grid, focus_pos_grid, parameters), ... + plot_t1_with_transducer(medium_masks, grid.resolution_mm, trans_pos_final, focus_pos_final, parameters),... 'montage') title('Original segmentation (left) and Cropped, padded, & smoothed medium mask (right)') - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_%s_seg_smoothing_and_cropping_%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png') close(h); end diff --git a/functions/head/preproc_align_to_focal_axis.m b/functions/head/preproc_align_to_focal_axis.m index 180e78c4..236fefab 100644 --- a/functions/head/preproc_align_to_focal_axis.m +++ b/functions/head/preproc_align_to_focal_axis.m @@ -48,6 +48,11 @@ parameters struct end + % Ensure inputs are on CPU (tformarray and other image processing functions don't support gpuArray) + nii_image = gather(nii_image); + trans_pos_grid = gather(trans_pos_grid); + focus_pos_grid = gather(focus_pos_grid); + %% Step 1: Compute focal axis % The focal axis is defined as a vector from `trans_pos_grid` to `focus_pos_grid`. focal_axis = [focus_pos_grid - trans_pos_grid, 1]'; @@ -81,15 +86,38 @@ rotation_and_scale_matrix = rotation_matrix * scale_matrix; %% Step 5: Compute new image dimensions - % Determine new image dimensions after applying transformations - TF = maketform('affine', rotation_and_scale_matrix'); - newdims = ceil(diff(findbounds(TF, [0 0 0; size(nii_image)]))); - - %% Step 6: Center transformations around image center + % Determine new image dimensions after applying transformations. + % We must ensure the new grid is large enough to contain the image AND the transducer/focus, + % while keeping the image center at the center of the new grid. + % Translate image center to origin before applying transformations rotation_center = (size(nii_image) + 1) / 2; forward = makehgtform('translate', -rotation_center); + % Create the transform that centers the input, then rotates/scales + % forward: translates input center to origin + % rotation_and_scale_matrix: rotates/scales around origin + T_centered = forward' * rotation_and_scale_matrix'; + TF_centered = maketform('affine', T_centered); + + % Get bounds of the transformed image corners + img_bounds = findbounds(TF_centered, [0 0 0; size(nii_image)]); + + % Get transformed positions of transducer and focus + tp_trans = tformfwd(trans_pos_grid, TF_centered); + fp_trans = tformfwd(focus_pos_grid, TF_centered); + + % Combine all points to find the maximum extent from the center + all_points = [img_bounds; tp_trans; fp_trans]; + max_abs = max(abs(all_points), [], 1); + + % Calculate symmetric dimensions to keep the image centered while fitting everything + % Factor of 2 ensures coverage from -max to +max + newdims = ceil(2 * max_abs) + 2; % +2 margin for safety + + %% Step 6: Center transformations around image center + % (forward is already calculated above) + % Translate back to center of new dimensions after transformations backward = makehgtform('translate', (newdims + 1) / 2); @@ -98,6 +126,8 @@ %% Step 7: Transform image % Apply affine transformation to rotate and scale the image + % (nii_image already ensured to be on CPU) + if numel(unique(nii_image))<20 % if the image is a mask use nearest neighbor parameters.interpolation = 'nearest'; else diff --git a/functions/head/preproc_crop_eCSF.m b/functions/head/preproc_crop_eCSF.m index ab48aec6..924a7325 100644 --- a/functions/head/preproc_crop_eCSF.m +++ b/functions/head/preproc_crop_eCSF.m @@ -1,23 +1,24 @@ function [medium_masks] = preproc_crop_eCSF(parameters, medium_masks, segmented_img, trans_pos_grid) % Expand CSF to mask areas away from the brain (e.g., exclude distant bone) - if isfield(parameters, 'seg_labels') && any(strcmp(fieldnames(parameters.seg_labels), 'csf')) + seg_labels = charm_seg_labels(); + if isfield(seg_labels, 'csf') % get CSF mask - csf_mask = segmented_img==getidx(parameters.seg_labels,'csf'); - expansion_voxels = ceil(parameters.csf_mask_expansion_factor / parameters.grid_step_mm); + csf_mask = segmented_img==getidx(seg_labels,'csf'); + expansion_voxels = ceil(parameters.headmodel.csf_expansion / parameters.grid.resolution_mm); SE = strel('cube', expansion_voxels); csf_mask_expanded = imdilate(csf_mask, SE); % Mask out non-CSF-expanded regions (i.e., set to water layer) - medium_masks(~csf_mask_expanded) = find(strcmp(fieldnames(parameters.medium), 'water')); + medium_masks(~csf_mask_expanded) = find(strcmp(fieldnames(parameters.medium_properties), 'water')); % [DEBUG] Visualize CSF expansion at transducer y-slice - if parameters.debug == 1 + if parameters.simulation.debug == 1 h = figure; imshowpair(squeeze(medium_masks(:, trans_pos_grid(2), :)), ... squeeze(csf_mask_expanded(:, trans_pos_grid(2), :)), 'falsecolor'); title('CSF mask (pink) and the segmented image (white)'); - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_%s_skull_csf_mask%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png'); close(h); end diff --git a/functions/head/preproc_crop_grid.m b/functions/head/preproc_crop_grid.m index 0b257954..14290719 100644 --- a/functions/head/preproc_crop_grid.m +++ b/functions/head/preproc_crop_grid.m @@ -4,97 +4,117 @@ % preproc_crop_grid Crops 3D head model grid to simulation-relevant region for k-Wave efficiency. % % Crops full-head model to CSF-guided + transducer-inclusive bounding box, excluding distant bone. -% Optimizes grid dimensions for FFT performance, pads as needed, computes skull edges and adjustments. +% Adds user-controlled symmetric padding BEFORE transducer_setup to prevent out-of-bounds. +% Optimizes grid dimensions for FFT performance. Computes skull edges and adjustments. % % Inputs: -% parameters - Struct with: -% .csf_mask_expansion_factor (mm), .pml_size, .grid_max_expand, -% .debug (1/0), .debug_dir, .subject_id, .simulation_medium, .results_filename_affix, -% .parameters.grid_step_mm (voxel size, used elsewhere). +% parameters - Struct with .headmodel.head_pad_mm (symmetric padding mm), .grid.resolution_mm, .pml_size, +% .headmodel.csf_expansion, .grid.max_expand, .simulation.debug, etc. % medium_masks - 3D array: Current medium labels (non-CSF regions zeroed). -% csf_mask - 3D binary: CSF mask (expanded for cropping guide). % segmentation - 3D array: Original segmentation/pseudoCT. +% bone_img - 3D array: Bone property image. % trans_pos_grid - 1x3: Transducer position [x y z] grid indices. % focus_pos_grid - 1x3: Focus position [x y z] grid indices. % % Outputs: % medium_masks - Cropped 3D medium labels. -% segmentation_crop - Cropped original segmentation. +% segmentation_crop - Cropped original segmentation. +% bone_crop - Cropped bone image. % parameters - Updated with .grid_dims = [Nx Ny Nz]. % trans_pos_final - 1x3: Adjusted transducer pos in cropped grid. % focus_pos_final - 1x3: Adjusted focus pos in cropped grid. -% translation_matrix - 4x4: Homogeneous translation (-min_dims). +% translation_matrix - 4x4: Homogeneous translation (original β†’ cropped coordinates). % -% Dependencies: -% Custom: get_crop_dims, transducer_setup, find_min_factor, makehgtform. -% Image Processing: strel, imdilate, padarray, edge3, imshowpair. - - % Expand CSF mask to crop the layered medium - if isfield(parameters, 'seg_labels') && any(strcmp(fieldnames(parameters.seg_labels), 'csf')) - [medium_masks] = preproc_crop_eCSF(parameters, medium_masks, segmentation, trans_pos_grid); - else - warning("CSF layer not specified or unknown ... will not use expanded CSF mask..."); - end - - % Set PML buffer as crop margin - crop_margin = parameters.pml_size + 1; - - % Include transducer bowl geometry - transducer_bowl = transducer_setup(parameters.transducer(1), trans_pos_grid, ... - focus_pos_grid, size(segmentation), parameters.grid_step_mm); - - % Compute crop bounds - orig_dims = size(medium_masks); - i_water = find(strcmp(fieldnames(parameters.medium), 'water')); - combinedmask = (medium_masks ~= i_water) | transducer_bowl; % Logical union of medium mask and transducer bowl - [min_dims, max_dims, new_grid_dims] = get_crop_dims(double(combinedmask), crop_margin); - clear combinedmask; - - % Pad if min_dims inside crop margin - if any(min_dims < 1) - pad_amount = abs(min(min_dims, [1 1 1])); - segmentation = padarray(segmentation, pad_amount, 0, 'pre'); - medium_masks = padarray(medium_masks, pad_amount, i_water, 'pre'); - bone_img = padarray(bone_img, pad_amount, 0, 'pre'); - min_dims = max(min_dims, [1 1 1]); - max_dims = max_dims + pad_amount; - new_grid_dims = max_dims - min_dims + 1; - end - - % Optimize dimensions for FFT - new_grid_dims(1) = find_min_factor(new_grid_dims(1), new_grid_dims(1) + parameters.grid_max_expand); - new_grid_dims(2) = find_min_factor(new_grid_dims(2), new_grid_dims(2) + parameters.grid_max_expand); - new_grid_dims(3) = find_min_factor(new_grid_dims(3), new_grid_dims(3) + parameters.grid_max_expand); - max_dims = min_dims + new_grid_dims - 1; - - % Pad post if max_dims exceeds - if any(max_dims > size(medium_masks)) - pad_amount = max(max_dims, size(segmentation)) - size(segmentation); - segmentation = padarray(segmentation, pad_amount, 0, 'post'); - medium_masks = padarray(medium_masks, pad_amount, i_water, 'post'); - bone_img = padarray(bone_img, pad_amount, 0, 'post'); - end - - % Apply crop (create new grids) - medium_masks = medium_masks(min_dims(1):max_dims(1), min_dims(2):max_dims(2), min_dims(3):max_dims(3)); - segmentation_crop = segmentation(min_dims(1):max_dims(1), min_dims(2):max_dims(2), min_dims(3):max_dims(3)); - bone_crop = bone_img(min_dims(1):max_dims(1), min_dims(2):max_dims(2), min_dims(3):max_dims(3)); - - % Update parameters - parameters.grid_dims = size(medium_masks); - - % Adjust positions - trans_pos_final = trans_pos_grid - min_dims + 1; - focus_pos_final = focus_pos_grid - min_dims + 1; - - % Translation matrix - translation_matrix = makehgtform('translate', 1-min_dims); - - % Display adjusted parameters - if orig_dims ~= new_grid_dims - fprintf('Adjusted grid ... \n'); - fprintf('Grid dims: [%d %d %d] β†’ [%d %d %d] \n', ... - orig_dims, new_grid_dims); - end + +% === USER SYMMETRIC PADDING (BOTH SIDES) === +if ~isfield(parameters.headmodel, 'head_pad_mm') || isempty(parameters.headmodel.head_pad_mm) + parameters.headmodel.head_pad_mm = 0; % mm +end +pad_voxels = round(parameters.headmodel.head_pad_mm / parameters.grid.resolution_mm); +pad_pre_post = [pad_voxels, pad_voxels, pad_voxels]; % Symmetric BOTH sides + +% Track TOTAL pre-padding offset for translation matrix +total_pre_offset = zeros(1,3); + +% Apply symmetric padding BEFORE transducer_setup +if any(pad_pre_post > 0) + segmentation = padarray(segmentation, pad_pre_post, 0, 'both'); + medium_masks = padarray(medium_masks, pad_pre_post, 0, 'both'); + bone_img = padarray(bone_img, pad_pre_post, 0, 'both'); + total_pre_offset = total_pre_offset + pad_pre_post; % User padding contribution + % Positions shift by PRE-padding amount ('both' adds pre first) + trans_pos_grid = trans_pos_grid + pad_pre_post; + focus_pos_grid = focus_pos_grid + pad_pre_post; +end + +% Expand CSF mask to crop the layered medium +[medium_masks] = preproc_crop_eCSF(parameters, medium_masks, segmentation, trans_pos_grid); + +% Set PML buffer as crop margin +crop_margin = parameters.grid.pml_size + 1; + +% Include transducer bowl geometry (safe with padding) +i_water = find(strcmp(fieldnames(parameters.medium_properties), 'water')); +transducer_bowl = transducer_setup(parameters.transducer(1), trans_pos_grid, ... + focus_pos_grid, size(segmentation), parameters.grid.resolution_mm); + +% Compute crop bounds +orig_dims = size(medium_masks); +combinedmask = (medium_masks ~= i_water) | transducer_bowl; +[min_dims, max_dims, new_grid_dims] = get_crop_dims(double(combinedmask), crop_margin); +clear combinedmask; + +% === CONDITIONAL PRE-PADDING if min_dims < 1 === +if any(min_dims < 1) + pad_amount = abs(min(min_dims, [1 1 1])); + segmentation = padarray(segmentation, pad_amount, 0, 'pre'); + medium_masks = padarray(medium_masks, pad_amount, i_water, 'pre'); + bone_img = padarray(bone_img, pad_amount, 0, 'pre'); + total_pre_offset = total_pre_offset + pad_amount; % Add conditional padding + min_dims = max(min_dims, [1 1 1]); + max_dims = max_dims + pad_amount; + new_grid_dims = max_dims - min_dims + 1; + trans_pos_grid = trans_pos_grid + pad_amount; + focus_pos_grid = focus_pos_grid + pad_amount; +end + +% Optimize dimensions for FFT +new_grid_dims(1) = find_min_factor(new_grid_dims(1), new_grid_dims(1) + parameters.grid.max_expand); +new_grid_dims(2) = find_min_factor(new_grid_dims(2), new_grid_dims(2) + parameters.grid.max_expand); +new_grid_dims(3) = find_min_factor(new_grid_dims(3), new_grid_dims(3) + parameters.grid.max_expand); +max_dims = min_dims + new_grid_dims - 1; + +% === CONDITIONAL POST-PADDING if FFT expansion exceeds padding === +if any(max_dims > size(medium_masks)) + pad_post_amount = max(0, max_dims - size(medium_masks)); + segmentation = padarray(segmentation, pad_post_amount, 0, 'post'); + medium_masks = padarray(medium_masks, pad_post_amount, i_water, 'post'); + bone_img = padarray(bone_img, pad_post_amount, 0, 'post'); + fprintf('Post-padding applied: [%d %d %d] voxels\n', pad_post_amount); +end + +% Apply final crop +medium_masks = medium_masks(min_dims(1):max_dims(1), min_dims(2):max_dims(2), min_dims(3):max_dims(3)); +segmentation_crop = segmentation(min_dims(1):max_dims(1), min_dims(2):max_dims(2), min_dims(3):max_dims(3)); +bone_crop = bone_img(min_dims(1):max_dims(1), min_dims(2):max_dims(2), min_dims(3):max_dims(3)); + +% Update parameters +parameters.grid.dims = size(medium_masks); + +% Final positions in cropped grid coordinates +trans_pos_final = trans_pos_grid - min_dims + 1; +focus_pos_final = focus_pos_grid - min_dims + 1; + +% === COMPLETE TRANSLATION MATRIX === +% Maps: original_world β†’ final_cropped_world +% Offset = total_pre_padding + crop_offset +translation_matrix = makehgtform('translate', total_pre_offset + (1-min_dims)); + +% Display summary +fprintf('Grid: [%dx%dx%d] β†’ [%dx%dx%d]\n', orig_dims, parameters.grid.dims); +fprintf(' User padding: %.1fmm = [%d %d %d] voxels\n', ... + parameters.headmodel.head_pad_mm, pad_pre_post); +fprintf(' Total pre-offset: [%d %d %d] voxels\n', total_pre_offset); +fprintf(' Crop bounds: min=[%d %d %d], max=[%d %d %d]\n', min_dims, max_dims); end \ No newline at end of file diff --git a/functions/head/preproc_head.m b/functions/head/preproc_head.m index 79e5195b..54df7587 100644 --- a/functions/head/preproc_head.m +++ b/functions/head/preproc_head.m @@ -28,15 +28,11 @@ disp('Checking inputs for head preprocessing ...'); % Define path to T1 image (user-specified) - filename_t1 = fullfile(parameters.data_path, sprintf(parameters.t1_path_template, parameters.subject_id)); + filename_t1 = fullfile(parameters.path.anat, sprintf(parameters.path.t1_pattern, parameters.subject_id)); % Define path to segmentation results - segmentation_folder = fullfile(parameters.seg_path, sprintf('m2m_sub-%03d', parameters.subject_id)); - if strcmp(parameters.segmentation_software, 'charm') - filename_segmented = fullfile(segmentation_folder, 'final_tissues.nii.gz'); - else - filename_segmented = fullfile(segmentation_folder, sprintf('sub-%03d_final_contr.nii.gz', parameters.subject_id)); - end + segmentation_folder = fullfile(parameters.path.seg, sprintf('m2m_sub-%03d', parameters.subject_id)); + filename_segmented = fullfile(segmentation_folder, 'final_tissues.nii.gz'); % Define path to T1 image (simnibs; aligned with segmentation space) filename_t1_simnibs = fullfile(segmentation_folder, 'T1.nii.gz'); @@ -51,7 +47,7 @@ disp('Loading images...'); - if isfield(parameters,'transducer_from_localite') && parameters.transducer_from_localite + if isfield(parameters.placement,'localite') && isfield(parameters.placement.localite,'enabled') && parameters.placement.localite.enabled t1_image = niftiread(filename_t1); t1_header = niftiinfo(filename_t1); else @@ -59,7 +55,7 @@ t1_header = niftiinfo(filename_t1_simnibs); end - if parameters.usepseudoCT == 1 + if parameters.pct.enabled == 1 % Load pseudoCT filename_pseudoCT = fullfile(segmentation_folder,'pseudoCT.nii.gz'); pseudoCT_image = niftiread(filename_pseudoCT); @@ -80,7 +76,7 @@ % Note: localite coordinates may refer to different header than e.g., simnibs segmentation % [Multi-transducer] the preprocessing will be based on the first transducer - if isfield(parameters,'transducer_from_localite') && parameters.transducer_from_localite + if isfield(parameters.placement,'localite') && isfield(parameters.placement.localite,'enabled') && parameters.placement.localite.enabled % Validate existence of localite file check_availability({localite_file}) % Determine transducer position [experimental] @@ -106,26 +102,26 @@ disp('Rotating images to focal axis and rescaling to grid resolution ...') - % If the headreco process was not successful, it will stop preprocessing + % If the segmentation process was not successful, it will stop preprocessing assert(exist(filename_segmented,'file') > 0, ... 'Head segmentation is not completed (%s does not exist), see logs in the batch_logs folder and in %s folder',... filename_segmented, segmentation_folder) % Defines output file location and name - filename_reoriented_scaled_data = fullfile(parameters.debug_dir, ... + filename_reoriented_scaled_data = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_after_rotating_and_scaling%s.mat', ... - parameters.subject_id, parameters.results_filename_affix)); + parameters.subject_id, parameters.io.output_affix)); if confirm_overwriting(filename_reoriented_scaled_data, parameters) - log_timer('start','preproc_rotscale', parameters.output_dir); + log_timer('start','preproc_rotscale', parameters.io.output_dir); %% [Planning image] (rotation matrix will be established for planning image) % Note: This primarily serves plotting purposes. if t1_header.ImageSize(3) > 1 % rescale the image to the desired grid resolution - scale_factor_t1 = t1_header.PixelDimensions(1)/parameters.grid_step_mm; + scale_factor_t1 = t1_header.PixelDimensions(1)/parameters.grid.resolution_mm; [t1_img_rr, trans_pos_rescaled, focus_pos_rescaled, ... scale_rotate_recenter_matrix, rotation_matrix, ~, ~, t1_rr_img_montage] = ... preproc_align_to_focal_axis(... @@ -137,13 +133,13 @@ parameters); % [DEBUG] visualize original and rotated planning image - if parameters.debug == 1 + if parameters.simulation.debug == 1 h = figure; imshow(t1_rr_img_montage) title('Original (left) and rotated (right) planning image'); - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_t1_after_rotating_and_scaling%s.png', ... - parameters.subject_id, parameters.results_filename_affix)); + parameters.subject_id, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png'); close(h); end; clear t1_rr_img_montage; @@ -154,7 +150,7 @@ %% [Tissue segmentation] - scale_factor_seg = tissues_mask_header.PixelDimensions(1)/parameters.grid_step_mm; + scale_factor_seg = tissues_mask_header.PixelDimensions(1)/parameters.grid.resolution_mm; [segmented_img_rr, ~, ~, ... ~, ~, ~, ~, segm_img_montage] = ... preproc_align_to_focal_axis(... @@ -166,61 +162,51 @@ parameters); % [DEBUG] visualize original and rotated segmentation image - if parameters.debug == 1 + if parameters.simulation.debug == 1 h = figure; imshow(segm_img_montage) title('Original (left) and rotated (right) tissue segmentation'); - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_segmented_after_rotating_and_scaling%s.png', ... - parameters.subject_id, parameters.results_filename_affix)); + parameters.subject_id, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png'); close(h); end; clear segm_img_montage; %% [bone mask/pCT] - if parameters.usepseudoCT == 1 + if parameters.pct.enabled == 1 [bone_img_rr, ~, ~, ~, ~, ~, ~, bone_img_montage] = ... preproc_align_to_focal_axis(... pseudoCT_image, ... pseudoCT_header, ... trans_pos_grid, ... focus_pos_grid, ... - pseudoCT_header.PixelDimensions(1)/parameters.grid_step_mm, ... + pseudoCT_header.PixelDimensions(1)/parameters.grid.resolution_mm, ... parameters); else - if strcmp(parameters.segmentation_software, 'charm') - % create filled bone mask as charm doesn't make it itself - if isfield(parameters, 'seg_labels') && any(strcmp(fieldnames(parameters.seg_labels), 'bonemask')) - bone_img = ismember(tissues_mask_image,getidx(parameters.seg_labels,'bonemask')); - else - bone_img = tissues_mask_image>0&(tissues_mask_image<=4|tissues_mask_image>=7); - warning("Using hardcoded labels for bonemask..."); - end - else % load bone mask created by simnibs - filename_bone_headreco = fullfile(segmentation_folder, 'bone.nii.gz'); - bone_img = niftiread(filename_bone_headreco); - end + % create filled bone mask (charm doesn't produce one directly) + bone_img = ismember(tissues_mask_image, charm_seg_labels().bonemask); [bone_img_rr, ~, ~, ~, ~, ~, ~, bone_img_montage] = ... preproc_align_to_focal_axis(... bone_img, ... tissues_mask_header, ... trans_pos_grid, ... focus_pos_grid, ... - tissues_mask_header.PixelDimensions(1)/parameters.grid_step_mm, ... + tissues_mask_header.PixelDimensions(1)/parameters.grid.resolution_mm, ... parameters); end % [DEBUG] visualize original and rotated bone mask image - if parameters.debug == 1 + if parameters.simulation.debug == 1 h = figure; imshow(bone_img_montage) title('Original (left) and rotated (right) original bone mask'); - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_after_rotating_and_scaling_orig%s.png', ... - parameters.subject_id, parameters.results_filename_affix)); + parameters.subject_id, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png') - close(h); + close(h); end; clear bone_img_montage; assert(isequal(size(trans_pos_rescaled(1:2)),size(focus_pos_rescaled(1:2))),... @@ -243,7 +229,7 @@ %% [DEBUG] Plot the skin & skull from the segmented image and an overlay for comparison - if parameters.debug == 1 + if parameters.simulation.debug == 1 % Create a T1 slice for comparison to SimNIBS segmented data t1_slice = repmat(mat2gray(squeeze(t1_img_rr(:,trans_pos_rescaled(2),:))), [1 1 3]); % Create slices of segmented SimNIBS data @@ -261,8 +247,8 @@ montage(cat(4,t1_slice*255 ,skin_skull_img*255 ,... imfuse(mat2gray(t1_slice), skin_skull_img,'blend')) ,'size',[1 NaN]); title('T1 and SimNIBS skin (green) and skull (blue) masks'); - output_plot_filename = fullfile(parameters.debug_dir,... - sprintf('sub-%03d_t1_skin_skull%s.png',parameters.subject_id, parameters.results_filename_affix)); + output_plot_filename = fullfile(parameters.io.debug_dir,... + sprintf('sub-%03d_t1_skin_skull%s.png',parameters.subject_id, parameters.io.output_affix)); saveas(h ,output_plot_filename ,'png') close(h); @@ -275,12 +261,12 @@ disp('Creating layered medium by smoothing and cropping the head segmentation...') % Defines output file location and name - filename_cropped_smoothed_skull_data = fullfile(parameters.debug_dir, ... + filename_cropped_smoothed_skull_data = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_%s_after_cropping_and_smoothing%s.mat', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); if confirm_overwriting(filename_cropped_smoothed_skull_data, parameters) - log_timer('start','preproc_skullsmooth', parameters.output_dir); + log_timer('start','preproc_skullsmooth', parameters.io.output_dir); % postprocess skull segmentation [medium_masks, segmentation_crop, bone_crop, trans_pos_final, focus_pos_final, crop_translation_matrix] = ... head_smooth_and_crop(... @@ -291,11 +277,11 @@ inv_final_transformation_matrix = maketform('affine', inv(final_transformation_matrix')'); % [DEBUG] save transformed medium mask and skull to debug dir - if parameters.debug == 1 + if parameters.simulation.debug == 1 % save medium mask orig_hdr = t1_header; % header is based on original T1w (always present) orig_hdr.Datatype = 'single'; - segmented_file = fullfile(parameters.debug_dir, ... + segmented_file = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_medium_masks_final', parameters.subject_id)); plotdata = single(tformarray(uint8(medium_masks), inv_final_transformation_matrix, ... makeresampler('nearest', 'fill'), [1 2 3], [1 2 3], orig_hdr.ImageSize, [], 0)) ; @@ -307,7 +293,7 @@ % save segmentation skull mask/pseudoCT orig_hdr = t1_header; % header is based on original T1w (always present) orig_hdr.Datatype = 'single'; - segmentation_file = fullfile(parameters.debug_dir, ... + segmentation_file = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_segmentation_final', parameters.subject_id)); plotdata = single(tformarray(uint8(segmentation_crop), inv_final_transformation_matrix, ... makeresampler('nearest', 'fill'), [1 2 3], [1 2 3], orig_hdr.ImageSize, [], 0)) ; @@ -319,7 +305,7 @@ % save skull mask/pseudoCT orig_hdr = t1_header; % header is based on original T1w (always present) orig_hdr.Datatype = 'double'; - skull_mask_file = fullfile(parameters.debug_dir, ... + skull_mask_file = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_skull_final', parameters.subject_id)); plotdata = double(tformarray(bone_crop, inv_final_transformation_matrix, ... makeresampler('nearest', 'fill'), [1 2 3], [1 2 3], orig_hdr.ImageSize, [], 0)) ; @@ -368,7 +354,7 @@ %% Plot placement of up to 2 Transducers % Update the parameters (for plottign below only) - parameters.grid_dims = size(medium_masks); + parameters.grid.dims = size(medium_masks); max_plots = min(2, numel(parameters.transducer)); if numel(parameters.transducer) > max_plots @@ -394,25 +380,25 @@ fpos_sim = pts_sim(2,:); end - if parameters.debug == 1 + if parameters.simulation.debug == 1 % [DEBUG] Plot brain segmentation [seg_with_trans_img, ~] = plot_t1_with_transducer(... - medium_masks, parameters.grid_step_mm, tpos_sim, fpos_sim, parameters); - + medium_masks, parameters.grid.resolution_mm, tpos_sim, fpos_sim, parameters); + h = figure; imshow(seg_with_trans_img); title('Segmentation with transducer'); - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_%s_segmented_brain_final_T%02d%s.png', ... - parameters.subject_id, parameters.simulation_medium, ti, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, ti, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png') close(h); end - + % Plot positioning of transducer on segmentation - output_plot_filename = fullfile(parameters.output_dir, ... + output_plot_filename = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_positioning_T%02d%s.png', ... - parameters.subject_id, ti, parameters.results_filename_affix)); + parameters.subject_id, ti, parameters.io.output_affix)); % Subplot 1: Original segmentation with initial transducer and focus positions % Subplot 2: Original segmentation with slice cap applied diff --git a/functions/head/preproc_medium_mask.m b/functions/head/preproc_medium_mask.m index 66393095..d0a80624 100644 --- a/functions/head/preproc_medium_mask.m +++ b/functions/head/preproc_medium_mask.m @@ -18,7 +18,7 @@ [parameters] = check_layers(parameters, segmented_img); layer_labels = fieldnames(parameters.layers); - medium_labels = fieldnames(parameters.medium); + medium_labels = fieldnames(parameters.medium_properties); medium_masks = zeros(size(segmented_img)); % === SKULL CONFIGURATION DETECTION === @@ -41,13 +41,13 @@ % Tissue-specific smoothing if contains(label_name, 'skull') - threshold = parameters.smooth_threshold_skull; + threshold = parameters.headmodel.smooth_threshold_skull; else - threshold = parameters.smooth_threshold_other; + threshold = parameters.headmodel.smooth_threshold_other; end - layer_mask_smoothed = smooth_img(layer_mask, parameters.smooth_window, ... - threshold, parameters.smooth_method); + layer_mask_smoothed = smooth_img(layer_mask, parameters.headmodel.smooth_fwhm_mm, parameters.grid.resolution_mm, ... + threshold, parameters.headmodel.smooth_method); % assign tissue-specific medium ID medium_masks(layer_mask_smoothed ~= 0) = find(strcmp(medium_labels, label_name)); end @@ -59,15 +59,15 @@ cortical_i = find(strcmp(medium_labels, 'skull_cortical')); skull_base_layers = getidx(parameters.layers, {'skull', 'skull_cortical'}); layer_mask = ismember(segmented_img, skull_base_layers); - layer_mask_smoothed = smooth_img(layer_mask, parameters.smooth_window, ... - parameters.smooth_threshold_skull, parameters.smooth_method); + layer_mask_smoothed = smooth_img(layer_mask, parameters.headmodel.smooth_fwhm_mm, parameters.grid.resolution_mm, ... + parameters.headmodel.smooth_threshold_skull, parameters.headmodel.smooth_method); medium_masks(layer_mask_smoothed ~= 0) = cortical_i; % Step 2: Overlay trabecular on top trabecular_i = find(strcmp(medium_labels, 'skull_trabecular')); trabecular_mask = ismember(segmented_img, getidx(parameters.layers, 'skull_trabecular')); - trabecular_mask_smoothed = smooth_img(trabecular_mask, parameters.smooth_window, ... - parameters.smooth_threshold_skull, parameters.smooth_method); + trabecular_mask_smoothed = smooth_img(trabecular_mask, parameters.headmodel.smooth_fwhm_mm, parameters.grid.resolution_mm, ... + parameters.headmodel.smooth_threshold_skull, parameters.headmodel.smooth_method); medium_masks(trabecular_mask_smoothed ~= 0) = trabecular_i; end diff --git a/functions/head/preproc_segmentation.m b/functions/head/preproc_segmentation.m index 46c7f371..2839eb30 100644 --- a/functions/head/preproc_segmentation.m +++ b/functions/head/preproc_segmentation.m @@ -1,4 +1,4 @@ -function preproc_segmentation(parameters) +function parameters = preproc_segmentation(parameters) % PREPROC_SEGMENTATION Segment the data using SimNIBS % @@ -9,8 +9,8 @@ function preproc_segmentation(parameters) disp('Checking inputs...'); % Define paths to T1 and T2 images - filename_t1 = fullfile(parameters.data_path, sprintf(parameters.t1_path_template, parameters.subject_id)); - filename_t2 = fullfile(parameters.data_path, sprintf(parameters.t2_path_template, parameters.subject_id)); + filename_t1 = fullfile(parameters.path.anat, sprintf(parameters.path.t1_pattern, parameters.subject_id)); + filename_t2 = fullfile(parameters.path.anat, sprintf(parameters.path.t2_pattern, parameters.subject_id)); % Validate existence of files files_to_check = {filename_t1, filename_t2}; @@ -21,26 +21,24 @@ function preproc_segmentation(parameters) disp('Starting segmentation...'); % Define output folder for segmentation results - segmentation_folder = fullfile(parameters.seg_path, sprintf('m2m_sub-%03d', parameters.subject_id)); + segmentation_folder = fullfile(parameters.path.seg, sprintf('m2m_sub-%03d', parameters.subject_id)); - if strcmp(parameters.segmentation_software, 'charm') - filename_segmented = fullfile(segmentation_folder, 'final_tissues.nii.gz'); - else - filename_segmented = fullfile(segmentation_folder, sprintf('sub-%03d_final_contr.nii.gz', parameters.subject_id)); - end + filename_segmented = fullfile(segmentation_folder, 'final_tissues.nii.gz'); % Run segmentation (if necessary) if confirm_overwriting(filename_segmented, parameters) && ... - (~isfield(parameters,'overwrite_simnibs') || parameters.overwrite_simnibs || ~exist(filename_segmented,'file')) - if parameters.usepseudoCT == 1 + (~isfield(parameters.io,'overwrite_simnibs') || parameters.io.overwrite_simnibs || ~exist(filename_segmented,'file')) + if parameters.pct.enabled == 1 % Note: This could be improved by allowing to specify a UTE/CT path in the config... warning("SimNIBS integration not supported when requesting pseudoCT. Please ensure SimNIBS has been run."); end - if parameters.interactive == 0 || confirmation_dlg('This will run SEGMENTATION WITH SIMNIBS that takes a long time. Are you sure?', 'Yes', 'No') - segmentation_run(parameters.data_path, parameters.subject_id, filename_t1, filename_t2, parameters); + if parameters.simulation.interactive == 0 || confirmation_dlg('This will run SEGMENTATION WITH SIMNIBS that takes a long time. Are you sure?', 'Yes', 'No') + segmentation_run(parameters.path.anat, parameters.subject_id, filename_t1, filename_t2, parameters); + parameters = simnibs_version(segmentation_folder, parameters); return; end else - disp('Skipping segmentation; loading existing file instead.'); + disp('Segmentation available...'); + parameters = simnibs_version(segmentation_folder, parameters); end diff --git a/functions/head/segmentation_run.m b/functions/head/segmentation_run.m index a3e23c68..6591c43f 100644 --- a/functions/head/segmentation_run.m +++ b/functions/head/segmentation_run.m @@ -1,70 +1,62 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, parameters) % set segmentation path to data_path if no specific seg_path is defined - if ~isfield(parameters, 'seg_path') || isempty(parameters.seg_path) - parameters.seg_path = data_path; + if ~isfield(parameters, 'path') || ~isfield(parameters.path, 'seg') || isempty(parameters.path.seg) + parameters.path.seg = data_path; end - % Create log directory if it does not exist + % Create log directory in segmentaion folder (if it does not exist) - log_dir = fullfile(parameters.sim_path, 'batch_job_logs'); + log_dir = fullfile(parameters.path.seg, 'batch_job_logs'); if ~isfolder(log_dir) mkdir(log_dir) end subj_id_string = sprintf('sub-%03d', subject_id); - % If no segmentation software is specified, default to SimNIBS' charm - if ~isfield(parameters, 'segmentation_software') - parameters.segmentation_software = 'charm'; + % Check if the last file produced in the charm pipeline exists. If + % other files are missing charm will produce the '--forcerun has to + % be set' error. Setting 'overwrite_simnibs' to 1 will resolve this. + result_simnibs = sprintf('%sm2m_sub-%03d/final_tissues.nii.gz', parameters.path.seg, subject_id); + if ~exist(result_simnibs, 'file') + parameters.io.overwrite_simnibs = 1; end - - if strcmp(parameters.segmentation_software, 'charm') - % Check if the last file produced in the charm pipeline exists. If - % other files are missing Charm will produce the '--forcerun has to - % be set' error. Setting 'overwrite_simnibs' to 1 will resolve this. - result_simnibs = sprintf('%sm2m_sub-%03d/final_tissues.nii.gz', parameters.seg_path, subject_id); - if ~exist(result_simnibs, 'file') - parameters.overwrite_simnibs = 1; - end - if ~isempty(filename_t2) - segment_call = sprintf('charm %s %s %s',... - subj_id_string,filename_t1,filename_t2); - else - segment_call = sprintf('charm %s %s',... - subj_id_string,filename_t1); - end - if isfield(parameters, 'overwrite_simnibs') && parameters.overwrite_simnibs == 1 - segment_call = [segment_call ' --forcerun']; - end - if isfield(parameters, 'use_forceqform') && parameters.use_forceqform == 1 - segment_call = [segment_call ' --forceqform']; - end - if isfield(parameters, 'charm_debug') && parameters.charm_debug == 1 - segment_call = [segment_call ' --debug']; - end + if ~isempty(filename_t2) + segment_call = sprintf('charm %s %s %s', subj_id_string, filename_t1, filename_t2); else - if ~isempty(filename_t2) - segment_call = sprintf('headreco all %s %s %s -d no-conform',... - subj_id_string, filename_t1, filename_t2); - else - segment_call = sprintf('headreco all %s %s -d no-conform',... - subj_id_string, filename_t1); - end + segment_call = sprintf('charm %s %s', subj_id_string, filename_t1); + end + if isfield(parameters, 'overwrite_simnibs') && parameters.io.overwrite_simnibs == 1 + segment_call = [segment_call ' --forcerun']; + end + if isfield(parameters, 'segmentation') && isfield(parameters.segmentation, 'use_qform') && parameters.segmentation.use_qform == 1 + segment_call = [segment_call ' --forceqform']; + end + if isfield(parameters, 'segmentation') && isfield(parameters.segmentation, 'debug') && parameters.segmentation.debug == 1 + segment_call = [segment_call ' --debug']; end - % if not running on a qsub or slurm HPC, the job will stop to run the segmentation manually - if strcmp(parameters.submit_medium, 'qsub') + % Platform selection + if strcmp(parameters.platform, 'auto') + platform = hpc_detect_system(); + parameters.platform = platform; + fprintf('➀ auto-detected: %s\n', upper(platform)); + else + platform = parameters.platform; + fprintf('➀ deploying: %s\n', upper(platform)); + end + + % Deploy on selected platform + if strcmp(parameters.platform, 'qsub') qsub_call = sprintf('qsub -N %s -l "nodes=1:ppn=1,mem=20Gb,walltime=24:00:00" -v MANPATH -o %s -e %s -d %s', ... - ['simnibs-', subj_id_string], ... + ['simnibs-', subj_id_string], ... fullfile(log_dir, sprintf('%s_qsub_segment_output_$timestamp.log', subj_id_string)),... - fullfile(log_dir, sprintf('%s_qsub_segment_error_$timestamp.log', subj_id_string)), ... - parameters.seg_path); - + fullfile(log_dir, sprintf('%s_qsub_segment_error_$timestamp.log', subj_id_string)), ... + parameters.path.seg); % execute simnibs call in segmentation directory full_cmd = sprintf('cd %s; timestamp=$(date +%%Y%%m%%d_%%H%%M%%S); echo "%s/%s" | %s', ... - parameters.seg_path, parameters.simnibs_bin_path, segment_call, qsub_call); + parameters.path.seg, parameters.startup.simnibs_bin_path, segment_call, qsub_call); % 3) submit segmentation job fprintf('Running segmentation with a command \n%s\n', full_cmd) @@ -74,7 +66,7 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param fprintf('Now wait for the job with the id listed above to finish') - elseif strcmp(parameters.submit_medium, 'slurm') + elseif strcmp(parameters.platform, 'slurm') % Create a temporary SLURM batch script file temp_slurm_file = tempname(log_dir); @@ -92,13 +84,13 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param % Add environment setup fprintf(fid, 'source /etc/profile\n'); % load system-wide environment variable setups and shell initialization commands - fprintf(fid, 'export PATH=%s:$PATH\n', parameters.simnibs_bin_path); - fprintf(fid, 'export LD_LIBRARY_PATH=%s:$LD_LIBRARY_PATH\n', parameters.ld_library_path); + fprintf(fid, 'export PATH=%s:$PATH\n', parameters.startup.simnibs_bin_path); + fprintf(fid, 'export LD_LIBRARY_PATH=%s:$LD_LIBRARY_PATH\n', parameters.hpc.ld_library_path); % Add segmentation call - fprintf(fid, 'cd %s\n', parameters.seg_path); + fprintf(fid, 'cd %s\n', parameters.path.seg); fprintf(fid, 'charm --version\n'); - fprintf(fid, '%s\n', [parameters.simnibs_bin_path, '/', segment_call]); + fprintf(fid, '%s\n', [parameters.startup.simnibs_bin_path, '/', segment_call]); fclose(fid); % Ensure script is executable @@ -114,9 +106,37 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param display(out); fprintf('Now wait for the job with the id listed above to finish') + + elseif strcmp(parameters.platform, 'matlab') + fprintf('Running segmentation locally:\n%s\n', segment_call); + + if ~isfield(parameters.startup, 'simnibs_bin_path') || isempty(parameters.startup.simnibs_bin_path) + error('simnibs_bin_path required for local execution'); + end + + % Use FULL PATH to charm (don't rely on PATH) + full_segment_call = sprintf('%s/%s', parameters.startup.simnibs_bin_path, segment_call); + fprintf('Full command: %s\n', full_segment_call); + + orig_dir = pwd; + try + cd(parameters.path.seg); + [res, out] = system(full_segment_call); + cd(orig_dir); + + if res == 0 + fprintf('Segmentation completed successfully.\n'); + else + error('Segmentation failed (exit code %d):\n%s', res, out); + end + catch ME + cd(orig_dir); + rethrow(ME); + end + else - fprintf('To get segmented head files, you need to run the segmentation software with a command: \n%s\n The script will stop for now, rerun it when the segmentation has finished.', segment_call) - error('Submission medium %s is not available for automatic segmentation.', parameters.submit_medium); + fprintf('To get segmented head files, you need to run the segmentation software with a command: \n%s\n The script will stop for now, rerun it when the segmentation has finished.\n', segment_call) + error('Submission medium %s is not available for automatic segmentation.', parameters.platform); end end diff --git a/functions/head/skull_fill_holes.m b/functions/head/skull_fill_holes.m index fdff1fcf..689bf3e0 100644 --- a/functions/head/skull_fill_holes.m +++ b/functions/head/skull_fill_holes.m @@ -1,4 +1,4 @@ -function [medium_masks, skull_i] = skull_fill_holes(parameters, medium_masks, labels, focus_pos_grid, segmented_img) +function [medium_masks, skull_i] = skull_fill_holes(parameters, medium_masks, focus_pos_grid, segmented_img) % skull_fill_holes Expands skull mask and fills gaps to skin. % % This function processes a 3D medium mask used in ultrasound brain stimulation simulations (e.g., k-Wave). @@ -8,8 +8,6 @@ % % Inputs: % parameters - Struct with simulation parameters: -% - smooth_window (numeric) Window size for smoothing bone image. -% - smooth_threshold_skull (numeric) Threshold for skull smoothing. % - debug (1/0) Enable debug plots. % - debug_dir (string) Directory for saving debug images. % - subject_id (numeric) Subject ID for filename. @@ -17,7 +15,6 @@ % - results_filename_affix (string) Affix for output filenames. % - seg_labels.eye (optional int) Label index for eye tissue. % medium_masks - 3D array of initial medium labels (updated in-place and returned). -% labels - Cell array of tissue label names (e.g., {'skin', 'skull_cortical'}). % focus_pos_grid - 1x3 vector [x,y,z] indices of focus position for debug slice (y-slice used). % segmented_img - 3D array of original segmentation labels. % @@ -26,11 +23,10 @@ % skull_i - Integer label value for skull tissue. % % Dependencies: -% - smooth_img (custom function): Smooths bone image. % - Image Processing Toolbox: imerode, strel, imfill, bwlabeln, regionprops, label2rgb, imshowpair, montage. % % Example: -% medium_masks = skull_fill_holes(params, medium_masks, labels, 2, bone_img, focus_pos, seg_img, trab_mask, 3); +% medium_masks = skull_fill_holes(params, medium_masks, 2, bone_img, focus_pos, seg_img, trab_mask, 3); % % Notes: % - Assumes voxel-based 3D head model from neuroimaging (e.g., CT/MRI segmentation). @@ -38,11 +34,14 @@ % - Gap filling identifies largest non-skin-skull blob (likely CSF/air) and ignores it. % - Eyes default to water (label 0) to avoid erroneous bone assignment. - if parameters.usepseudoCT == 0 && any(contains(labels, 'skull_cortical')) + labels_medium = fieldnames(parameters.medium_properties); + labels_requested = fieldnames(parameters.layers); + + if parameters.pct.enabled == 0 && any(contains(labels_requested, 'skull_cortical')) % treat cortical bone as the base layer - skull_i = find(ismember(labels, {'skull_cortical'; 'skull_trabecular'})); + skull_i = find(ismember(labels_medium, {'skull_cortical'; 'skull_trabecular'})); else - skull_i = find(strcmp(labels, 'skull')); + skull_i = find(strcmp(labels_medium, 'skull')); end % Retain pre-fill medium mask for later plotting @@ -50,7 +49,7 @@ % Ensure continuous skull (fill small holes, connect thin regions) skull = ismember(medium_masks, skull_i); - if isfield(parameters, 'skullfillmethod') && strcmp(parameters.skullfillmethod, 'rubberwrap') + if isfield(parameters.headmodel, 'skull_fill_method') && strcmp(parameters.headmodel.skull_fill_method, 'rubberwrap') % Local skull filling with rubber expansion skull_continuous = skull_rubber_wrap(parameters, skull, medium_masks, segmented_img); else @@ -62,38 +61,39 @@ medium_masks(skull_new) = skull_i(1); % Only add new voxels as cortical bone (if differentiated) % [DEBUG] Plot skull expansion at focus y-slice - if parameters.debug == 1 + if parameters.simulation.debug == 1 h = figure; montage({1-squeeze(skull(:,focus_pos_grid(2),:)), ... 1-squeeze(skull_new(:,focus_pos_grid(2),:)), ... 1-squeeze(skull_continuous(:,focus_pos_grid(2),:))}, 'Size', [1 3]); title('Skull (left), added bone (center), continuous skull (right)'); - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_%s_skull_expansion%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png'); close(h); end % Label eye layer as water - if isfield(parameters.seg_labels, 'eye') - eye_i = parameters.seg_labels.eye; + seg_labels = charm_seg_labels(); + if isfield(seg_labels, 'eye') + eye_i = seg_labels.eye; eye = segmented_img == eye_i; - i_water = find(strcmp(fieldnames(parameters.medium), 'water')); + i_water = find(strcmp(fieldnames(parameters.medium_properties), 'water')); medium_masks(eye ~= 0) = i_water; % Default to water end % [DEBUG] Plot before/after gap filling at focus y-slice - if parameters.debug == 1 + if parameters.simulation.debug == 1 medium_mask_updates = double(medium_masks-medium_with_gaps); medium_mask_updates(medium_mask_updates ~=0) = 1; % indicate where changes occured h = figure; imshowpair(label2rgb(squeeze(medium_mask_updates(:,focus_pos_grid(2),:)), 'parula'), ... label2rgb(squeeze(medium_masks(:,focus_pos_grid(2),:)), 'parula'), 'montage'); title('Updated (left) and closed off (right) segmented images'); - output_plot_filename = fullfile(parameters.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_%s_segmented_img_closing_gaps_changes%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png'); close(h); end diff --git a/functions/head/skull_rubber_wrap.m b/functions/head/skull_rubber_wrap.m index 0ea671eb..a0196f27 100644 --- a/functions/head/skull_rubber_wrap.m +++ b/functions/head/skull_rubber_wrap.m @@ -11,7 +11,7 @@ % ============================================================================== % Load the T1w input image as a reference - info = niftiinfo(fullfile(parameters.seg_path, parameters.t1_path_template)); + info = niftiinfo(fullfile(parameters.path.seg, parameters.path.t1_pattern)); % ============================================================================== % CLEAN SKULL MASK @@ -33,8 +33,8 @@ %% --- Rubber wrap parameters --- - if isfield(parameters, 'wrapradius') && ~isempty(parameters.wrapradius) - wrapRadius = parameters.wrapradius; + if isfield(parameters.headmodel, 'skull_wrap_radius') && ~isempty(parameters.headmodel.skull_wrap_radius) + wrapRadius = parameters.headmodel.skull_wrap_radius; else wrapRadius = 10; % skull rubber wrap radius [grid voxels]; end @@ -59,14 +59,14 @@ BW = uint8(BWwrap2); clear BWwrap2; % [debug] save balloon wrap mask - if parameters.debug == 1 + if parameters.simulation.debug == 1 - outNii = fullfile(parameters.debug_dir, ... - sprintf('balloon_mask%s.nii', parameters.results_filename_affix)); + outNii = fullfile(parameters.io.debug_dir, ... + sprintf('balloon_mask%s.nii', parameters.io.output_affix)); infoOut = info; infoOut.ImageSize = size(BW); - infoOut.PixelDimensions = repmat(parameters.grid_step_mm,1,numel(size(BW))); + infoOut.PixelDimensions = repmat(parameters.grid.resolution_mm,1,numel(size(BW))); infoOut.Datatype = 'uint8'; infoOut.BitsPerPixel = 8; infoOut.Filename = outNii; @@ -89,34 +89,43 @@ error("Size mismatch: balloon %s vs tissues %s", mat2str(size(BW)), mat2str(size(T))); end - GM_LABEL = find(strcmp(fieldnames(parameters.layers), 'brain')); - SKIN_LABEL = find(strcmp(fieldnames(parameters.layers), 'skin')); + tissues_available = fieldnames(parameters.layers); + medium_labels = fieldnames(parameters.medium_properties); - GM = (T == GM_LABEL); - SKIN = (T == SKIN_LABEL); + if ismember(tissues_available, 'brain') + BRAIN_LABEL = find(strcmp(medium_labels, 'brain')); + BRAIN = (T == BRAIN_LABEL); + else + BRAIN = zeros(size(T)); + end + + if ismember(tissues_available, 'brain') + SKIN_LABEL = find(strcmp(medium_labels, 'skin')); + SKIN = (T == SKIN_LABEL); + else + SKIN = zeros(size(T)); + end - %% "Touch" masks: balloon voxel touches GM if it lies within 1-voxel dilation of GM + %% "Touch" masks: balloon voxel touches BRAIN if it lies within 1-voxel dilation of BRAIN touchRadius = 1; % voxels: 1 = immediate neighbors (26-neighborhood) se = strel("sphere", touchRadius); - GM_touch_region = imdilate(GM, se); + GM_touch_region = imdilate(BRAIN, se); SKIN_touch_region = imdilate(SKIN, se); % Keep balloon voxels that touch BOTH B_touch_both = BW & GM_touch_region & SKIN_touch_region; - % Remove overlap with GM from the remaining mask - B_out = B_touch_both & ~GM; + % Remove overlap with BRAIN from the remaining mask + B_out = B_touch_both & ~BRAIN; %% Include CSF voxels at the SKIN interface (strict 6-neigh), near B_out % Here, we refer to the more detailed segmentation - CSF_LABEL = [getidx(parameters.seg_labels, {'csf'})]; - + CSF_LABEL = charm_seg_labels().csf; CSF = (ismember(segmented_img, CSF_LABEL)); - SKIN = (T == SKIN_LABEL); % --- Strict 6-neighborhood kernel (faces only) --- K6 = zeros(3,3,3,'logical'); @@ -144,8 +153,8 @@ % --- Add MEN interface voxels to balloon mask --- B_out2 = B_out | CSF_touch_SKIN_6_near; - % Keep your original constraint: no GM overlap - B_out2 = B_out2 & ~GM; + % Keep your original constraint: no BRAIN overlap + B_out2 = B_out2 & ~BRAIN; B_out = BW1 | B_out2; @@ -168,14 +177,14 @@ %% [DEBUG] Save as .nii.gz - if parameters.debug == 1 + if parameters.simulation.debug == 1 - outNii = fullfile(parameters.debug_dir, ... - sprintf('balloon_mask_final%s.nii', parameters.results_filename_affix)); + outNii = fullfile(parameters.io.debug_dir, ... + sprintf('balloon_mask_final%s.nii', parameters.io.output_affix)); infoOut = info; infoOut.ImageSize = size(SKULL_BALLON); - infoOut.PixelDimensions = repmat(parameters.grid_step_mm,1,numel(size(SKULL_BALLON))); + infoOut.PixelDimensions = repmat(parameters.grid.resolution_mm,1,numel(size(SKULL_BALLON))); infoOut.Datatype = 'uint8'; infoOut.BitsPerPixel = 8; infoOut.Filename = outNii; @@ -192,7 +201,7 @@ % Note: this seems computationally heavy on the HPC % There are like;ly ways to optimize, but it will be inactive for now. - if parameters.skullwrap_visualize == 1 + if parameters.headmodel.skull_wrap_visualize == 1 skull_rubber_wrap_visualize(parameters, SKULL, ZADDED, BALLOON); end diff --git a/functions/head/skull_rubber_wrap_visualize.m b/functions/head/skull_rubber_wrap_visualize.m index 51edbda1..aded2d47 100644 --- a/functions/head/skull_rubber_wrap_visualize.m +++ b/functions/head/skull_rubber_wrap_visualize.m @@ -7,8 +7,8 @@ function skull_rubber_wrap_visualize(parameters, SKULL, ZADDED, BALLOON, downsam % - Reduces rendering load: no edges, no gouraud, optional point cap % % Inputs: -% parameters.debug_dir -% parameters.results_filename_affix +% parameters.io.debug_dir +% parameters.io.output_affix % SKULL, ZADDED, BALLOON : 3-D logical or numeric masks (same size) % downsample_factor : (optional) integer >=1 @@ -153,8 +153,8 @@ function draw_view(ax, viewvec, ttl, msize) %% Fast raster export (avoid slow vector pipeline) - output_png = fullfile(parameters.debug_dir, ... - sprintf('skull_visualization%s.png', parameters.results_filename_affix)); + output_png = fullfile(parameters.io.debug_dir, ... + sprintf('skull_visualization%s.png', parameters.io.output_affix)); drawnow limitrate; fr = getframe(fig); diff --git a/functions/head/smooth_img.m b/functions/head/smooth_img.m index 4d2b56c1..da99b031 100644 --- a/functions/head/smooth_img.m +++ b/functions/head/smooth_img.m @@ -1,55 +1,108 @@ -function thresholded_img = smooth_img(unsmoothed_img, smooth_window, threshold, method) -%SMOOTH_IMG - Smooth tissue masks +function thresholded_img = smooth_img(unsmoothed_img, fwhm_mm, voxel_size_mm, threshold, method) +%SMOOTH_IMG - Smooth image with FWHM [mm] % % SYNOPSIS: -% smoothed = smooth_img(binary_mask, smooth_window, threshold, 'anisotropic') +% smoothed = smooth_img(binary_mask, 1.0, 0.5, 0.5, 'gaussian') % % INPUT: -% unsmoothed_img - [Nx Ny Nz] logical/double binary mask -% smooth_window - Controls smoothing strength (iterations for AD) -% threshold - Post-smoothing binarization [0.1-0.9] -% method - Filter type ['gaussian'|'box'] +% unsmoothed_img - Input image [Nx Ny Nz] or [Nx Ny] +% fwhm_mm - FWHM in mm (scalar β†’ isotropic, or 1xN per dim) +% voxel_size_mm - Voxel spacing in mm (scalar or [dx dy (dz)]) +% threshold - Post-smoothing binarization [0.1-0.9] | 0: continuous +% method - ['gaussian'|'box'] % % OUTPUT: -% thresholded_img - [Nx Ny Nz] logical smoothed binary mask +% thresholded_img - Smoothed (binary if threshold > 0) % -% DEFAULT BEHAVIOR: -% method='gaussian' -% iterations=smooth_window*2, conduction=0.125 (skull-optimized) +% DEFAULT: fwhm_mm=1.0, voxel_size_mm=0.5 +% +% HOW IT WORKS: +% Convert FWHM(mm) β†’ FWHM(voxels) = fwhm_mm ./ voxel_size_mm +% GAUSSIAN: FWHM(voxels) β†’ Οƒ(voxels) = fwhm_voxels/2.35482 β†’ imgaussfilt(3) +% BOX: Kernel size = round(FWHM(voxels)) per dim (no min size) + arguments unsmoothed_img {mustBeNumericOrLogical} - smooth_window (1,1) double {mustBePositive} = 4 + fwhm_mm (1,:) double {mustBeNonnegative} = 1.0 + voxel_size_mm (1,:) double {mustBePositive} = 0.5 threshold (1,1) double {mustBeInRange(threshold, 0, 1)} = 0.5 method string {mustBeMember(method, ["gaussian", "box"])} = "gaussian" end - + img = double(unsmoothed_img); ndims_img = ndims(img); - - if ndims_img == 2 - switch method - case "gaussian" - sigma = smooth_window / 3; - filter_size = max(2, 2 * round(smooth_window / 2) + 1); % Force odd: 5,7,9... with minimal size 2 - smoothed_img = imgaussfilt(img, sigma, 'FilterSize', filter_size); - disp(['Gaussian filtering: sigma ', num2str(sigma) ,' size ', num2str(filter_size), ' voxels']); - case "box" - kernel = ones(smooth_window, smooth_window) / smooth_window^2; - smoothed_img = imfilter(img, kernel, 'replicate'); - end - elseif ndims_img == 3 - switch method - case "gaussian" - filter_size = max(2, 2 * round(smooth_window / 2) + 1); % Also fix for consistency - smoothed_img = smooth3(img, 'gaussian', filter_size); - disp(['Gaussian filtering: size ', num2str(filter_size), ' voxels']); - case "box" - kernel = ones(smooth_window, smooth_window, smooth_window) / smooth_window^3; - smoothed_img = convn(img, kernel, 'same'); + + % Normalize fwhm_mm and voxel_size_mm to length ndims_img + if isscalar(fwhm_mm) + fwhm_mm = repmat(fwhm_mm, 1, ndims_img); + end + if isscalar(voxel_size_mm) + voxel_size_mm = repmat(voxel_size_mm, 1, ndims_img); + end + if numel(fwhm_mm) ~= ndims_img || numel(voxel_size_mm) ~= ndims_img + error('fwhm_mm and voxel_size_mm must be scalar or match image dimensionality (%dD).', ndims_img); + end + + % Convert FWHM(mm) β†’ FWHM(voxels) per dimension + fwhm_voxels = fwhm_mm ./ voxel_size_mm; + + % Convert FWHM(voxels) β†’ Οƒ(voxels) for Gaussian [web:9] + sigma_voxels = fwhm_voxels / 2.35482; % FWHM = 2.35482 Γ— Οƒ [web:9] + + if any(fwhm_voxels > 0) + fprintf('Smoothing: FWHM=[%s] mm, voxel=[%s] mm β†’ FWHM=[%s] vox β†’ Οƒ=[%s] vox\n', ... + num2str(fwhm_mm), num2str(voxel_size_mm), num2str(fwhm_voxels), num2str(sigma_voxels)); + + if ndims_img == 2 + sig = sigma_voxels(1:2); + + switch method + case "gaussian" + % imgaussfilt expects sigma in pixels/voxels [web:1][web:2] + smoothed_img = imgaussfilt(img, sig); + + case "box" + % Kernel size = round(FWHM voxels), no minimum + ksz = round(fwhm_voxels(1:2)); + if any(ksz == 0) + smoothed_img = img; % No smoothing + return; + end + % Force odd kernel size + ksz = ksz + mod(ksz+1,2); + kernel = ones(ksz) / prod(ksz); + smoothed_img = imfilter(img, kernel, 'replicate'); % Works for ksz=1 [web:24] + end + + elseif ndims_img == 3 + sig = sigma_voxels(1:3); + + switch method + case "gaussian" + % imgaussfilt3 sigma also in voxels [web:4] + smoothed_img = imgaussfilt3(img, sig); + + case "box" + ksz = round(fwhm_voxels(1:3)); + if any(ksz == 0) + smoothed_img = img; % No smoothing + return; + end + ksz = ksz + mod(ksz+1,2); + kernel = ones(ksz) / prod(ksz); + smoothed_img = convn(img, kernel, 'same'); % Works for ksz=1 [web:21] + end + else + error('Supports 2D/3D only (ndims=%d)', ndims_img); end else - error('Supports 2D/3D only (ndims=%d)', ndims_img); + smoothed_img = img; % No smoothing + end + + % Post-process binarization (optional) + if threshold > 0 + thresholded_img = smoothed_img > threshold; + else + thresholded_img = smoothed_img; end - - thresholded_img = smoothed_img > threshold; end diff --git a/functions/helper/charm_seg_labels.m b/functions/helper/charm_seg_labels.m new file mode 100644 index 00000000..97bfa1b2 --- /dev/null +++ b/functions/helper/charm_seg_labels.m @@ -0,0 +1,39 @@ +function labels = charm_seg_labels() +% CHARM_SEG_LABELS Returns the fixed SimNIBS charm tissue label indices. +% +% labels = charm_seg_labels() +% +% Returns a struct with named fields mapping tissue names to their integer +% label values as produced by SimNIBS charm segmentation. These values +% are fixed by the charm pipeline and should not need to be changed. +% +% Fields: +% bonemask - All tissues within the skull [1,2,3,4,7,8,9] +% intracranial - Intracranial tissues (skull excluded) [1,2,3,9] +% external - Background / external [0] +% wm - White matter [1] +% gm - Grey matter [2] +% csf - Cerebrospinal fluid [3] +% skull - Skull (combined) [4] +% skin - Skin [5] +% eye - Eye [6] +% skull_cortical - Cortical bone [7] +% skull_trabecular- Trabecular bone [8] +% blood - Blood vessels [9] +% muscle - Muscle [10] + +labels.bonemask = [1, 2, 3, 4, 7, 8, 9]; +labels.intracranial = [1, 2, 3, 9]; +labels.external = 0; +labels.wm = 1; +labels.gm = 2; +labels.csf = 3; +labels.skull = 4; +labels.skin = 5; +labels.eye = 6; +labels.skull_cortical = 7; +labels.skull_trabecular = 8; +labels.blood = 9; +labels.muscle = 10; + +end diff --git a/functions/helper/check_layers.m b/functions/helper/check_layers.m index c60ef3cf..05b5b85e 100644 --- a/functions/helper/check_layers.m +++ b/functions/helper/check_layers.m @@ -9,7 +9,7 @@ parameters.layers.water = seg_values; % [pCT] combine cortical and trabecular layers (if specified) - if parameters.usepseudoCT + if parameters.pct.enabled % Collect ALL skull layer indices skull_layers = []; if isfield(parameters.layers, 'skull') diff --git a/functions/helper/confirm_overwriting.m b/functions/helper/confirm_overwriting.m index c866f1d0..8b51890a 100644 --- a/functions/helper/confirm_overwriting.m +++ b/functions/helper/confirm_overwriting.m @@ -3,7 +3,7 @@ % CONFIRM_OVERWRITING Handles file overwriting based on user preferences and interactivity. % % This function checks whether a file should be overwritten based on its existence, -% user-defined parameters (`parameters.overwrite_files`), and interactive mode settings. +% user-defined parameters (`parameters.io.overwrite_files`), and interactive mode settings. % If interactive mode is enabled, the user is prompted to confirm overwriting. % % Input: @@ -16,18 +16,18 @@ % overwrite - Boolean flag indicating whether the file should be overwritten (1 = yes, 0 = no). % Check if the file does not exist or if overwriting is set to 'always' - if ~exist(filename, 'file') || strcmp(parameters.overwrite_files, 'always') + if ~exist(filename, 'file') || strcmp(parameters.io.overwrite_files, 'always') overwrite = 1; % Overwrite allowed return; % If overwriting is set to 'never', do not overwrite - elseif strcmp(parameters.overwrite_files, 'never') + elseif strcmp(parameters.io.overwrite_files, 'never') overwrite = 0; % Do not overwrite return; end % Ensure valid behavior in non-interactive mode - if parameters.interactive == 0 - error("In non-interactive mode, parameters.overwrite_files should be 'always' or 'never'"); + if parameters.simulation.interactive == 0 + error("In non-interactive mode, parameters.io.overwrite_files should be 'always' or 'never'"); end % Interactive mode: Prompt user for confirmation to overwrite the file diff --git a/functions/helper/simnibs_version.m b/functions/helper/simnibs_version.m new file mode 100644 index 00000000..8f4da398 --- /dev/null +++ b/functions/helper/simnibs_version.m @@ -0,0 +1,44 @@ +function parameters = simnibs_version(segmentation_folder, parameters) +%LOG_SIMNIBS_VERSION Extract SimNIBS version from charm_log.html and store in parameters +% +% Inputs: +% segmentation_folder - Path to folder containing charm_log.html +% parameters - Existing parameters struct +% +% Output: +% parameters - Updated with .simnibs_version field + + log_file = fullfile(segmentation_folder, 'charm_log.html'); + + if ~exist(log_file, 'file') + warning('charm_log.html not found in %s', segmentation_folder); + parameters.simnibs_version = 'unknown'; + fprintf('LOG: SimNIBS version: UNKNOWN (no charm_log.html)\n'); + return; + end + + try + % Read first few lines (fast) + fid = fopen(log_file, 'r'); + first_line = fgetl(fid); + fclose(fid); + + % Parse "INFO: simnibs version X.Y.Z" + pattern = 'INFO:\s*simnibs\s*version\s*([\d.]+)'; + tokens = regexp(first_line, pattern, 'tokens'); + + if ~isempty(tokens) + version = tokens{1}{1}; + parameters.simnibs_version = version; + fprintf('SimNIBS version %s detected from %s\n', version, log_file); + else + parameters.simnibs_version = 'unknown'; + fprintf('SimNIBS version UNKNOWN (could not parse "%s")\n', first_line); + end + + catch ME + warning('Error parsing %s: %s', log_file, ME.message); + parameters.simnibs_version = 'error'; + fprintf('SimNIBS version: ERROR\n'); + end +end diff --git a/functions/helper/tissuemask_binary.m b/functions/helper/tissuemask_binary.m index ba5fdd46..7cdf93c5 100644 --- a/functions/helper/tissuemask_binary.m +++ b/functions/helper/tissuemask_binary.m @@ -1,6 +1,6 @@ function mask = tissuemask_binary(parameters, medium_masks) - labels = fieldnames(parameters.medium); + labels = fieldnames(parameters.medium_properties); skull_i = find(strcmp(labels, 'skull')); cortical_i = find(strcmp(labels, 'skull_cortical')); diff --git a/functions/hpc/hpc_detect_system.m b/functions/hpc/hpc_detect_system.m new file mode 100644 index 00000000..2778c910 --- /dev/null +++ b/functions/hpc/hpc_detect_system.m @@ -0,0 +1,17 @@ +function hpc_type = hpc_detect_system() +%% HPC_DETECT_SYSTEM Detect SLURM or qsub HPC system +% +% Detects available HPC scheduler by checking for sbatch (SLURM) or qsub. +% Returns 'slurm', 'qsub', or throws error if neither found. + +[sbatch_out, sbatch_path] = system('which sbatch'); +[qsub_out, qsub_path] = system('which qsub'); + +if sbatch_out == 0 + hpc_type = 'slurm'; +elseif qsub_out == 0 + hpc_type = 'qsub'; +else + error('No HPC system detected (sbatch/qsub)'); +end +end diff --git a/functions/hpc/hpc_job_info.m b/functions/hpc/hpc_job_info.m new file mode 100644 index 00000000..ffb33efb --- /dev/null +++ b/functions/hpc/hpc_job_info.m @@ -0,0 +1,49 @@ +function display_info = hpc_job_info(platform, job_id, job_name, ... + memory_gb, timelimit, log_dir, visualize) +%% HPC_JOB_INFO Generate formatted job display information +% +% Creates structured display info for job submission feedback. +% +% Inputs: +% platform - 'slurm' or 'qsub' +% job_id - Raw job ID from submission +% job_name - Job name string (encodes subject ID) +% memory_gb - Memory allocation (GB) +% timelimit - Time limit string +% log_dir - Log directory path +% visualize - Display job info? +% +% Output: +% display_info - Struct with all formatted display fields + + % Format job ID + if strcmp(platform, 'slurm') && isnumeric(job_id) + display_info.id = sprintf('%d', job_id); + display_info.check_cmd = sprintf('squeue -u $USER | grep %s', job_name); + display_info.detail_cmd = sprintf('sacct -j %s', display_info.id); + else + display_info.id = string(job_id); + display_info.check_cmd = sprintf('qstat %s', display_info.id); + display_info.detail_cmd = display_info.check_cmd; + end + + % Common fields + display_info.name = job_name; + display_info.memory_gb = memory_gb; + display_info.timelimit = timelimit; + display_info.log_dir = log_dir; + + if visualize == true + fprintf('\nβš™οΈ JOB INFO\n'); + fprintf('═════════════════════════════\n'); + fprintf('Job ID: %s\n', display_info.id); + fprintf('Job name: %s\n', display_info.name); + fprintf('Subject: %s\n', job_name); + fprintf('Memory: %.0f GB\n', display_info.memory_gb); + fprintf('Time limit: %s\n', display_info.timelimit); + fprintf('Log dir: %s\n', display_info.log_dir); + fprintf('Check: %s\n', display_info.check_cmd); + fprintf('Details: %s\n', display_info.detail_cmd); + fprintf('\n'); + end +end diff --git a/functions/hpc/hpc_job_name.m b/functions/hpc/hpc_job_name.m new file mode 100644 index 00000000..c2d93691 --- /dev/null +++ b/functions/hpc/hpc_job_name.m @@ -0,0 +1,25 @@ +function job_name = hpc_job_name(parameters) +%% HPC_JOB_NAME Generate standardized HPC job name +% +% job_name = hpc_job_name(parameters) +% +% Creates job name in format: PREFIX_sub-XXX where PREFIX is +% job_prefix from parameters (default: PRESTUS). +% Subject ID is read from parameters.subject_id. +% +% Inputs: +% parameters - Struct; must contain parameters.subject_id +% +% Output: +% job_name - String for scheduler job name (max 20 chars recommended) +% +% See also PRESTUS_PIPELINE_START, HPC_SUBMIT_JOB. + + subj_id_string = sprintf('sub-%03d', parameters.subject_id); + + if ~isfield(parameters.hpc, 'job_prefix') + parameters.hpc.job_prefix = 'PRESTUS'; + end + + job_name = [parameters.hpc.job_prefix '_' subj_id_string]; +end diff --git a/functions/hpc/hpc_setup_temp_files.m b/functions/hpc/hpc_setup_temp_files.m new file mode 100644 index 00000000..c4d8e269 --- /dev/null +++ b/functions/hpc/hpc_setup_temp_files.m @@ -0,0 +1,42 @@ +function [log_dir, prestus_path, temp_data_path, temp_m_path, temp_m_file] = ... + hpc_setup_temp_files(parameters) +%% HPC_SETUP_TEMP_FILES Setup directories and generate temporary files +% +% Creates output directory, log directory, and timestamped temporary files +% for MATLAB data and script. +% Subject ID is read from parameters.subject_id. +% +% Outputs: +% log_dir - Path to batch_job_logs directory +% prestus_path - PRESTUS path +% temp_data_path - Path for temporary .mat data file +% temp_m_path - Path for temporary .m script file +% temp_m_file - Basename of MATLAB script (no path) +% +% See also HPC_SUBMIT_JOB. + +% Setup output directory +if isfield(parameters, 'io') && isfield(parameters.path, 'subject_subfolder') && parameters.path.subject_subfolder + output_dir = fullfile(parameters.path.sim, sprintf('sub-%03d', parameters.subject_id)); +else + output_dir = parameters.path.sim; +end +if ~isfolder(output_dir), mkdir(output_dir); end + +% Setup batch/log directory +log_dir = fullfile(output_dir, 'batch_job_logs'); +if ~isfolder(log_dir), mkdir(log_dir); end + +prestus_path = get_prestus_path; + +% Generate temp files +timestamp = datestr(now, 'yyyymmdd_HHMMSS'); +temp_base = tempname(log_dir); +[~, temp_base_name] = fileparts(temp_base); +temp_base_name = temp_base_name(end-7:end); + +temp_data_path = fullfile(log_dir, sprintf('temp_data_%s_%s.mat', timestamp, temp_base_name)); +temp_m_file = sprintf('temp_matlab_%s_%s', timestamp, temp_base_name); +temp_m_path = fullfile(log_dir, [temp_m_file, '.m']); + +end diff --git a/functions/hpc/hpc_submit_job.m b/functions/hpc/hpc_submit_job.m new file mode 100644 index 00000000..462a1896 --- /dev/null +++ b/functions/hpc/hpc_submit_job.m @@ -0,0 +1,117 @@ +function job_id = hpc_submit_job(hpc_type, temp_m_file, parameters, log_dir) +%% HPC_SUBMIT_JOB Submit HPC batch job (SLURM or qsub) +% +% Generates scheduler script and submits job. Supports SLURM (sbatch) and +% PBS (qsub) with automatic GPU/partition detection. +% Subject ID is read from parameters.subject_id. +% +% Inputs: +% hpc_type - 'slurm' or 'qsub' +% temp_m_file - MATLAB script basename +% parameters - Job parameters; must contain parameters.subject_id +% log_dir - Log directory path +% +% Output: +% job_id - Job ID string/number +% +% See also HPC_DETECT_SYSTEM, HPC_WAIT_FOR_COMPLETION. + +subj_id_string = sprintf('sub-%03d', parameters.subject_id); + +switch hpc_type + case 'slurm' + temp_slurm_path = fullfile(log_dir, sprintf('temp_slurm_%s.sh', datestr(now, 'yyyymmdd_HHMMSS'))); + write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir); + job_id = submit_slurm_job(temp_slurm_path, log_dir); + + case 'qsub' + temp_qsub_path = fullfile(log_dir, sprintf('temp_qsub_%s.sh', datestr(now, 'yyyymmdd_HHMMSS'))); + write_qsub_script(temp_qsub_path, parameters, temp_m_file, log_dir); + job_id = submit_qsub_job(temp_qsub_path, log_dir); + + otherwise + error('Unsupported HPC type: %s', hpc_type); +end + +fprintf('Job "%s" (ID: %s) submitted successfully\n', hpc_job_name(parameters), sprintf('%d', job_id)); + +% ========== LOCAL FUNCTIONS ========== +function write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir) + subj_id_string = sprintf('sub-%03d', parameters.subject_id); + job_name = hpc_job_name(parameters); + + fid = fopen(temp_slurm_path, 'w+'); + fprintf(fid, '#!/bin/bash\n'); + fprintf(fid, '#SBATCH --job-name=%s\n', job_name); + + % Partition & GPU detection + needs_gpu = (isfield(parameters.simulation, 'code_type') && any(strcmp(parameters.simulation.code_type, {'matlab_gpu', 'cpp_gpu'}))); + + if isfield(parameters.hpc, 'partition') && ~isempty(strtrim(char(parameters.hpc.partition))) + fprintf(fid, '#SBATCH --partition=%s\n', strtrim(char(parameters.hpc.partition))); + elseif needs_gpu + fprintf(fid, '#SBATCH --partition=gpu\n'); + end + + if isfield(parameters.hpc, 'gpu') && ~isempty(strtrim(char(parameters.hpc.gpu))) + fprintf(fid, '#SBATCH --gres=%s\n', strtrim(char(parameters.hpc.gpu))); + elseif needs_gpu + fprintf(fid, '#SBATCH --gres=gpu:1\n'); + end + + if isfield(parameters.hpc, 'reservation') && ~isempty(strtrim(char(parameters.hpc.reservation))) + fprintf(fid, '#SBATCH --reservation=%s\n', strtrim(char(parameters.hpc.reservation))); + end + + fprintf(fid, '#SBATCH --mem=%iG\n', parameters.hpc.memorylimit); + fprintf(fid, '#SBATCH --time=%s\n', parameters.hpc.timelimit); + fprintf(fid, '#SBATCH --output=%s_slurm_output_%%j.log\n', subj_id_string); + fprintf(fid, '#SBATCH --error=%s_slurm_error_%%j.log\n', subj_id_string); + fprintf(fid, '#SBATCH --chdir=%s\n', log_dir); + + if needs_gpu, fprintf(fid, 'nvidia-smi\n'); end + fprintf(fid, 'module load matlab/R2023b\n'); + fprintf(fid, 'matlab -batch "%s"\n', temp_m_file); + fclose(fid); +end + +function job_id = submit_slurm_job(temp_slurm_path, log_dir) + sbatch_call = sprintf('sbatch %s', temp_slurm_path); + full_cmd = sprintf('cd %s; %s', log_dir, sbatch_call); + + fprintf('SLURM command: %s\n', full_cmd); + [status, out] = system(full_cmd); + if status ~= 0, error('SLURM submission failed: %s', out); end + + job_ids = regexp(out, '\d+', 'match'); + if isempty(job_ids), error('No SLURM job ID returned'); end + job_id = str2double(job_ids{1}); +end + +function write_qsub_script(temp_qsub_path, parameters, temp_m_file, log_dir) + subj_id_string = sprintf('sub-%03d', parameters.subject_id); + job_name = hpc_job_name(parameters); + + fid = fopen(temp_qsub_path, 'w+'); + fprintf(fid, '#!/bin/bash\n'); + fprintf(fid, '#PBS -N %s\n', job_name); + fprintf(fid, '#PBS -l nodes=1:gpus=1,feature=cuda,reqattr=cudacap>=8.0,mem=%iGb,walltime=%i\n', ... + parameters.hpc.memorylimit, parameters.hpc.timelimit); + fprintf(fid, '#PBS -o %s_qsub_output_%%j.log\n', subj_id_string); + fprintf(fid, '#PBS -e %s_qsub_error_%%j.log\n', subj_id_string); + fprintf(fid, '#PBS -d %s\n', log_dir); + fprintf(fid, 'module load matlab/R2023b\n'); + fprintf(fid, 'matlab -batch "%s"\n', temp_m_file); + fclose(fid); +end + +function job_id = submit_qsub_job(temp_qsub_path, log_dir) + qsub_call = sprintf('qsub %s', temp_qsub_path); + full_cmd = sprintf('cd %s; %s', log_dir, qsub_call); + + fprintf('qsub command: %s\n', full_cmd); + [status, out] = system(full_cmd); + if status ~= 0, error('qsub submission failed: %s', out); end + job_id = strtrim(out); +end +end diff --git a/functions/hpc/hpc_validate_parameters.m b/functions/hpc/hpc_validate_parameters.m new file mode 100644 index 00000000..b843ec42 --- /dev/null +++ b/functions/hpc/hpc_validate_parameters.m @@ -0,0 +1,21 @@ +function hpc_validate_parameters(parameters, hpc_type) +%% HPC_VALIDATE_PARAMETERS Validate HPC job parameters +% +% hpc_validate_parameters(parameters, hpc_type) +% +% Disables interactive mode and validates overwrite_files option for batch +% HPC jobs. +% +% Inputs: +% parameters - Struct with HPC parameters +% hpc_type - 'slurm' or 'qsub' +% +% See also HPC_DETECT_SYSTEM, HPC_SUBMIT_JOB. + +if parameters.simulation.interactive + warning('Interactive mode disabled for %s jobs.', upper(hpc_type)); + parameters.simulation.interactive = false; +end +assert(matches(parameters.io.overwrite_files, ["always", "never"]), ... + 'overwrite_files must be "always" or "never" for %s jobs.', upper(hpc_type)); +end diff --git a/functions/hpc/hpc_wait_for_completion.m b/functions/hpc/hpc_wait_for_completion.m new file mode 100644 index 00000000..240c1be8 --- /dev/null +++ b/functions/hpc/hpc_wait_for_completion.m @@ -0,0 +1,99 @@ +function hpc_wait_for_completion(job_id, hpc_type, max_checks) +%% HPC_WAIT_FOR_COMPLETION Monitor HPC job until completion +% Fixed for Donders/DCCN SLURM cluster format + +if nargin < 3 || isempty(max_checks) + max_checks = 540; % default: ~3 hours at 1 check/20s +end +disp('User has chosen to wait until job is finished...'); +job_completed = false; +checks = 0; +tic_start = tic; + +while ~job_completed && checks < max_checks + checks = checks + 1; + + switch lower(hpc_type) + case 'slurm' + job_id_str = sprintf('%.0f', job_id); + + % 1. squeue - simple column split for YOUR format + [status_q, out_q] = system(sprintf('squeue --noheader -j %s 2>/dev/null', job_id_str)); + if status_q == 0 && ~isempty(strtrim(out_q)) + parts = strsplit(strtrim(out_q)); + if length(parts) >= 5 + job_state = upper(parts{5}); % Column 5 = ST (PD, R, CG...) + fprintf('SLURM Job %s: %s (check %d/%d)\n', job_id_str, job_state, checks, max_checks); + + % Keep waiting for active states + if any(strcmp(job_state, {'PD','R','CG','CD','F','TO','S','CA'})) + pause(20); + continue; + end + end + end + + % 2. Job gone from squeue β†’ check sacct + [status, out] = system(sprintf('sacct -j %s -o State --noheader | tail -n 1 2>/dev/null', job_id_str)); + if status == 0 && ~isempty(strtrim(out)) + % Safe first word extraction + first_word = strtok(strtrim(out)); % Safer than regex + terminal_states = {'COMPLETED','FAILED','CANCELLED','TIMEOUT','OUT_OF_MEMORY'}; + if any(strcmpi(first_word, terminal_states)) + fprintf('βœ“ SLURM Job %s FINISHED: %s\n', job_id_str, first_word); + job_completed = true; + else + fprintf('SLURM Job %s sacct: %s (check %d)\n', job_id_str, first_word, checks); + pause(20); + end + else + fprintf('βœ“ SLURM Job %s gone from queue β†’ COMPLETE (check %d)\n', job_id_str, checks); + job_completed = true; + end + + case {'qsub', 'pbs', 'torque'} + job_id_str = char(job_id); + + % qstat -f for detailed state + [status, out] = system(sprintf('qstat -f %s 2>/dev/null | grep job_state', job_id_str)); + if status == 0 && ~isempty(out) + parts = strsplit(strtrim(out), '='); + if length(parts) >= 2 + job_state = strtrim(parts{end}); + fprintf('qsub Job %s: %s (check %d/%d)\n', job_id_str, job_state, checks, max_checks); + + if any(strcmpi(job_state, {'C','E','F'})) + fprintf('βœ“ qsub Job %s FINISHED: %s\n', job_id_str, job_state); + job_completed = true; + else + pause(20); + end + continue; + end + end + + % Fallback: simple qstat + [status_check, ~] = system(sprintf('qstat %s >/dev/null 2>&1', job_id_str)); + persistent seen_once; + if isempty(seen_once), seen_once = false; end + + if status_check ~= 0 && seen_once + fprintf('βœ“ qsub Job %s gone from queue β†’ COMPLETE\n', job_id_str); + job_completed = true; + elseif ~seen_once + fprintf('qsub Waiting for job %s to appear... (check %d/%d)\n', job_id_str, checks, max_checks); + seen_once = true; + end + pause(20); + end +end + +elapsed = toc(tic_start); +if ~job_completed + warning('TIMEOUT job %s after %.1f min (%d checks)', job_id_str, elapsed/60, checks); +else + fprintf('βœ“ Job %s (%s) complete after %.1f min (%d checks)\n', job_id_str, hpc_type, elapsed/60, checks); +end + +end + diff --git a/functions/hpc/single_subject_pipeline_with_qsub.m b/functions/hpc/single_subject_pipeline_with_qsub.m deleted file mode 100644 index 54b2634c..00000000 --- a/functions/hpc/single_subject_pipeline_with_qsub.m +++ /dev/null @@ -1,137 +0,0 @@ -function single_subject_pipeline_with_qsub(subject_id, parameters, wait_for_job, timelimit, memorylimit) - arguments - subject_id double - parameters struct - wait_for_job logical = false % boolean for waiting for job to finish before continuing the code - timelimit (1,1) double = 60*60*4 % time limit for a job in seconds (4 hours by default) - memorylimit (1,1) double = 40 % memory limit for a job in Gb (40 Gb by default) - end - - % Save that this parameter set is using qsub for further branching - parameters.submit_medium = 'qsub'; - - if parameters.interactive - warning('Processing is set to interactive mode, this is not supported when running jobs with qsub, switching off interactive mode.') - parameters.interactive = 0; - end - assert(matches(parameters.overwrite_files,["always","never"]), "When running jobs with qsub, it is not possible to create dialog windows to ask for a confirmation when a file already exists. Set parameters.overwrite_files to 'always' or 'never'"); - - % Make subfolder (if enabled) and check if directory exists - % This ensures that log files are saved in the subject subdirectory - if isfield(parameters,'subject_subfolder') && parameters.subject_subfolder == 1 - output_dir = fullfile(parameters.sim_path, sprintf('sub-%03d', subject_id)); - else - output_dir = fullfile(parameters.sim_path); - end - - if ~isfolder(output_dir) - mkdir(output_dir); - end - - log_dir = fullfile(output_dir, 'batch_job_logs'); - if ~exist(log_dir, 'dir' ) - mkdir(log_dir) - end - - [path_to_pipeline, ~, ~] = fileparts(which('single_subject_pipeline')); - - subj_id_string = sprintf('sub-%03d', subject_id); - - % save inputs in the temp file - temp_data_path = tempname(log_dir); - [tempdir,tempfile] = fileparts(temp_data_path); - tempfile = [tempfile '.mat']; - temp_data_path = [temp_data_path '.mat']; - save(temp_data_path, "subject_id", "parameters"); - - temp_m_file = tempname(log_dir); - fid = fopen([temp_m_file '.m'], 'w+'); - fprintf(fid, "load %s; cd %s; single_subject_pipeline(subject_id, parameters); delete %s; delete %s;", temp_data_path, path_to_pipeline, temp_data_path, [temp_m_file '.m']); - fclose(fid); - [~,temp_m_file_name,~] = fileparts(temp_m_file); - - matlab_cmd = sprintf('matlab -batch "%s"', temp_m_file_name); - - if ~isfield(parameters, 'qsub_job_prefix') - parameters.qsub_job_prefix = 'PRESTUS'; - end - job_name = [parameters.qsub_job_prefix '_' subj_id_string]; - qsub_call = sprintf('qsub -N %s -l "nodes=1:gpus=1,feature=cuda,reqattr=cudacap>=8.0,mem=%iGb,walltime=%i" -o %s -e %s -d %s', ... - job_name,... - memorylimit, timelimit, ... - sprintf('%s_qsub_pipeline_output_$timestamp.log', subj_id_string),... - sprintf('%s_qsub_pipeline_error_$timestamp.log', subj_id_string),... - log_dir); - - full_cmd = sprintf('cd %s; timestamp=$(date +%%Y%%m%%d_%%H%%M%%S); echo ''%s'' | %s', log_dir, matlab_cmd, qsub_call); - - fprintf('Submitted the job to the cluster with a command \n%s \nSee logs in %s in case there are errors. \n', full_cmd, log_dir) - [status, out] = system(full_cmd); - - job_id = strtrim(out); - fprintf('Job name: %s; job ID: %s\n', job_name, job_id) - - if status == 0 - disp('Job submitted successfully'); - - % Polling for job status - job_completed = true; - if wait_for_job - disp('User has chosen to wait until job is finished...'); - job_completed = false; - end - - while ~job_completed - check_cmd = sprintf('qstat -f %s | grep job_state', job_id); - [status, out] = system(check_cmd); - - n_sec = 20; % Pause for n seconds before checking again - if status == 0 - % Extract the job state (e.g., "job_state = R") - % Split the output at the '=' and trim to get the job state - parts = strsplit(out, '='); - if numel(parts) == 2 - job_state = strtrim(parts{2}); - - if job_state == 'R' % Running - disp('Job is still running...'); - pause(n_sec); % Pause for n seconds before checking again - elseif job_state == 'Q' % Queued - disp('Job is still queued...'); - pause(n_sec); % Pause for n seconds before checking again - elseif job_state == 'C' % Completed - disp('Job completed successfully.'); - job_completed = true; - else - disp(['Job status: ', job_state]); - pause(n_sec); % Pause for n seconds before checking again% Pause for n seconds before checking again - % Additional states: 'E' (exiting), 'H' (held), etc. - end - else - disp('Failed to parse job state.'); - end - else - disp('Failed to check job status.'); - disp(out); - - % Handle case where job might have completed and dropped from qstat - check_cmd = sprintf('qstat %s', job_id); - [status, out] = system(check_cmd); - if status ~= 0 - disp('Job is no longer listed in qstat. Assuming it completed.'); - job_completed = true; - else - break; - end - end - end - - else - disp('Command failed to submit the job.'); - disp(out); % Display the error message - end - - % Continue with MATLAB script - disp('Continuing with the MATLAB script...'); - -end diff --git a/functions/hpc/single_subject_pipeline_with_slurm.m b/functions/hpc/single_subject_pipeline_with_slurm.m deleted file mode 100644 index fab04b31..00000000 --- a/functions/hpc/single_subject_pipeline_with_slurm.m +++ /dev/null @@ -1,177 +0,0 @@ -function single_subject_pipeline_with_slurm(subject_id, parameters, wait_for_job, timelimit, memorylimit, options) - arguments - subject_id double - parameters struct - wait_for_job logical = false % boolean for waiting for job to finish before continuing the code - timelimit string = "04:00:00" % time limit for a job in seconds (4 hours by default) - memorylimit (1,1) double = 40 % memory limit for a job in Gb (40 Gb by default) - options.sequential_configs struct = struct() - end - - % Save that this parameter set is using slurm for further branching - parameters.submit_medium = 'slurm'; - - if parameters.interactive - warning('Processing is set to interactive mode, this is not supported when running jobs with qsub, switching off interactive mode.') - parameters.interactive = 0; - end - assert(matches(parameters.overwrite_files,["always","never"]), "When running jobs with qsub, it is not possible to create dialog windows to ask for a confirmation when a file already exists. Set parameters.overwrite_files to 'always' or 'never'"); - - % Make subfolder (if enabled) and check if directory exists - % This ensures that log files are saved in the subject subdirectory - if isfield(parameters,'subject_subfolder') && parameters.subject_subfolder == 1 - output_dir = fullfile(parameters.sim_path, sprintf('sub-%03d', subject_id)); - else - output_dir = fullfile(parameters.sim_path); - end - - if ~isfolder(output_dir) - mkdir(output_dir); - end - - log_dir = fullfile(output_dir, 'batch_job_logs'); - if ~exist(log_dir, 'dir' ) - mkdir(log_dir) - end - - [path_to_pipeline, ~, ~] = fileparts(which('single_subject_pipeline')); - - subj_id_string = sprintf('sub-%03d', subject_id); - - % save inputs in the temp file - temp_data_path = tempname(log_dir); - [tempdir,tempfile] = fileparts(temp_data_path); - tempfile = [tempfile '.mat']; - temp_data_path = [temp_data_path '.mat']; - save(temp_data_path, "subject_id", "parameters"); - - temp_m_file = tempname(log_dir); - fid = fopen([temp_m_file '.m'], 'w+'); - - % Depending on the input, determine what to save and what to submit to the pipeline - if ~isempty(fieldnames(options.sequential_configs)) - sequential_configs = options.sequential_configs; - save(temp_data_path, "subject_id", "parameters", "sequential_configs"); - fprintf(fid, "load '%s'; cd '%s'; single_subject_pipeline(subject_id, parameters, 'sequential_configs', sequential_configs); delete '%s'; delete '%s';", temp_data_path, path_to_pipeline, temp_data_path, [temp_m_file '.m']); - else - save(temp_data_path, "subject_id", "parameters"); - fprintf(fid, "load '%s'; cd '%s'; single_subject_pipeline(subject_id, parameters); delete '%s'; delete '%s';", temp_data_path, path_to_pipeline, temp_data_path, [temp_m_file '.m']); - end - fclose(fid); - [~,temp_m_file_name,~] = fileparts(temp_m_file); - - if ~isfield(parameters, 'slurm_job_prefix') - parameters.slurm_job_prefix = 'PRESTUS'; - end - - % Create a temporary SLURM batch script file - temp_slurm_file = tempname(log_dir); - job_name = [parameters.slurm_job_prefix '_' subj_id_string]; - fid = fopen([temp_slurm_file '.sh'], 'w+'); - fprintf(fid, '#!/bin/bash\n'); - fprintf(fid, '#SBATCH --job-name=%s\n', job_name); - if isfield(parameters, 'hpc_partition') && ~isempty(parameters.hpc_partition) && ... - ~strcmp(parameters.hpc_partition, '') - fprintf(fid, '#SBATCH --partition=%s\n', parameters.hpc_partition); - request_gpu = 1; - elseif strcmp(parameters.code_type, 'matlab_gpu') || strcmp(parameters.code_type, 'cpp_gpu') - fprintf(fid, '#SBATCH --partition=gpu\n'); - request_gpu = 1; - else - request_gpu = 0; - end - if isfield(parameters, 'hpc_gpu') && ~isempty(parameters.hpc_gpu) && ... - ~strcmp(parameters.hpc_gpu, '') - fprintf(fid, '#SBATCH --gres=%s\n', parameters.hpc_gpu); - elseif strcmp(parameters.code_type, 'matlab_gpu') || strcmp(parameters.code_type, 'cpp_gpu') - fprintf(fid, '#SBATCH --gres=gpu:1\n'); - end - if isfield(parameters, 'hpc_reservation') && ~isempty(parameters.hpc_reservation) && ... - ~strcmp(parameters.hpc_reservation, '') - fprintf(fid, '#SBATCH --reservation=%s\n', parameters.hpc_reservation); - end - fprintf(fid, '#SBATCH --mem=%iG\n', memorylimit); - fprintf(fid, '#SBATCH --time=%s\n', timelimit); - fprintf(fid, '#SBATCH --output=%s\n', sprintf('%s_slurm_output_%%j.log', subj_id_string)); - fprintf(fid, '#SBATCH --error=%s\n', sprintf('%s_slurm_error_%%j.log', subj_id_string)); - fprintf(fid, '#SBATCH --chdir=%s\n', log_dir); - if request_gpu == 1 - fprintf(fid, 'nvidia-smi\n'); - end - fprintf(fid, 'module load matlab/R2023b\n'); - fprintf(fid, 'matlab -batch "%s"\n', temp_m_file_name); - fclose(fid); - - % Create the full command to submit the batch script - sbatch_call = sprintf('sbatch %s.sh', temp_slurm_file); - - % Execute the full command - full_cmd = sprintf('cd %s; %s', log_dir, sbatch_call); - fprintf('Submitted the job to the cluster with a command \n%s \nSee logs in %s in case there are errors. \n', full_cmd, log_dir) - [status, out] = system(full_cmd); - - job_id = regexp(out, '\d+', 'match'); - try % there are instances where the system returns no job id but a warning, show this - job_id = str2double(job_id{1}); - fprintf('Job name: %s; job ID: %i\n', job_name, job_id) - catch - disp(out); - end - - if status == 0 - disp('Job submitted successfully'); - - % Polling for job status - job_completed = true; - if wait_for_job - disp('User has chosen to wait until job is finished...'); - job_completed = false; - end - - while ~job_completed - % SLURM equivalent of qstat to check job state - check_cmd = sprintf('sacct -j %i -o State --noheader | tail -n 1', job_id); - [status, out] = system(check_cmd); - - n_sec = 20; % Pause for n seconds before checking again - if status == 0 - % Extract the job state (e.g., "RUNNING", "PENDING", "COMPLETED") - job_state = strtrim(out); - - if strcmp(job_state, 'RUNNING') == true - disp('Job is still running...'); - pause(n_sec); % Pause for n seconds before checking again - elseif strcmp(job_state, 'PENDING') == true - disp('Job is still queued...'); - pause(n_sec); % Pause for n seconds before checking again - elseif strcmp(job_state, 'COMPLETED') == true - disp('Job completed successfully.'); - job_completed = true; - else - disp(['Job status: ', job_state]); - pause(n_sec); % Pause for n seconds before checking again - % Additional states: "FAILED", "CANCELLED", etc. - end - else - disp('Failed to check job status.'); - disp(out); - - % Handle case where job might have completed and dropped from squeue - check_cmd = sprintf('scontrol show job %s', job_id); - [status, out] = system(check_cmd); - if status ~= 0 - disp('Job is no longer listed in squeue. Assuming it completed.'); - job_completed = true; - else - break; - end - end - end - else - disp('Command failed to submit the job.'); - disp(out); % Display the error message - end - - % Continue with MATLAB script - disp('Continuing with the MATLAB script...'); -end \ No newline at end of file diff --git a/functions/hpc/transducer_positioning_with_qsub.m b/functions/hpc/transducer_positioning_with_qsub.m deleted file mode 100644 index 322b1f4f..00000000 --- a/functions/hpc/transducer_positioning_with_qsub.m +++ /dev/null @@ -1,87 +0,0 @@ -function transducer_positioning_with_qsub(subject_id, parameters, pn, target_name, mni_targets, timelimit, memorylimit) - -% TRANSDUCER_POSITIONING_WITH_QSUB Submits transducer positioning jobs to a cluster using Qsub. -% -% This function prepares and submits a transducer positioning job to a cluster using Qsub. -% It generates temporary MATLAB and Qsub batch script files to execute the -% `transducer_positioning` function for a given subject and target. -% -% Input: -% subject_id - Integer specifying the subject ID. -% parameters - Struct containing simulation parameters (e.g., paths, Qsub settings). -% pn - Struct containing subject-specific paths (e.g., segmentation folder). -% target_name - String specifying the name of the target (e.g., 'motor_cortex'). -% mni_targets - Struct containing MNI coordinates for each target. -% timelimit - Scalar specifying the time limit for the Qsub job in seconds (default: 3600 seconds or 1 hour). -% memorylimit - Scalar specifying the memory limit for the Qsub job in GB (default: 12 GB). -% -% Output: -% None. The function submits the job to the cluster and provides feedback on submission status. - - arguments - subject_id double - parameters struct - pn struct - target_name string - mni_targets struct - timelimit (1,1) double = 60*60*1 % Time limit for a job in seconds (default: 1 hour) - memorylimit (1,1) double = 12 % Memory limit for a job in GB (default: 12 GB) - end - - %% Check interactive mode and overwrite settings - if parameters.interactive - warning('Interactive mode is not supported when submitting jobs with Qsub. Switching off interactive mode.'); - parameters.interactive = 0; - end - - assert(matches(parameters.overwrite_files, ["always", "never"]), ... - "When running jobs with Qsub, dialog windows cannot be used. Set parameters.overwrite_files to 'always' or 'never'."); - - %% Create log directory if it does not exist - log_dir = fullfile(parameters.output_dir, 'batch_job_logs'); - if ~exist(log_dir, 'dir') - mkdir(log_dir); - end - - %% Determine pipeline location and prepare temporary files - [path_to_pipeline, ~, ~] = fileparts(which('transducer_positioning.m')); - - subj_id_string = sprintf('sub-%03d', subject_id); - - % Save inputs in a temporary MAT file - temp_data_path = tempname(log_dir); - temp_data_path = [temp_data_path '.mat']; - save(temp_data_path, "subject_id", "parameters", "pn", "target_name", "mni_targets"); - - % Create temporary MATLAB script file - temp_m_file = tempname(log_dir); - fid = fopen([temp_m_file '.m'], 'w+'); - fprintf(fid, "load %s; cd %s; transducer_positioning(parameters, pn, subject_id, target_name, mni_targets); delete %s; delete %s;", ... - temp_data_path, path_to_pipeline, temp_data_path, [temp_m_file '.m']); - fclose(fid); - - [~, temp_m_file_name, ~] = fileparts(temp_m_file); - - matlab_cmd = sprintf('matlab -batch "%s"', temp_m_file_name); - - %% Prepare Qsub submission command - if ~isfield(parameters, 'qsub_job_prefix') - parameters.qsub_job_prefix = 'tusim_tp'; - end - - job_name = [parameters.qsub_job_prefix '_' subj_id_string]; - - qsub_call = sprintf('qsub -N %s -l "nodes=1:gpus=1,feature=cuda,reqattr=cudacap>=8.0,mem=%iGb,walltime=%i" -o %s -e %s -d %s', ... - job_name,... - memorylimit, timelimit, ... - sprintf('%s_qsub_pipeline_output_$timestamp.log', subj_id_string),... - sprintf('%s_qsub_pipeline_error_$timestamp.log', subj_id_string),... - log_dir); - - full_cmd = sprintf('cd %s; timestamp=$(date +%%Y%%m%%d_%%H%%M%%S); echo ''%s'' | %s', log_dir, matlab_cmd, qsub_call); - - fprintf('Submitted the job to the cluster with a command \n%s \nSee logs in %s in case there are errors. \n', full_cmd, log_dir); - [res, out] = system(full_cmd); - - fprintf('Job name: %s; job ID: %s\n', job_name, out); -end \ No newline at end of file diff --git a/functions/hpc/transducer_positioning_with_slurm.m b/functions/hpc/transducer_positioning_with_slurm.m deleted file mode 100644 index 0ddfeccf..00000000 --- a/functions/hpc/transducer_positioning_with_slurm.m +++ /dev/null @@ -1,105 +0,0 @@ -function transducer_positioning_with_slurm(subject_id, parameters, pn, target_name, mni_targets, timelimit, memorylimit) - -% TRANSDUCER_POSITIONING_WITH_SLURM Submits transducer positioning jobs to a SLURM cluster. -% -% This function prepares and submits a transducer positioning job to a SLURM-based -% high-performance computing (HPC) cluster. It generates temporary MATLAB and SLURM -% batch script files to execute the `transducer_positioning` function for a given -% subject and target. -% -% Input: -% subject_id - Integer specifying the subject ID. -% parameters - Struct containing simulation parameters (e.g., paths, SLURM settings). -% pn - Struct containing subject-specific paths (e.g., segmentation folder). -% target_name - String specifying the name of the target (e.g., 'motor_cortex'). -% mni_targets - Struct containing MNI coordinates for each target. -% timelimit - String specifying the time limit for the SLURM job (default: '01:00:00'). -% memorylimit - Scalar specifying the memory limit for the SLURM job in GB (default: 12). -% -% Output: -% None. The function submits the job to the cluster and provides feedback on submission status. - - arguments - subject_id double - parameters struct - pn struct - target_name string - mni_targets struct - timelimit string = "01:00:00" % Time limit for a job in seconds (default: 1 hour) - memorylimit (1,1) double = 12 % Memory limit for a job in GB (default: 12 GB) - end - - %% Check interactive mode and overwrite settings - if parameters.interactive - warning('Interactive mode is not supported when submitting jobs with SLURM. Switching off interactive mode.'); - parameters.interactive = 0; - end - - assert(matches(parameters.overwrite_files, ["always", "never"]), ... - "When running jobs with SLURM, dialog windows cannot be used. Set parameters.overwrite_files to 'always' or 'never'."); - - %% Create log directory if it does not exist - log_dir = fullfile(parameters.output_dir, 'batch_job_logs'); - if ~exist(log_dir, 'dir') - mkdir(log_dir); - end - - %% Determine pipeline location and prepare temporary files - [path_to_pipeline, ~, ~] = fileparts(which('transducer_positioning.m')); - - subj_id_string = sprintf('sub-%03d', subject_id); - - % Save inputs in a temporary MAT file - temp_data_path = tempname(log_dir); - temp_data_path = [temp_data_path '.mat']; - save(temp_data_path, "subject_id", "parameters", "pn", "target_name", "mni_targets"); - - % Create temporary MATLAB script file - temp_m_file = tempname(log_dir); - fid = fopen([temp_m_file '.m'], 'w+'); - fprintf(fid, "load %s; cd %s; transducer_positioning(parameters, pn, subject_id, target_name, mni_targets); delete %s; delete %s;", ... - temp_data_path, path_to_pipeline, temp_data_path, [temp_m_file '.m']); - fclose(fid); - - [~, temp_m_file_name, ~] = fileparts(temp_m_file); - - %% Prepare SLURM batch script file - if ~isfield(parameters, 'slurm_job_prefix') - parameters.slurm_job_prefix = 'PRESTUS'; - end - - temp_slurm_file = tempname(log_dir); - job_name = [parameters.slurm_job_prefix '_' subj_id_string]; - - fid = fopen([temp_slurm_file '.sh'], 'w+'); - fprintf(fid, '#!/bin/bash\n'); - fprintf(fid, '#SBATCH --job-name=%s\n', job_name); - if isfield(parameters, 'hcp_partition') && ~isempty(parameters.hcp_partition) - fprintf(fid, '#SBATCH --partition=%s\n', parameters.hcp_partition); - else - fprintf(fid, '#SBATCH --partition=gpu\n'); - end - if isfield(parameters, 'hcp_gpu') && ~isempty(parameters.hcp_gpu) - fprintf(fid, '#SBATCH --gres=%s\n', parameters.hcp_gpu); - else - fprintf(fid, '#SBATCH --gres=gpu:1\n'); - end - fprintf(fid, '#SBATCH --mem=%iG\n', memorylimit); - fprintf(fid, '#SBATCH --time=%s\n', timelimit); - fprintf(fid, '#SBATCH --output=%s\n', sprintf('%s_slurm_output_%%j.log', subj_id_string)); - fprintf(fid, '#SBATCH --error=%s\n', sprintf('%s_slurm_error_%%j.log', subj_id_string)); - fprintf(fid, '#SBATCH --chdir=%s\n', log_dir); - fprintf(fid, 'module load matlab\n'); - fprintf(fid, 'matlab -batch "%s"\n', temp_m_file_name); - fclose(fid); - - % Create the full command to submit the batch script - sbatch_call = sprintf('sbatch %s.sh', temp_slurm_file); - - % Execute the full command - full_cmd = sprintf('cd %s; %s', log_dir, sbatch_call); - fprintf('Submitted the job to the cluster with a command \n%s \nSee logs in %s in case there are errors. \n', full_cmd, log_dir) - [res, out] = system(full_cmd); - - fprintf('Job name: %s; job ID: %s', job_name, out) -end \ No newline at end of file diff --git a/functions/medium/medium_pct_attenuation.m b/functions/medium/medium_pct_attenuation.m new file mode 100644 index 00000000..33113bec --- /dev/null +++ b/functions/medium/medium_pct_attenuation.m @@ -0,0 +1,52 @@ +function [alpha_coeff, alpha_power] = medium_pct_attenuation(parameters, alpha_coeff, alpha_power, pseudoCT, skull_idx, algorithm) + +switch algorithm + case 'k-plan' + + kPlan_alpha = 13.3; % https://dispatch.k-plan.io/static/docs/simulation-pipeline.html + kPlan_alpha_power = 1; + % Note that we allow different values to be specified in the config. + % If replication of k-Wave is the goal, the above values should be specified. + % Throw a warning in the case of deviations. + if parameters.medium_properties.skull.alpha_coeff ~= kPlan_alpha || ... + parameters.medium_properties.skull.alpha_power ~= kPlan_alpha_power + warning('Specified attenuation varies from k-Plan setup.') + end + alpha_coeff(skull_idx) = parameters.medium_properties.skull.alpha_coeff; + alpha_power(skull_idx) = parameters.medium_properties.skull.alpha_power; + + case 'mueller' + + alpha_min = 4; % cortical bone at 500 kHz [dB/cm] [Aubry et al., 2022] + alpha_max = 8.7; % bone at 500 kHz [dB/cm] [Fry 1978] + + % Finds maximum and minimum values + HU_min = min(pseudoCT(skull_idx)); + HU_max = max(pseudoCT(skull_idx)); + + % estimate attenuation based on (pseudo-)HU + alpha_pseudoCT(skull_idx) = alpha_min + (alpha_max - alpha_min) * ... + (1 - (pseudoCT(skull_idx) - HU_min) / (HU_max - HU_min)).^0.5; + alpha_power(skull_idx) = parameters.medium_properties.skull.alpha_power; + % convert alpha at 500 kHz into prefactor alpha0 (dB/MHz/cm) according to specified alpha_power + % (definition of lower and upper attenuation bounds is derived from 500kHz) + alpha_coeff(skull_idx) = alpha_pseudoCT(skull_idx)./(0.5^parameters.medium_properties.skull.alpha_power); + + case 'aubry' + + alpha_min = 0.2; % Aubry et al., 2003 + alpha_max = 8; % Aubry et al., 2003 + + phi(skull_idx) = 1-(pseudoCT(skull_idx)/max(pseudoCT(skull_idx))); + alpha_coeff(skull_idx) = alpha_min + (alpha_max - alpha_min) * (phi(skull_idx).^0.5); + % regularize sound speed to a minimum of water + alpha_power(skull_idx) = parameters.medium_properties.skull.alpha_power; + + case 'none' + + alpha_coeff(skull_idx) = parameters.medium_properties.skull.alpha_coeff; + alpha_power(skull_idx) = parameters.medium_properties.skull.alpha_power; + + otherwise + error("Specified pCT attenuation mapping is not supported.") +end \ No newline at end of file diff --git a/functions/medium/medium_pct_density.m b/functions/medium/medium_pct_density.m new file mode 100644 index 00000000..41d45a29 --- /dev/null +++ b/functions/medium/medium_pct_density.m @@ -0,0 +1,91 @@ +function [density] = medium_pct_density(parameters, density, pseudoCT, skull_idx, algorithm) + +switch algorithm + case 'k-plan' + + % define piece-wise linear mapping between HU and mass density in kg/m^3 hounsfieldUnits = [-990, 60, 1000, 1950]; + % see https://dispatch.k-plan.io/static/docs/planning-images.html#ct-calibration + hounsfieldUnits = [-990, 60, 1000, 1950]; + massDensity = [1.2, 1060, 1530, 2150]; + + density(skull_idx) = fit_pairwiselinear(pseudoCT(skull_idx), hounsfieldUnits, massDensity, 1); + + % plot the mapping + if parameters.simulation.debug == 1 + output_plot = fullfile(parameters.io.debug_dir, ... + sprintf('pCT_hounsfield-density_kplan.png')); + exportgraphics(gcf, output_plot, 'Resolution', 150); + end + close(gcf); + + % regularize minimum to density in water + density(skull_idx) = max(parameters.medium_properties.water.density, density(skull_idx)); + + case 'k-wave' + + offset_HU = 1000; + rho_max = 2100; % max. density in skull [kg/m3] + + % Preprocess pCT values + % Offset CT values to use housfield2density + pseudoCT(skull_idx) = pseudoCT(skull_idx) + offset_HU; + + % set minimum to air tissue (pHU=-1000, pHU_scaled = 0) + pseudoCT(skull_idx) = max(pseudoCT(skull_idx),0); + + % estimate density + density(skull_idx) = hounsfield2density(pseudoCT(skull_idx), 1); + + % plot the mapping + if parameters.simulation.debug == 1 + output_plot = fullfile(parameters.io.debug_dir, ... + sprintf('pCT_hounsfield-density_kwave.png')); + exportgraphics(gcf, output_plot, 'Resolution', 150); + end + close(gcf); + + % regularize minimum density to water density + density(skull_idx) = max(density(skull_idx),parameters.medium_properties.water.density); + + % regularize maximum density to rho_max + density(skull_idx) = min(density(skull_idx),rho_max); + + case 'marsac' + + HU_min = 300; % minimum HU considered as skull + HU_max = 2000; % maximum skull HU for regularization + + % cf. Marsac et al., 2017: do not exclude pHU < HU_min + % as this is prone to create trabecular holes + % skull_idx(pseudoCT(skull_idx) < HU_min) = []; + + % regularize minimum pHU to pHU_min + pseudoCT(skull_idx) = max(pseudoCT(skull_idx),HU_min); + + % regularize maximum pHU to pHU_max + pseudoCT(skull_idx) = min(pseudoCT(skull_idx),HU_max); + + rho_water = parameters.medium_properties.water.density; % density [kg/m^3] + rho_bone = 2100; % max. skull density [kg/m3] + + % estimate density from CT HU based on Marsac et al., 2017 & Bancel et al., 2021 + % note: the original code hard-codes HU_min as 0, which may have been an error + density(skull_idx) = rho_water + (rho_bone - rho_water) * ... + (pseudoCT(skull_idx) - HU_min) / (HU_max - HU_min); + + case 'aubry' + + rho_water = parameters.medium_properties.water.density; + rho_bone = parameters.medium_properties.skull.density; + + phi(skull_idx) = 1-(pseudoCT(skull_idx)/max(pseudoCT(skull_idx))); % [Aubry et al., 2003; Guo et al., 2019] + density(skull_idx) = rho_water * phi(skull_idx) + ... + rho_bone * (1-phi(skull_idx)); + + case 'none' + + density(skull_idx) = parameters.medium_properties.skull.density; + + otherwise + error("Specified CT density mapping is not supported.") +end \ No newline at end of file diff --git a/functions/medium/medium_pct_soundspeed.m b/functions/medium/medium_pct_soundspeed.m new file mode 100644 index 00000000..250dd938 --- /dev/null +++ b/functions/medium/medium_pct_soundspeed.m @@ -0,0 +1,38 @@ +function [sound_speed] = medium_pct_soundspeed(parameters, sound_speed, density, pseudoCT, skull_idx, algorithm) + +switch algorithm + case 'k-plan' + + sound_speed(skull_idx) = 1.33.*density(skull_idx) + 167; + + % regularize minimum to sound speed in water + sound_speed(skull_idx) = max(parameters.medium_properties.water.sound_speed, sound_speed(skull_idx)); + + case 'marsac' + + c_water = parameters.medium_properties.water.sound_speed; % sound speed [m/s] + c_skull = 3360; % max. speed of sound in skull [m/s] + rho_water = parameters.medium_properties.water.density; % density [kg/m^3] + rho_bone = 2100; % max. skull density [kg/m3] + + sound_speed(skull_idx) = c_water + (c_skull - c_water) * ... + (density(skull_idx) - rho_water) / (rho_bone - rho_water); + + case 'aubry' + + c_water = parameters.medium_properties.water.sound_speed; + c_bone = parameters.medium_properties.skull.sound_speed; + + phi(skull_idx) = 1-(pseudoCT(skull_idx)/max(pseudoCT(skull_idx))); + sound_speed(skull_idx) = c_water * phi(skull_idx) + ... + c_bone * (1-phi(skull_idx)); + % regularize sound speed to a minimum of water + sound_speed(skull_idx) = max(sound_speed(skull_idx),c_water); + + case 'none' + + sound_speed(skull_idx) = parameters.medium_properties.skull.sound_speed; + + otherwise + error("Specified CT sound speed mapping is not supported.") +end diff --git a/functions/medium/medium_properties_nifti.m b/functions/medium/medium_properties_nifti.m index bd9d73d8..95ddb0b6 100644 --- a/functions/medium/medium_properties_nifti.m +++ b/functions/medium/medium_properties_nifti.m @@ -10,7 +10,7 @@ function medium_properties_nifti(parameters, kwave_medium, inv_final_transformat orig_hdr = t1_header; % header based on original T1w orig_hdr.Datatype = 'single'; - file_name = fullfile(parameters.debug_dir, property); + file_name = fullfile(parameters.io.debug_dir, property); if ~isfield(kwave_medium, property) warning('Missing field: %s', property); diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index 91a762d9..2ba3c86f 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -15,254 +15,106 @@ % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % Set residual unmapped medium voxels to water - medium_masks(medium_masks==0) = find(strcmp(fieldnames(parameters.medium), 'water')); + medium_masks(medium_masks==0) = find(strcmp(fieldnames(parameters.medium_properties), 'water')); % Loads the medium settings from the config file - medium = parameters.medium; + medium = parameters.medium_properties; % Create empty matrices for medium properties - empty_grid = NaN(parameters.grid_dims); + empty_grid = NaN(parameters.grid.dims); sound_speed = empty_grid; density = empty_grid; alpha_coeff = empty_grid; alpha_power = empty_grid; - thermal_conductivity = empty_grid; % [W/(m.K)] - specific_heat = empty_grid; % [J/(kg.K)] + thermal_conductivity = empty_grid; + specific_heat = empty_grid; perfusion = empty_grid; - absorption_fraction = ones(parameters.grid_dims); % default: convert 100% of attenuation into absorption + absorption_fraction = ones(parameters.grid.dims); % default: convert 100% of attenuation into absorption temp_0 = empty_grid; % Get layer and medium labels layer_labels = fieldnames(parameters.layers); - medium_labels = fieldnames(parameters.medium); + medium_labels = fieldnames(parameters.medium_properties); % Iterate through each layer & assign medium ID to create medium mask for label_i = 1:length(layer_labels) label_name = layer_labels{label_i}; medium_i = find(strcmp(medium_labels, label_name)); - if parameters.usepseudoCT == 1 && strcmp(label_name, 'skull') + if parameters.pct.enabled == 1 && strcmp(label_name, 'skull') skull_idx = find(ismember(medium_masks,medium_i)); - thermal_conductivity(skull_idx) = medium.(label_name).thermal_conductivity; + + % set skull thermal conductivity + thermal_conductivity(skull_idx) = medium.(label_name).thermal_conductivity; + + % set skull heat capacity specific_heat(skull_idx) = medium.(label_name).specific_heat_capacity; + + % set skull perfusion if isfield(medium.(label_name), 'perfusion') perfusion(skull_idx) = medium.(label_name).perfusion; end + + % set skull absorption fraction if isfield(medium.(label_name), 'absorption_fraction') absorption_fraction(skull_idx) = medium.(label_name).absorption_fraction; end + + % set skull starting temperature if isfield(parameters.thermal.temp_0, label_name) temp_0(skull_idx) = parameters.thermal.temp_0.(label_name); end - % define default pCT-to-tissue conversion variant - if isfield(parameters, "pseudoCT_variant") - pCT_variant = parameters.pseudoCT_variant; + % map skull bone density with the desired algorithm + if isfield(parameters, 'pct') && isfield(parameters.pct, 'mapping_density') + pct_mapping_density = parameters.pct.mapping_density; else - pCT_variant = "kosciessa"; - end - - switch pCT_variant - case 'carpino' - pct_skullmapping.density = 'k-wave'; - pct_skullmapping.soundspeed = 'k-plan'; - pct_skullmapping.attenuation = 'yakuub'; - case 'yakuub' - pct_skullmapping.density = 'marsac'; - pct_skullmapping.soundspeed = 'marsac'; - pct_skullmapping.attenuation = 'yakuub'; - case 'k-plan' - pct_skullmapping.density = 'k-plan'; - pct_skullmapping.soundspeed = 'k-plan'; - pct_skullmapping.attenuation = 'k-plan'; - case 'marquet' - pct_skullmapping.density = 'marquet'; - pct_skullmapping.soundspeed = 'marquet'; - pct_skullmapping.attenuation = 'k-plan'; - case 'kosciessa' - pct_skullmapping.density = 'k-plan'; - pct_skullmapping.soundspeed = 'marsac'; - pct_skullmapping.attenuation = 'yakuub'; - otherwise - pct_skullmapping.density = parameters.pct_skullmapping.density; - pct_skullmapping.soundspeed = parameters.pct_skullmapping.soundspeed; - pct_skullmapping.attenuation = parameters.pct_skullmapping.attenuation; - end - - switch pct_skullmapping.density - case 'k-plan' - - % define piece-wise linear mapping between HU and mass density in kg/m^3 hounsfieldUnits = [-990, 60, 1000, 1950]; - % see https://dispatch.k-plan.io/static/docs/planning-images.html#ct-calibration - hounsfieldUnits = [-990, 60, 1000, 1950]; - massDensity = [1.2, 1060, 1530, 2150]; - - density(skull_idx) = fit_pairwiselinear(pseudoCT(skull_idx), hounsfieldUnits, massDensity, 1); - - % plot the mapping - if parameters.debug == 1 - output_plot = fullfile(parameters.debug_dir, ... - sprintf('pCT_hounsfield-density_kplan.png')); - exportgraphics(gcf, output_plot, 'Resolution', 150); - end - close(gcf); - - case 'k-wave' - - offset_HU = 1000; - rho_max = 2100; % max. density in skull [kg/m3] - - % Preprocess pCT values - % Offset CT values to use housfield2density - pseudoCT(skull_idx) = pseudoCT(skull_idx) + offset_HU; - - % set minimum to air tissue (pHU=-1000, pHU_scaled = 0) - pseudoCT(skull_idx) = max(pseudoCT(skull_idx),0); - - % estimate density - density(skull_idx) = hounsfield2density(pseudoCT(skull_idx), 1); - - % plot the mapping - if parameters.debug == 1 - output_plot = fullfile(parameters.debug_dir, ... - sprintf('pCT_hounsfield-density_kwave.png')); - exportgraphics(gcf, output_plot, 'Resolution', 150); - end - close(gcf); - - % regularize minimum density to water density - density(skull_idx) = max(density(skull_idx),medium.water.density); - % regularize maximum density to rho_max - density(skull_idx) = min(density(skull_idx),rho_max); - - % remove initial offset - pseudoCT(skull_idx) = pseudoCT(skull_idx)-offset_HU; - - case 'marsac' - - HU_min = 300; % minimum HU considered as skull - HU_max = 2000; % maximum skull HU for regularization - rho_water = 996; % density [kg/m^3] [Baumgartner et al., 2024] - rho_bone = 2100; % max. skull density [kg/m3] [Baumgartner et al., 2024] - - % if observed max HU is lower than threshold, set HU_max to actual max - HU_max = min(max(pseudoCT(skull_idx),[],'all'), HU_max); - - % truncate CT HU (see Marsac et al., 2017) - % do not use pCT-based properties for presumed non-skull tissue - % BUT: this is prone to create trabecular holes - skull_idx(pseudoCT(skull_idx) < HU_min) = []; - % regularize maximum HU to HU_max - pseudoCT(skull_idx) = min(pseudoCT(skull_idx),HU_max); - - % estimate density from CT HU based on Marsac et al., 2017 & Bancel et al., 2021 - % note: the original code hard-codes HU_min as 0, which may have been an error - density(skull_idx) = rho_water + (rho_bone - rho_water) * ... - (pseudoCT(skull_idx) - HU_min) / (HU_max - HU_min); - - case 'marquet' - - rho_water = 1000; - rho_bone = 2200; - - phi(skull_idx) = 1-(pseudoCT(skull_idx)/1000); - density(skull_idx) = rho_water * phi(skull_idx) + ... - rho_bone * (1-phi(skull_idx)); - - otherwise - error("Specified CT density mapping is not supported.") + pct_mapping_density = 'none'; end + [density] = medium_pct_density(parameters, density, pseudoCT, skull_idx, pct_mapping_density); - switch pct_skullmapping.soundspeed - case 'k-plan' - - sound_speed(skull_idx) = 1.33.*density(skull_idx) + 167; - - case 'marsac' - - c_water = 1500; % sound speed [m/s] - c_skull = 3360; % max. speed of sound in skull [m/s] [Baumgartner et al., 2024] - rho_water = 996; % density [kg/m^3] [Baumgartner et al., 2024] - rho_bone = 2100; % max. skull density [kg/m3] [Baumgartner et al., 2024] - - sound_speed(skull_idx) = c_water + (c_skull - c_water) * ... - (density(skull_idx) - rho_water) / (rho_bone - rho_water); - - case 'marquet' - - c_water = 1500; - if strcmp(label_name, 'skull_cortical') - c_bone = 3100; - else - c_bone = 2200; - end - - phi(skull_idx) = 1-(pseudoCT(skull_idx)/1000); - sound_speed(skull_idx) = c_water * phi(skull_idx) + ... - c_bone * (1-phi(skull_idx)); - % regularize sound speed to a minimum of water - sound_speed(skull_idx) = max(sound_speed(skull_idx),c_water); - - otherwise - error("Specified CT sound speed mapping is not supported.") + % map skull bone sound speed with the desired algorithm + if isfield(parameters, 'pct') && isfield(parameters.pct, 'mapping_soundspeed') + pct_mapping_soundspeed = parameters.pct.mapping_soundspeed; + else + pct_mapping_soundspeed = 'none'; end + [sound_speed] = medium_pct_soundspeed(parameters, sound_speed, density, pseudoCT, skull_idx, pct_mapping_soundspeed); - switch pct_skullmapping.attenuation - case 'k-plan' - - kPlan_alpha = 13.3; % https://dispatch.k-plan.io/static/docs/simulation-pipeline.html - kPlan_alpha_power = 1; - % Note that we allow different values to be specified in the config. - % If replication of k-Wave is the goal, the above values should be specified. - % Throw a warning in the case of deviations. - if medium.(label_name).alpha_coeff ~= kPlan_alpha || ... - medium.(label_name).alpha_power ~= kPlan_alpha_power - warning('Specified attenuation varies from k-Plan setup.') - end - alpha_coeff(skull_idx) = medium.(label_name).alpha_coeff; - alpha_power(skull_idx) = medium.(label_name).alpha_power; - - case 'yakuub' - - alpha_min = 4; % cortical bone at 500 kHz [dB/cm] [Aubry et al., 2022] - alpha_max = 8.7; % bone at 500 kHz [dB/cm] [Fry 1978] - - % Finds maximum and minimum values - HU_min = min(pseudoCT(skull_idx)); - HU_max = max(pseudoCT(skull_idx)); - - % estimate attenuation based on (pseudo-)HU - alpha_pseudoCT(skull_idx) = alpha_min + (alpha_max - alpha_min) * ... - (1 - (pseudoCT(skull_idx) - HU_min) / (HU_max - HU_min)).^0.5; - alpha_power(skull_idx) = medium.(label_name).alpha_power; - % convert alpha at 500 kHz into prefactor alpha0 (dB/MHz/cm) according to specified alpha_power - % (definition of lower and upper attenuation bounds is derived from 500kHz) - alpha_coeff(skull_idx) = alpha_pseudoCT(skull_idx)./(0.5^medium.(label_name).alpha_power); - - otherwise - error("Specified pCT attenuation mapping is not supported.") + % map skull bone attenuation with the desired algorithm + if isfield(parameters, 'pct') && isfield(parameters.pct, 'mapping_attenuation') + pct_mapping_attenuation = parameters.pct.mapping_attenuation; + else + pct_mapping_attenuation = 'none'; end + [alpha_coeff, alpha_power] = medium_pct_attenuation(parameters, alpha_coeff, alpha_power, pseudoCT, skull_idx, pct_mapping_attenuation); % [DEBUG] save pCT mapping overview - if parameters.debug == 1 + if parameters.simulation.debug == 1 h = figure('Units', 'normalized', 'Position', [0.1, 0.1, 0.25, 1]); subplot(4,1,1); hold on; histogram(pseudoCT(skull_idx)); xlabel("pseudo-HU") - title(['pseudoCT tissue property ranges: ', parameters.pseudoCT_variant]); + title(['pseudoCT tissue property ranges']); subplot(4,1,2); hold on; histogram(density(skull_idx)); xlabel("Density [kg/m3]") % add lines for the fixed parameters - xline(medium.skull_trabecular.density, 'r', 'LineWidth', 2); - xline(medium.skull_cortical.density, 'r', 'LineWidth', 2); + xline(medium.skull_trabecular.density, 'r', 'LineWidth', 1); + xline(medium.skull_cortical.density, 'r', 'LineWidth', 1); + xline(medium.skull.density, 'k', 'LineWidth', 2); + title(sprintf('Density mapping: %s', pct_mapping_density)) subplot(4,1,3); hold on; histogram(sound_speed(skull_idx)); xlabel("Sound speed [m/s]") - xline(medium.skull_trabecular.sound_speed, 'r', 'LineWidth', 2); - xline(medium.skull_cortical.sound_speed, 'r', 'LineWidth', 2); + xline(medium.skull_trabecular.sound_speed, 'r', 'LineWidth', 1); + xline(medium.skull_cortical.sound_speed, 'r', 'LineWidth', 1); + xline(medium.skull.sound_speed, 'k', 'LineWidth', 2); + title(sprintf('Sound speed mapping: %s', pct_mapping_soundspeed)) subplot(4,1,4); hold on; histogram(alpha_coeff(skull_idx)); xlabel("Attenuation [dB/(cm.MHzy)]") - xline(medium.skull_trabecular.alpha_coeff, 'r', 'LineWidth', 2); - xline(medium.skull_cortical.alpha_coeff, 'r', 'LineWidth', 2); - output_plot = fullfile(parameters.debug_dir, ... - sprintf('pCT_histograms_%s.png',parameters.pseudoCT_variant)); + xline(medium.skull_trabecular.alpha_coeff, 'r', 'LineWidth', 1); + xline(medium.skull_cortical.alpha_coeff, 'r', 'LineWidth', 1); + xline(medium.skull.alpha_coeff, 'k', 'LineWidth', 2); + title(sprintf('Attention mapping: %s', pct_mapping_attenuation)) + output_plot = fullfile(parameters.io.debug_dir, ... + sprintf('pCT_histograms%s.png',parameters.io.output_affix)); exportgraphics(h, output_plot, 'Resolution', 150); close(h); end @@ -270,8 +122,8 @@ clear skull_idx else - thermal_conductivity(medium_masks==medium_i) = medium.(label_name).thermal_conductivity; % [W/(m.K)] - specific_heat(medium_masks==medium_i) = medium.(label_name).specific_heat_capacity; % [J/(kg.K)] + thermal_conductivity(medium_masks==medium_i) = medium.(label_name).thermal_conductivity; + specific_heat(medium_masks==medium_i) = medium.(label_name).specific_heat_capacity; sound_speed(medium_masks==medium_i) = medium.(label_name).sound_speed; density(medium_masks==medium_i) = medium.(label_name).density; alpha_coeff(medium_masks==medium_i) = medium.(label_name).alpha_coeff; @@ -283,12 +135,15 @@ absorption_fraction(medium_masks==medium_i) = medium.(label_name).absorption_fraction; end if isfield(parameters.thermal.temp_0, label_name) - temp_0(medium_masks==medium_i) = parameters.thermal.temp_0.(label_name); % [degC] + temp_0(medium_masks==medium_i) = parameters.thermal.temp_0.(label_name); end end end - % account for k-Wave's actual attenuation behaviour + % convert perfusion rate [mL/min/kg] into perfusion coefficient [1/s] + perfusion_coeff = (perfusion ./ 60) .* density * 1e-6; + + %% account for k-Wave's actual attenuation behaviour % limits discrepancies for high attenuation estimates (see https://doi.org/10.1121/1.4894790). % 'alpha_coeff' is rescaled to match the specified alpha_coeff and alpa_power_true for the center frequency @@ -299,7 +154,7 @@ if parameters.fit_alpha_power == 1 alpha_power_fixed = 2; - if parameters.debug == 1 + if parameters.simulation.debug == 1 plot_fit = true; else plot_fit = false; @@ -319,9 +174,9 @@ plot_fit); % DEBUG mode: save plot of fitted attenuation values - if parameters.debug == 1 - fig_path = fullfile(parameters.debug_dir, ... - ['attenuation_fit', char(parameters.results_filename_affix), '.png']); + if parameters.simulation.debug == 1 + fig_path = fullfile(parameters.io.debug_dir, ... + ['attenuation_fit', char(parameters.io.output_affix), '.png']); saveas(gcf, fig_path); close(gcf); end @@ -331,10 +186,51 @@ alpha_power_fixed = alpha_power; end - % convert perfusion rate [mL/min/kg] into perfusion coefficient [1/s] - perfusion_coeff = (perfusion ./ 60) .* density * 1e-6; % [1/s] + %% smooth medium masks + + if isfield(parameters.headmodel, 'smooth_properties') && parameters.headmodel.smooth_properties == true + disp("Smoothing acoustic property maps ..."); + + tmp_density = density; % keep unsmoothed image for figure + + fwhm_mm = parameters.headmodel.smooth_fwhm_mm; + grid_mm = parameters.grid.resolution_mm; + smooth_method = parameters.headmodel.smooth_method; - % specify the medium as a kWave-compatible structure + sound_speed = smooth_img(sound_speed, fwhm_mm, grid_mm, 0, smooth_method); + density = smooth_img(density, fwhm_mm, grid_mm, 0, smooth_method); + alpha_coeff_fixed = smooth_img(alpha_coeff_fixed, fwhm_mm, grid_mm, 0, smooth_method); + thermal_conductivity = smooth_img(thermal_conductivity, fwhm_mm, grid_mm, 0, smooth_method); + specific_heat = smooth_img(specific_heat, fwhm_mm, grid_mm, 0, smooth_method); + perfusion_coeff = smooth_img(perfusion_coeff, fwhm_mm, grid_mm, 0, smooth_method); + absorption_fraction = smooth_img(absorption_fraction, fwhm_mm, grid_mm, 0, smooth_method); + temp_0 = smooth_img(temp_0, fwhm_mm, grid_mm, 0, smooth_method); + + % [DEBUG] Plot unsmoothed and smoothed density + if parameters.simulation.debug == 1 + h = figure('Position', [100 100 800 400]); + if numel(size(density))==3 + density_pre = squeeze(tmp_density(:,round(size(tmp_density,2)/2),:)); + density_post = squeeze(density(:,round(size(density,2)/2),:)); + else + density_pre = squeeze(tmp_density); + density_post = squeeze(density); + end + subplot(1,2,1); imagesc(density_pre); title('Original density') + subplot(1,2,2); imagesc(density_post); title('Smoothed density') + output_plot_filename = fullfile(parameters.io.debug_dir, ... + sprintf('sub-%03d_%s_density_smoothing_changes%s.png', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + saveas(h, output_plot_filename, 'png') + close(h); + clear density_pre density_post; + end; clear tmp_density; + else + disp('No smoothing applied to acoustic property maps ...') + end + + + %% specify the medium as a kWave-compatible structure % Note: absorption_fraction and temp_0 need to be removed later) kwave_medium = struct('sound_speed', sound_speed, ... 'density', density, ... @@ -346,50 +242,45 @@ 'absorption_fraction', absorption_fraction,... 'temp_0', temp_0); - % save images for debugging - if parameters.debug == 1 + %% [debug] save acoustic property images + if parameters.simulation.debug == 1 + % save raw medium matrices as niftis try - filename_density = fullfile(parameters.debug_dir, sprintf('matrix_density')); - niftiwrite(density, filename_density, 'Compressed',true); - pause(0.1); - filename_sound_speed = fullfile(parameters.debug_dir, sprintf('matrix_sound_speed')); - niftiwrite(sound_speed, filename_sound_speed, 'Compressed',true); - pause(0.1); - filename_alpha_coeff = fullfile(parameters.debug_dir, sprintf('matrix_alpha_coeff')); - niftiwrite(alpha_coeff, filename_alpha_coeff, 'Compressed',true); - pause(0.1); - filename_alpha_power = fullfile(parameters.debug_dir, sprintf('matrix_alpha_power')); - niftiwrite(alpha_power, filename_alpha_power, 'Compressed',true); - pause(0.1); - filename_alpha_coeff_fixed = fullfile(parameters.debug_dir, sprintf('matrix_alpha_coeff_fixed')); - niftiwrite(alpha_coeff_fixed, filename_alpha_coeff_fixed, 'Compressed',true); - pause(0.1); - filename_perfusion = fullfile(parameters.debug_dir, sprintf('matrix_perfusion')); - niftiwrite(perfusion_coeff, filename_perfusion, 'Compressed',true); - pause(0.1); - filename_absorption = fullfile(parameters.debug_dir, sprintf('matrix_absorption')); - niftiwrite(absorption_fraction, filename_absorption, 'Compressed',true); + filename_density = fullfile(parameters.io.debug_dir, sprintf('matrix_density')); + niftiwrite(density, filename_density, 'Compressed',true); pause(0.1); + filename_sound_speed = fullfile(parameters.io.debug_dir, sprintf('matrix_sound_speed')); + niftiwrite(sound_speed, filename_sound_speed, 'Compressed',true); pause(0.1); + filename_alpha_coeff = fullfile(parameters.io.debug_dir, sprintf('matrix_alpha_coeff')); + niftiwrite(alpha_coeff, filename_alpha_coeff, 'Compressed',true); pause(0.1); + filename_alpha_power = fullfile(parameters.io.debug_dir, sprintf('matrix_alpha_power')); + niftiwrite(alpha_power, filename_alpha_power, 'Compressed',true); pause(0.1); + filename_alpha_coeff_fixed = fullfile(parameters.io.debug_dir, sprintf('matrix_alpha_coeff_fixed')); + niftiwrite(alpha_coeff_fixed, filename_alpha_coeff_fixed, 'Compressed',true); pause(0.1); + filename_perfusion = fullfile(parameters.io.debug_dir, sprintf('matrix_perfusion')); + niftiwrite(perfusion_coeff, filename_perfusion, 'Compressed',true); pause(0.1); + filename_absorption = fullfile(parameters.io.debug_dir, sprintf('matrix_absorption')); + niftiwrite(absorption_fraction, filename_absorption, 'Compressed',true); pause(0.1); catch warning("Error with saving debug images: medium mapping. May result from concurrent write attempts...") end - end - % save images of assigned medium properties - if contains(parameters.simulation_medium, {'layered'}) && parameters.debug == 1 && (exist('planimg') & ~isempty(planimg)) - medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'sound_speed') - medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'density') - medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'alpha_coeff') - medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'alpha_power') - medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'thermal_conductivity') - medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'specific_heat') - medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'perfusion_coeff') - medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'absorption_fraction') - end + % save images of assigned medium properties with proper headers + if contains(parameters.simulation.medium, {'layered'}) && (exist('planimg') & ~isempty(planimg)) + medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'sound_speed') + medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'density') + medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'alpha_coeff') + medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'alpha_power') + medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'thermal_conductivity') + medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'specific_heat') + medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'perfusion_coeff') + medium_properties_nifti(parameters, kwave_medium, planimg.inv_transf, planimg.t1_header, 'absorption_fraction') + end - % save a pCT if used - if parameters.usepseudoCT == 1 && parameters.debug == 1 - filename_pct = fullfile(parameters.debug_dir, sprintf('pct%s', parameters.results_filename_affix)); - niftiwrite(pseudoCT, filename_pct, 'Compressed',true); + % save a pCT (if used) + if parameters.pct.enabled == 1 + filename_pct = fullfile(parameters.io.debug_dir, sprintf('pct%s', parameters.io.output_affix)); + niftiwrite(pseudoCT, filename_pct, 'Compressed',true); + end end end \ No newline at end of file diff --git a/functions/neuronav/neuronav_compute_series_statistics.m b/functions/neuronav/neuronav_compute_series_statistics.m index 5d7310c9..b1b514cd 100644 --- a/functions/neuronav/neuronav_compute_series_statistics.m +++ b/functions/neuronav/neuronav_compute_series_statistics.m @@ -1,102 +1,176 @@ -function [results, n_series] = neuronav_compute_series_statistics(localite, parameters, voxel_size, expected_segment_length) -% NEURONAV_COMPUTE_SERIES_STATISTICS - Compute mean position and variability over stimulus trains -% -% This function processes Localite trigger marker data to analyze stimulus trains -% by segmenting the triggers into series based on time gaps and computing summary -% statistics for each series. It calculates mean spatial coordinates per series, -% distances of each stimulus from the mean location in millimeters, and variability -% metrics to quantify stimulus localization consistency. -% -% INPUT: -% localite - Struct with Localite trigger marker info, including timing and spatial coordinates -% parameters - Struct with transducer parameters (e.g., curvature radius) -% voxel_size - Voxel size of the planning image (mm) -% expected_segment_length - (optional) Expected number of stimulations per train to filter segments -% -% OUTPUT: -% results - Struct containing series-wise average coordinates and distance statistics -% n_series - Cell array where each element is a struct array of stim data with computed distance metrics per pulse -% -% Processing steps: -% 1. Sort triggers by recording time -% 2. Detect large temporal gaps to segment triggers into stimulus trains -% 3. Filter segments by expected length if provided -% 4. Compute average coordinate per segment and Euclidean distances from the mean -% 5. Calculate mean and standard deviation of these distances per segment -% -% This allows quantifying the spatial stability and precision of stimulation localization over stimulus trains. -% -% USAGE: -% [results, n_series] = neuronav_compute_series_statistics(localite, parameters, expected_segment_length) +function stats = neuronav_compute_series_statistics(localite, voxel_size, expected_segment_length, markertype) - % ----------- Setup and Preprocessing ------------ - if nargin < 3 - expected_segment_length = []; % optional filter for real use + if nargin < 4 || isempty(markertype) + markertype = 'TriggerMarkers'; end + if nargin < 3, expected_segment_length = []; end - ref_dist = -(parameters.transducer.curv_radius_mm - parameters.transducer.dist_to_plane_mm); + % Field lists + coord_fields = {'data03Attribute', 'data13Attribute', 'data23Attribute'}; + rot_fields = {'data00Attribute', 'data01Attribute', 'data02Attribute', ... + 'data10Attribute', 'data11Attribute', 'data12Attribute', ... + 'data20Attribute', 'data21Attribute', 'data22Attribute'}; - triggerMarkers = [localite.TriggerMarker]; - recordingTimes = [triggerMarkers.recordingTimeAttribute]; + % ----------- Setup: Robust 4D Matrix Extraction ------------ + if strcmp(markertype, 'TriggerMarkers') + if isfield(localite, 'TriggerMarker') + triggerMarkers = [localite.TriggerMarker]; + recordingTimes = [triggerMarkers.recordingTimeAttribute]; + else + error('TriggerMarkers expected but localite.TriggerMarker not found'); + end + else % GUMMarkers + if ~isfield(localite, 'Element') + error('GUMMarkers expected but localite.Element not found'); + end + elements = localite.Element; + if ~iscell(elements), elements = {elements}; end + n_elements = numel(elements{:}); + triggerMarkers(n_elements) = struct(); + + for i = 1:n_elements + instr_marker = elements{:}(i).InstrumentMarker; + matrix4d = instr_marker.Matrix4D; + triggerMarkers(i).Matrix4D = matrix4d; + + % Build clean 4x4 matrix + matrix_data = zeros(4,4); + all_fields = fieldnames(matrix4d); + for row = 0:3 + for col = 0:3 + fieldname = sprintf('data%d%dAttribute', row, col); + if ismember(fieldname, all_fields) + val = matrix4d.(fieldname); + if isnumeric(val) + matrix_data(row+1, col+1) = val(1); + end + end + end + end + triggerMarkers(i).Matrix4D_full = matrix_data; + + % Extract components + for f = 1:3 + triggerMarkers(i).(coord_fields{f}) = matrix_data(f,4); + end + rot_flat = reshape(matrix_data(1:3,1:3), 1, 9); + for f = 1:9 + triggerMarkers(i).(rot_fields{f}) = rot_flat(f); + end + end + recordingTimes = (1:n_elements)'; + end - % Sort just in case + % Process TriggerMarkers Matrix4D + for i = 1:length(triggerMarkers) + if isfield(triggerMarkers(i), 'Matrix4D') && ~isfield(triggerMarkers(i), 'Matrix4D_full') + matrix4d = triggerMarkers(i).Matrix4D; + matrix_data = zeros(4,4); + all_fields = fieldnames(matrix4d); + for row = 0:3 + for col = 0:3 + fieldname = sprintf('data%d%dAttribute', row, col); + if ismember(fieldname, all_fields) + val = matrix4d.(fieldname); + if isnumeric(val) + matrix_data(row+1, col+1) = val(1); + end + end + end + end + triggerMarkers(i).Matrix4D_full = matrix_data; + for f = 1:3 + triggerMarkers(i).(coord_fields{f}) = matrix_data(f,4); + end + rot_flat = reshape(matrix_data(1:3,1:3), 1, 9); + for f = 1:9 + triggerMarkers(i).(rot_fields{f}) = rot_flat(f); + end + end + end + + % Sort by recording time [recordingTimes, sortIdx] = sort(recordingTimes); triggerMarkers = triggerMarkers(sortIdx); - % ----------- Detect Time Gaps to Segment Series ---------- - timeDiffs = diff(recordingTimes); - gap_threshold = median(timeDiffs) * 3; % typical heuristic: large gap = break - jumpIdx = find(timeDiffs > gap_threshold); - - segmentStarts = [1, jumpIdx + 1]; - segmentEnds = [jumpIdx, numel(recordingTimes)]; + % Detect segments (TriggerMarkers only) + if strcmp(markertype, 'TriggerMarkers') && length(recordingTimes) > 1 + timeDiffs = diff(recordingTimes); + gap_threshold = median(timeDiffs) * 3; + jumpIdx = find(timeDiffs > gap_threshold); + segmentStarts = [1, jumpIdx + 1]; + segmentEnds = [jumpIdx, numel(recordingTimes)]; + else + segmentStarts = 1; + segmentEnds = length(triggerMarkers); + end - % Create segments from trigger markers + % Create segments segments = {}; for i = 1:length(segmentStarts) range = segmentStarts(i):segmentEnds(i); if isempty(expected_segment_length) || abs(length(range) - expected_segment_length) <= 20 - segments{end+1} = triggerMarkers(range); %#ok + segments{end+1} = triggerMarkers(range); end end if isempty(segments) - warning("⚠ No stim series matched the expected_segment_length of %d", expected_segment_length); + warning("⚠ No valid %s segments found", markertype); + stats = {}; + return; end - % ----------- Compute Statistics for Each Series ------------ - field_names = fieldnames(triggerMarkers(1).Matrix4D); - results = struct(); - n_series = {}; - - for s = 1:length(segments) - stim_data = [segments{s}.Matrix4D]; - series_name = sprintf('series%d', s); - results.(series_name) = struct(); - - % Mean coordinate per Matrix4D field (e.g., data03Attribute) - for f = 1:length(field_names) - field = field_names{f}; - results.(series_name).(field) = mean([stim_data.(field)]); + % ----------- ONE CELL PER SERIES WITH Matrix4D_full AT CELL LEVEL ----------- + stats = {}; % 1Γ—N cell array + + for s = 1:length(segments{:}) + stim_data = segments{1}(s); + N = length(stim_data); + + % Extract all Matrix4D_full matrices for this series + all_matrix4d = zeros(N, 4, 4); + coords = zeros(N, 3); + for i = 1:N + all_matrix4d(i,:,:) = stim_data(i).Matrix4D_full; + coords(i,:) = [stim_data(i).(coord_fields{1}), stim_data(i).(coord_fields{2}), stim_data(i).(coord_fields{3})]; end - - % Compute Euclidean distance to mean coordinate - ref = results.(series_name); - ref_voxel = [ref.data03Attribute, ref.data13Attribute, ref.data23Attribute]; - - for i = 1:length(stim_data) - pt_voxel = [stim_data(i).data03Attribute, stim_data(i).data13Attribute, stim_data(i).data23Attribute]; - dist = sqrt(sum((pt_voxel - ref_voxel).^2)); - dist_mm = dist / voxel_size; - stim_data(i).euclideanDist = dist_mm; + + mean_coords = mean(coords, 1); + mean_matrix4d = mean(all_matrix4d, 1); % [1Γ—4Γ—4] mean transform + + % Rotation submatrix for deviation calculation + rotations = zeros(N, 9); + for f = 1:9 + rotations(:, f) = [stim_data.(rot_fields{f})]; end - - % Add stat summaries to each pulse - vals = [stim_data.euclideanDist]; - [stim_data.euclideanDist_M] = deal(mean(vals)); - [stim_data.euclideanDist_SD] = deal(std(vals)); - - % Save output - n_series{end+1} = stim_data; + rotations = reshape(rotations, N, 3, 3); + mean_rotation = mean(rotations, 1); + + euclideanDists = sqrt(sum((coords - mean_coords).^2, 2)) / voxel_size; + rot_deviations = zeros(N, 1); + for i = 1:N + R_dev = squeeze(rotations(i,:,:)) - mean_rotation; + rot_deviations(i) = sqrt(sum(R_dev(:).^2)); + end + + % CREATE COMPLETE SERIES CELL + stats{s} = struct(... + 'series_id', s, ... + 'N_pulses', N, ... + 'voxel_size', voxel_size, ... + 'position_mean', mean_coords, ... % [1Γ—3] + 'matrix4d_mean', mean_matrix4d, ... % [1Γ—4Γ—4] MEAN 4D MATRIX + 'matrix4d_all', all_matrix4d, ... % [NΓ—4Γ—4] ALL 4D MATRICES + 'position_dev_mm', struct(... + 'mean', mean(euclideanDists), ... + 'std', std(euclideanDists), ... + 'all', euclideanDists'), ... + 'rotation_dev_rad', struct(... + 'mean', mean(rot_deviations), ... + 'std', std(rot_deviations), ... + 'all', rot_deviations'), ... + 'markers', stim_data); % Original enriched markers end + + fprintf('Created %d series cells (%s)\n', length(stats), markertype); end diff --git a/functions/neuronav/neuronav_convert_native_to_MNI.m b/functions/neuronav/neuronav_convert_native_to_MNI.m index 6f39dda0..f0a947f5 100644 --- a/functions/neuronav/neuronav_convert_native_to_MNI.m +++ b/functions/neuronav/neuronav_convert_native_to_MNI.m @@ -62,7 +62,7 @@ % Note: the planning and segmentation images have the same size, % but with different headers / location systems - t1plan_info = niftiinfo(fullfile(pn.data_prelocalite, sprintf('%s_T1_forneuronav.nii.gz',sub_id))); + t1plan_info = niftiinfo(fullfile(pn.data_prelocalite, sprintf('%s_T1*.nii*',sub_id))); t1seg_info = niftiinfo(fullfile(pn.data_seg, sprintf('m2m_%s', sub_id), 'final_tissues.nii.gz')); mni_info = niftiinfo(fullfile(pn.data_seg, sprintf('m2m_%s', sub_id), 'toMNI', 'final_tissues_MNI.nii.gz')); diff --git a/functions/neuronav/neuronav_convert_trigger_to_voxels.m b/functions/neuronav/neuronav_convert_trigger_to_voxels.m index af8a3b5b..42e248b1 100644 --- a/functions/neuronav/neuronav_convert_trigger_to_voxels.m +++ b/functions/neuronav/neuronav_convert_trigger_to_voxels.m @@ -1,12 +1,13 @@ -function [transducer_ras, transducer_pos, target_ras, target_pos, t1_image] = neuronav_convert_trigger_to_voxels(sub_id, sides, outputStruct, parameters, pn) +function [transducer_ras, transducer_pos, target_ras, target_pos, t1_image] = ... + neuronav_convert_trigger_to_voxels(sub_id, positions, outputStruct, parameters, pn) % NEURONAV_CONVERT_TRIGGER_TO_VOXELS - Convert Localite trigger positions to voxel (image) coordinates. % % USAGE: -% [transducers, targets, t1_image] = neuronav_convert_trigger_to_voxels(subject_id, sides, outputStruct, parameters, pn) +% [transducers, targets, t1_image] = neuronav_convert_trigger_to_voxels(subject_id, positions, outputStruct, parameters, pn) % % INPUT: % sub_id - String subject if (e.g., sub-001) -% sides - Array of stimulation sides (1:2 for left/right) +% positions - Array of stimulation positions (e.g., 1:2 for left/right) % outputStruct - Struct containing averaged Localite (trigger) matrices for each side % parameters - Simulation configuration struct, including hardware geometry % pn - Struct containing path configuration for all data locations @@ -34,7 +35,7 @@ % For group analysis or reporting in standard space, do spatial normalization post hoc. % --- STEP 1: Locate and load T1 MRI and header/info - t1_file = dir(fullfile(pn.data_prelocalite, sprintf('%s_*T1*.nii.gz',sub_id))); + t1_file = dir(fullfile(pn.data_prelocalite, sprintf('%s_*T1*.nii*',sub_id))); t1_header = niftiinfo(fullfile(t1_file.folder, t1_file.name)); t1_image = niftiread(fullfile(t1_file.folder, t1_file.name)); @@ -42,10 +43,10 @@ reference_dist = -(parameters.transducer.curv_radius_mm - parameters.transducer.dist_to_plane_mm); % --- STEP 3: For each stimulation side, extract the averaged transformation matrix and compute positions - for i = sides + for i = positions % Extract 4x4 transformation from Matrix4D fields - matrix_flat = struct2cell(outputStruct.TriggerMarker(i).Matrix4D); - coord_matrix = reshape(cell2mat(matrix_flat)', [4, 4])'; % Transpose to match MATLAB layout + matrix_flat = squeeze(outputStruct{i}.matrix4d_mean); + coord_matrix = reshape((matrix_flat)', [4, 4])'; % Transpose to match MATLAB layout % -- Extract and interpret Localite coordinate system: % - coord_matrix(:,4): the position (origin) in RAS mm @@ -56,8 +57,12 @@ % -- Compute the RAS mm position of the transducer surface transducer_ras(i,:) = ref_pos + reference_dist * ref_vec; % -- Compute the RAS mm position of the acoustic focal point (forward along vector) - target_ras(i,:) = ref_pos + parameters.expected_focal_distance_mm * ref_vec; - + parameters = focal_distance_calculation(parameters); + if isfield(parameters, 'expected_focal_distance_bowl') + target_ras(i,:) = ref_pos + parameters.expected_focal_distance_bowl * ref_vec; + elseif isfield(parameters.transducer(1), 'expected_focal_distance_bowl') + target_ras(i,:) = ref_pos + parameters.transducer(1).expected_focal_distance_bowl * ref_vec; + end % -- Convert these world (RAS mm) positions into MRI voxel index space transducer_pos(i,:) = ras_to_grid(transducer_ras(i,1:3)', t1_header); target_pos(i,:) = ras_to_grid(target_ras(i,1:3)', t1_header); diff --git a/functions/neuronav/neuronav_create_marker_average.m b/functions/neuronav/neuronav_create_marker_average.m deleted file mode 100644 index 6e544425..00000000 --- a/functions/neuronav/neuronav_create_marker_average.m +++ /dev/null @@ -1,36 +0,0 @@ -function outputStruct = neuronav_create_marker_average(localite, results) -% CREATE_MARKER_AVERAGE - Build an averaged Localite-trigger structure for export. -% -% USAGE: -% outputStruct = create_marker_average_struct(localite, results) -% -% INPUT: -% localite - Original Localite struct with raw trigger markers -% results - Struct containing average coordinate fields for two series -% -% OUTPUT: -% outputStruct - New Localite-compatible structure with 2 averaged trigger markers -% -% DESCRIPTION: -% Using the position and matrix averages from `compute_series_statistics`, -% it creates a simplified trigger structure with two static (average) matrices. - - fields = fieldnames(results.series1); - outputStruct = localite; - - try - outputStruct.TriggerMarker = repmat(outputStruct.TriggerMarker(1), 2, 1); - catch - warning('Failed to replicate trigger marker structure.'); - outputStruct = []; - return - end - - for f = 1:length(fields) - field = fields{f}; - outputStruct.TriggerMarker(1).Matrix4D.(field) = results.series1.(field); - outputStruct.TriggerMarker(2).Matrix4D.(field) = results.series2.(field); - end - outputStruct.TriggerMarker(1).recordingTime = '0'; - outputStruct.TriggerMarker(2).recordingTime = '1'; -end diff --git a/functions/neuronav/neuronav_export_session_csv.m b/functions/neuronav/neuronav_export_session_csv.m index 2e397daf..01265f00 100644 --- a/functions/neuronav/neuronav_export_session_csv.m +++ b/functions/neuronav/neuronav_export_session_csv.m @@ -1,6 +1,6 @@ function neuronav_export_session_csv( ... sub_id, session, session_target, ... - n_series, outputs_folder, ... + outputs_folder, ... trans_vox, target_vox, ... trans_mm, target_mm, ... trans_mni_vox, target_mni_vox, ... @@ -12,7 +12,6 @@ function neuronav_export_session_csv( ... % INPUTS: % sub_id, session : ID strings % session_target : {N x 1} cell array with target labels -% n_series : {N x 1} unused here (included for consistency) % outputs_folder : path to save the output CSV % trans_vox, target_vox : [N x 3] voxel coordinates in native (segmentation) space % trans_mm, target_mm : [N x 3] RAS mm coordinates in native segmentation space @@ -55,7 +54,7 @@ function neuronav_export_session_csv( ... }; end -outfile = fullfile(outputs_folder, sprintf('%s_info.csv', session)); +outfile = fullfile(outputs_folder, sprintf('%s_%s_info.csv', sub_id, session)); writetable(T, outfile); fprintf('Saved session info table (%d targets) β†’ %s\n', N, outfile); diff --git a/functions/neuronav/neuronav_get_group_mean_mni.m b/functions/neuronav/neuronav_get_group_mean_mni.m index 39104922..aa491ca2 100644 --- a/functions/neuronav/neuronav_get_group_mean_mni.m +++ b/functions/neuronav/neuronav_get_group_mean_mni.m @@ -5,7 +5,7 @@ % Only includes rows where interpolated == 0. % Load all subject session CSVs -csv_files = dir(fullfile(parameters.sim_path, 'sub-*', '*_info.csv')); +csv_files = dir(fullfile(parameters.path.sim, 'sub-*', '*_info.csv')); expected_vars = { ... 'trans_MNI_mm_x', 'trans_MNI_mm_y', 'trans_MNI_mm_z', ... diff --git a/functions/neuronav/neuronav_select_and_average_localite.m b/functions/neuronav/neuronav_select_and_average_localite.m deleted file mode 100644 index 5d7677c1..00000000 --- a/functions/neuronav/neuronav_select_and_average_localite.m +++ /dev/null @@ -1,134 +0,0 @@ -function localite = neuronav_select_and_average_localite(sub_id, session, pn) -% NEURONAV_SELECT_AND_AVERAGE_LOCALITE - Select most recent Localite XML for a session. -% Compares against Localite files from the immediately preceding session (if available) to skip duplicates. -% -% INPUT: -% sub_id - e.g., 'sub-010' -% session - e.g., 'ses-03' (format must be 'ses-XX' with XX numeric) -% pn - struct with .data_postlocalite field -% -% OUTPUT: -% localite - struct parsed from selected Localite XML file ([] if none match) - - localite = []; - excludeFile = struct('name', {}); - - % --- Parse session number from input --- - ses_num = regexp(session, 'ses-(\d{2})', 'tokens', 'once'); - if isempty(ses_num) - error('Session input must be in the format "ses-XX" where XX is two digits'); - end - ses_num = str2double(ses_num{1}); - - % Define previous session string (with zero padding) - prev_ses_num = ses_num - 1; - if prev_ses_num < 1 - session_earlier = ''; % No earlier session available - else - session_earlier = sprintf('ses-%02d', prev_ses_num); - end - - % --- Helper function to list valid Localite files --- - function [files, datetimes] = list_valid_localite_files(basepath) - files = dir(fullfile(basepath, 'TriggerMarkers_Coil0*.xml')); - files = files([files.bytes] > 20480); - datetimes = datetime.empty; - for i = 1:length(files) - tokens = regexp(files(i).name, 'TriggerMarkers_Coil0_(\d{17})', 'tokens', 'once'); - if ~isempty(tokens) - try - t = datetime(tokens{1}, 'InputFormat', 'yyyyMMddHHmmssSSS'); - datetimes(end+1) = t; - catch - datetimes(end+1) = NaT; - end - else - datetimes(end+1) = NaT; - end - end - valid_idx = ~isnat(datetimes); - files = files(valid_idx); - datetimes = datetimes(valid_idx); - end - - % --- List files for current session --- - localite_path_later = fullfile(pn.data_postlocalite, sub_id, session, 'localite', '*', 'TMSTrigger'); - [localite_files, dt_later] = list_valid_localite_files(localite_path_later); - - if isempty(localite_files) - warning("⚠ No Localite files >20kB with valid timestamps found for %s %s", sub_id, session); - return; - end - - % --- List files for previous session (if it exists) --- - compare_files = []; - if ~isempty(session_earlier) - localite_path_earlier = fullfile(pn.data_postlocalite, sub_id, session_earlier, 'localite', '*', 'TMSTrigger'); - [compare_files, dt_earlier] = list_valid_localite_files(localite_path_earlier); - % Sort previous session files by date desc - [~, sidx] = sort(dt_earlier, 'descend'); - compare_files = compare_files(sidx); - end - - % Sort later session files by date desc (most recent first) - [~, sidx] = sort(dt_later, 'descend'); - localite_files = localite_files(sidx); - - % --- Main selection loop --- - status = 0; - while status == 0 && ~isempty(localite_files) - current_file = []; - - % Select first unused valid file (most recent) - for k = 1:length(localite_files) - if ~ismember(localite_files(k).name, {excludeFile.name}) - current_file = localite_files(k); - break; - end - end - - if isempty(current_file) - warning("⚠ No Localite file could be selected for %s %s", sub_id, session); - return; - end - - sel_file_path = fullfile(current_file.folder, current_file.name); - - try - current_localite = readstruct(sel_file_path); - catch - warning("⚠ Failed to read file: %s", sel_file_path); - excludeFile(end+1).name = current_file.name; - continue; - end - - % If previous session files exist, compare files to skip duplicates - if ~isempty(compare_files) - is_duplicate = false; - for cfi = 1:length(compare_files) - earlier_file_path = fullfile(compare_files(cfi).folder, compare_files(cfi).name); - cmd = sprintf('diff "%s" "%s"', sel_file_path, earlier_file_path); - [cmp_status, ~] = system(cmd); - if cmp_status == 0 - disp("⚠ File is identical to previous session β€” skipping."); - excludeFile(end+1).name = current_file.name; - is_duplicate = true; - break; - end - end - if is_duplicate - continue; % skip to next later session file - else - disp("File differs from all previous session files β€” selecting."); - end - end - - % Select this Localite file - localite = current_localite; - break; - end - - if isempty(localite) - warning("⚠ No valid Localite XML could be selected for %s %s", sub_id, session); - end -end diff --git a/functions/neuronav/neuronav_select_localite.m b/functions/neuronav/neuronav_select_localite.m new file mode 100644 index 00000000..745def93 --- /dev/null +++ b/functions/neuronav/neuronav_select_localite.m @@ -0,0 +1,170 @@ +function localite = neuronav_select_localite(pn, sub_id, ses_id, markertype) +% NEURONAV_SELECT_LOCALITE - Select most recent Localite XML for a session. +% Compares against Localite files from the immediately preceding session (if available) to skip duplicates. +% +% INPUT: +% pn - struct with .data_postlocalite field (FIRST input) +% sub_id - e.g., 'sub-010' +% ses_id - e.g., 3 (numeric) or 'ses-03' +% markertype - 'TriggerMarkers' (default) or 'GUMMarkers' +% +% OUTPUT: +% localite - struct parsed from selected Localite XML file ([] if none match) + + if nargin < 4 || isempty(markertype) + markertype = 'TriggerMarkers'; % Default + end + + localite = []; + excludeFile = struct('name', {}); + + % --- Harmonize ses_id to session string --- + if isnumeric(ses_id) + ses_num = double(ses_id); + session = sprintf('ses-%02d', ses_num); + else + ses_num = regexp(ses_id, 'ses-(\d{2})', 'tokens', 'once'); + if isempty(ses_num) + error('ses_id must be numeric or "ses-XX" format'); + end + ses_num = str2double(ses_num{1}); + session = char(ses_id); + end + + % Define previous session string (with zero padding) + prev_ses_num = ses_num - 1; + if prev_ses_num < 1 + session_earlier = ''; % No earlier session available + else + session_earlier = sprintf('ses-%02d', prev_ses_num); + end + + % --- Helper function to list valid Localite files --- + function [files, datetimes] = list_valid_localite_files(basepath, markertype) + datetimes = datetime.empty; + + if strcmp(markertype, 'TriggerMarkers') + pattern = 'TriggerMarkers_Coil0*.xml'; + search_path = fullfile(basepath, 'localite', '*', 'TMSTrigger'); + files = dir(fullfile(search_path, pattern)); + files = files([files.bytes] > 10000); % 10kB ONLY for TriggerMarkers + else % GUMMarkers + pattern = 'GUMMarkers*.xml'; % Matches GUMMarkers_sub-003_VS.xml + search_path = fullfile(basepath, 'localite'); + files = dir(fullfile(search_path, pattern)); + % No size criterion for GUMMarkers + end + + for i = 1:length(files) + if strcmp(markertype, 'TriggerMarkers') + % Extract filename timestamp for TriggerMarkers + tokens = regexp(files(i).name, '_(\d{17})', 'tokens', 'once'); + if ~isempty(tokens) + try + t = datetime(tokens{1}, 'InputFormat', 'yyyyMMddHHmmssSSS'); + datetimes(end+1) = t; + catch + datetimes(end+1) = NaT; + end + else + datetimes(end+1) = NaT; + end + else + % Use file modification timestamp for GUMMarkers + datetimes(end+1) = datetime(files(i).datenum, 'ConvertFrom', 'datenum'); + end + end + + valid_idx = ~isnat(datetimes); + files = files(valid_idx); + datetimes = datetimes(valid_idx); + end + + % --- List files for current session --- + if strcmp(markertype, 'TriggerMarkers') + localite_path_later = fullfile(pn.data_postlocalite, sub_id, session, 'localite', '*', 'TMSTrigger'); + else + localite_path_later = fullfile(pn.data_postlocalite, sub_id, session); % Session level for GUMMarkers + end + [localite_files, dt_later] = list_valid_localite_files(localite_path_later, markertype); + + if isempty(localite_files) + if strcmp(markertype, 'TriggerMarkers') + warning("⚠ No %s files >10kB with valid timestamps found for %s %s", markertype, sub_id, session); + else + warning("⚠ No %s files found for %s %s", markertype, sub_id, session); + end + return; + end + + % --- List files for previous session (if it exists) --- + compare_files = []; + if ~isempty(session_earlier) + if strcmp(markertype, 'TriggerMarkers') + localite_path_earlier = fullfile(pn.data_postlocalite, sub_id, session_earlier, 'localite', '*', 'TMSTrigger'); + else + localite_path_earlier = fullfile(pn.data_postlocalite, sub_id, session_earlier); + end + [compare_files, dt_earlier] = list_valid_localite_files(localite_path_earlier, markertype); + if ~isempty(compare_files) + [~, sidx] = sort(dt_earlier, 'descend'); + compare_files = compare_files(sidx); + end + end + + % --- Main selection loop --- + [~, sidx] = sort(dt_later, 'descend'); + localite_files = localite_files(sidx); + + status = 0; + while status == 0 && ~isempty(localite_files) + current_file = []; + for k = 1:length(localite_files) + if ~ismember(localite_files(k).name, {excludeFile.name}) + current_file = localite_files(k); + break; + end + end + + if isempty(current_file) + warning("⚠ No %s file could be selected for %s %s", markertype, sub_id, session); + return; + end + + sel_file_path = fullfile(current_file.folder, current_file.name); + try + current_localite = readstruct(sel_file_path); + catch + warning("⚠ Failed to read file: %s", sel_file_path); + excludeFile(end+1).name = current_file.name; + continue; + end + + if ~isempty(compare_files) + is_duplicate = false; + for cfi = 1:length(compare_files) + earlier_file_path = fullfile(compare_files(cfi).folder, compare_files(cfi).name); + cmd = sprintf('diff "%s" "%s"', sel_file_path, earlier_file_path); + [cmp_status, ~] = system(cmd); + if cmp_status == 0 + disp("⚠ File is identical to previous session β€” skipping."); + excludeFile(end+1).name = current_file.name; + is_duplicate = true; + break; + end + end + if is_duplicate + continue; + else + disp("File differs from all previous session files β€” selecting."); + end + end + + localite = current_localite; + break; + end + + if isempty(localite) + warning("⚠ No valid %s XML could be selected for %s %s", markertype, sub_id, session); + end +end diff --git a/functions/neuronav/position_transducer_localite.m b/functions/neuronav/position_transducer_localite.m index 7e757b0f..ea806d5c 100644 --- a/functions/neuronav/position_transducer_localite.m +++ b/functions/neuronav/position_transducer_localite.m @@ -10,8 +10,8 @@ % localite_instr_file - Path to the Localite instrument markers XML file. % mri_hdr - Header of the MRI file containing the world-to-voxel transformation matrix. % parameters - Struct containing additional parameters: -% * reference_transducer_distance_mm: Distance between reference point and transducer (in mm). -% * expected_focal_distance_mm: Distance between transducer and focus point (in mm). +% * placement.localite.reference_distance_mm: Distance between reference point and transducer (in mm). +% * expected_focal_distance_bowl: Distance between transducer bowl and focus point (in mm). % % Output: % trans_pos - [1x3] array specifying the transducer position in voxel coordinates (ijk). @@ -22,8 +22,8 @@ %% Notes on Inputs and Coordinate Systems % - 'localite_instr_file' contains the path to the Localite instrument markers XML file. % - 'mri_hdr' is the header of the MRI file that provides world-to-voxel transformation matrix. - % - 'reference_transducer_distance_mm' is the distance between neuronavigation reference point and transducer (mm). - % - 'expected_focal_distance_mm' is the distance between transducer and focus point (mm). + % - 'placement.localite.reference_distance_mm' is the distance between neuronavigation reference point and transducer (mm). + % - 'expected_focal_distance_bowl' is the distance between transducer bowl and focus point (mm). % % The Localite coordinates are provided in RAS format. For details on NIFTI coordinate systems, refer to: % "Orientation information" at https://brainder.org/2012/09/23/the-nifti-file-format/. @@ -50,10 +50,10 @@ %% Compute Transducer and Focus Positions in RAS Space % Calculate transducer position relative to reference point. - trans_pos_ras = reference_pos + parameters.reference_transducer_distance_mm * reference_center_to_head; + trans_pos_ras = reference_pos + parameters.placement.localite.reference_distance_mm * reference_center_to_head; % Calculate focus position relative to transducer position. - focus_pos_ras = trans_pos_ras + parameters.expected_focal_distance_mm * reference_center_to_head; + focus_pos_ras = trans_pos_ras + parameters.expected_focal_distance_bowl * reference_center_to_head; %% Convert Positions from RAS Space to Voxel Space % Use MRI header's transformation matrix to convert RAS coordinates to voxel coordinates. diff --git a/functions/pct/pct_create_pseudoCT.sh b/functions/pct/pct_create_pseudoCT.sh index 7a51279d..cc2b90b4 100644 --- a/functions/pct/pct_create_pseudoCT.sh +++ b/functions/pct/pct_create_pseudoCT.sh @@ -56,102 +56,102 @@ function pct_create_pseudoCT() ute_reg_bias="${path_pct}/UTE_thr0_BiasField.nii.gz" ute_norm="${path_pct}/UTE_STnorm.nii.gz" - # # echo "=========================================================================" - # # echo "SEGMENTATION" - # # echo "=========================================================================" + # echo "=========================================================================" + # echo "SEGMENTATION" + # echo "=========================================================================" - # # Externaly run a segmentation using SimNIBS (charm) where the T1 is a normal weighted T1 - # # and instead of the T2 we use a PETRA UTE scan, so that the UTE will be registered to the T1 + # Externaly run a segmentation using SimNIBS (charm) where the T1 is a normal weighted T1 + # and instead of the T2 we use a PETRA UTE scan, so that the UTE will be registered to the T1 - # # Change directory to the pCT folder - # cd "${path_pct}" || { echo "Directory not found"; exit 1; } + # Change directory to the pCT folder + cd "${path_pct}" || { echo "Directory not found"; exit 1; } - # # Check if UTE_reg.nii.gz already exists. If it exists, skip renaming the file. - # if [ -f $ute_reg ]; then - # echo "UTE_reg.nii.gz already exists. Skipping renaming." - # else - # # Rename T2_reg.nii.gz to UTE_reg.nii.gz to use the PETRA UTE image instead of T2 - # mv $t2w_reg $ute_reg - # echo "Renamed T2_reg.nii.gz to UTE_reg.nii.gz" - # fi + # Check if UTE_reg.nii.gz already exists. If it exists, skip renaming the file. + if [ -f $ute_reg ]; then + echo "UTE_reg.nii.gz already exists. Skipping renaming." + else + # Rename T2_reg.nii.gz to UTE_reg.nii.gz to use the PETRA UTE image instead of T2 + mv $t2w_reg $ute_reg + echo "Renamed T2_reg.nii.gz to UTE_reg.nii.gz" + fi - # echo "=========================================================================" - # echo "SKULL MASK" - # echo "=========================================================================" + echo "=========================================================================" + echo "SKULL MASK" + echo "=========================================================================" - # # Create a binary skull mask from final_tissues, thresholding to include skull (value 7 or 8) - # fslmaths th -thr 7 -uthr 8 -bin $skull_mask + # Create a binary skull mask from final_tissues, thresholding to include skull (value 7 or 8) + fslmaths th -thr 7 -uthr 8 -bin $skull_mask - # # Refine skull mask - # echo "Running MATLAB script pct_skullexpand.m..." - # cd "${path_fun_pct}" || { echo "Directory not found: $path_fun_pct"; exit 1; } - # matlab_command="addpath(genpath('${path_fun}')); pct_skullexpand('${path_simnibs}', '${path_pct}'); exit" - # "$path_matlab" -nodisplay -nosplash -batch "$matlab_command" || { echo "MATLAB pct_skullexpand failed"; exit 1; } - # # Move back to pCT folder - # cd "${path_pct}" || { echo "Directory not found"; exit 1; } + # Refine skull mask + echo "Running MATLAB script pct_skullexpand.m..." + cd "${path_fun_pct}" || { echo "Directory not found: $path_fun_pct"; exit 1; } + matlab_command="addpath(genpath('${path_fun}')); pct_skullexpand('${path_simnibs}', '${path_pct}'); exit" + "$path_matlab" -nodisplay -nosplash -batch "$matlab_command" || { echo "MATLAB pct_skullexpand failed"; exit 1; } + # Move back to pCT folder + cd "${path_pct}" || { echo "Directory not found"; exit 1; } - # ################################################################################ - # ##### pCT processing ##### - # ################################################################################ + ################################################################################ + ##### pCT processing ##### + ################################################################################ - # ################################################################################ - # ##### STEP 1: UTE THRESHOLD ##### - # ################################################################################ + ################################################################################ + ##### STEP 1: UTE THRESHOLD ##### + ################################################################################ - # echo "=========================================================================" - # echo "STEP 1: UTE THRESHOLD" - # echo "=========================================================================" + echo "=========================================================================" + echo "STEP 1: UTE THRESHOLD" + echo "=========================================================================" - # # Apply a threshold to the UTE image at 0, eliminating negative values - # fslmaths $ute_reg -thr 0 $ute_reg_thr0 + # Apply a threshold to the UTE image at 0, eliminating negative values + fslmaths $ute_reg -thr 0 $ute_reg_thr0 - # ################################################################################ - # ##### STEP 2: UTE BIAS FIELD CORRECTION ##### - # ################################################################################ + ################################################################################ + ##### STEP 2: UTE BIAS FIELD CORRECTION ##### + ################################################################################ - # echo "=========================================================================" - # echo "STEP 2: UTE BIAS FIELD CORRECTION" - # echo "=========================================================================" + echo "=========================================================================" + echo "STEP 2: UTE BIAS FIELD CORRECTION" + echo "=========================================================================" + + # Perform bias field correction using ANTs' N4BiasFieldCorrection to remove intensity inhomogeneity + # This step requires ANTs to be installed and available in the environment (e.g., module load ants) + if [ -f $ute_reg_thr0_corr ]; then + echo "N4BiasFieldCorrection already run; reusing estimates." + else + echo "Running N4BiasFieldCorrection." + N4BiasFieldCorrection \ + --image-dimensionality 3 \ + --input-image $ute_reg_thr0 \ + --convergence [50x50x50x50,0.0000001] \ + --bspline-fitting [180] \ + --output [$ute_reg_thr0_corr, $ute_reg_bias] + fi - # # Perform bias field correction using ANTs' N4BiasFieldCorrection to remove intensity inhomogeneity - # # This step requires ANTs to be installed and available in the environment (e.g., module load ants) - # if [ -f $ute_reg_thr0_corr ]; then - # echo "N4BiasFieldCorrection already run; reusing estimates." - # else - # echo "Running N4BiasFieldCorrection." - # N4BiasFieldCorrection \ - # --image-dimensionality 3 \ - # --input-image $ute_reg_thr0 \ - # --convergence [50x50x50x50,0.0000001] \ - # --bspline-fitting [180] \ - # --output [$ute_reg_thr0_corr, $ute_reg_bias] - # fi - - # ################################################################################ - # ##### STEP 3: UTE SOFT TISSUE NORMALISATION ##### - # ################################################################################ + ################################################################################ + ##### STEP 3: UTE SOFT TISSUE NORMALISATION ##### + ################################################################################ - # echo "=========================================================================" - # echo "STEP 3: UTE SOFT TISSUE NORMALISATION" - # echo "=========================================================================" + echo "=========================================================================" + echo "STEP 3: UTE SOFT TISSUE NORMALISATION" + echo "=========================================================================" - # # Create a file to store the soft tissue peak intensity value (for later normalization) - # touch ${path_pct}/pCT_soft_tissue_value.txt + # Create a file to store the soft tissue peak intensity value (for later normalization) + touch ${path_pct}/pCT_soft_tissue_value.txt - # # Call MATLAB to run the soft_tissue_peak.m script to find the peak intensity of soft tissue - # echo "Running MATLAB script pct_soft_tissue_peak.m..." - # cd "${path_fun_pct}" || { echo "Directory not found"; exit 1; } - # matlab_command="addpath(genpath('${path_fun}')); pct_soft_tissue_peak(\"$path_simnibs\",\"$path_pct\"); exit" - # $path_matlab -nodisplay -r "$matlab_command" || { echo "MATLAB function failed"; exit 1; } - # # Move to pCT folder - # cd "${path_pct}" || { echo "Directory not found"; exit 1; } + # Call MATLAB to run the soft_tissue_peak.m script to find the peak intensity of soft tissue + echo "Running MATLAB script pct_soft_tissue_peak.m..." + cd "${path_fun_pct}" || { echo "Directory not found"; exit 1; } + matlab_command="addpath(genpath('${path_fun}')); pct_soft_tissue_peak(\"$path_simnibs\",\"$path_pct\"); exit" + $path_matlab -nodisplay -r "$matlab_command" || { echo "MATLAB function failed"; exit 1; } + # Move to pCT folder + cd "${path_pct}" || { echo "Directory not found"; exit 1; } - # # Read the soft tissue peak value from the file created by the MATLAB script - # read peak_value < ${path_pct}/pCT_soft_tissue_value.txt - # echo "The soft tissue peak value obtained from the Matlab code is: $peak_value" + # Read the soft tissue peak value from the file created by the MATLAB script + read peak_value < ${path_pct}/pCT_soft_tissue_value.txt + echo "The soft tissue peak value obtained from the Matlab code is: $peak_value" - # # Normalize UTE image using the soft tissue peak value -> soft tissue peak = 1 - # fslmaths $ute_reg_thr0_corr -div "$peak_value" $ute_norm + # Normalize UTE image using the soft tissue peak value -> soft tissue peak = 1 + fslmaths $ute_reg_thr0_corr -div "$peak_value" $ute_norm # The skull fraction should now be between the noise/air peak (around 0) and the soft tissue peak (1). diff --git a/functions/pct/pct_skullexpand.m b/functions/pct/pct_skullexpand.m index 3c765f74..f07b9159 100644 --- a/functions/pct/pct_skullexpand.m +++ b/functions/pct/pct_skullexpand.m @@ -42,32 +42,31 @@ function pct_skullexpand(seg_path, path_pct) %% --- Parameters --- parameters = struct(); - parameters.debug = 1; + parameters.simulation.debug = 1; - parameters.seg_path = seg_path; + parameters.path.seg = seg_path; parameters.debug_path = path_pct; % If you use this elsewhere in your pipeline, keep it consistent: - parameters.t1_path_template = 'T1.nii.gz'; + parameters.path.t1_pattern = 'T1.nii.gz'; - parameters.wrapradius = 10; - parameters.skullwrap_visualize = 0; + parameters.headmodel.skull_wrap_radius = 10; + parameters.headmodel.skull_wrap_visualize = 0; - parameters.debug_dir = path_pct; % where skull_rubber_wrap_visualize writes images - parameters.results_filename_affix = ''; + parameters.io.debug_dir = path_pct; % where skull_rubber_wrap_visualize writes images + parameters.io.output_affix = ''; % Grid step (mm) from header voxel size if available % niftiinfo.PixelDimensions is [dx dy dz] in mm for most NIfTIs. if isfield(info_tissues,'PixelDimensions') && numel(info_tissues.PixelDimensions) >= 3 - parameters.grid_step_mm = double(info_tissues.PixelDimensions(1)); % First dimension, assuming isometric + parameters.grid.resolution_mm = double(info_tissues.PixelDimensions(1)); % First dimension, assuming isometric else - parameters.grid_step_mm = 1; % fallback + parameters.grid.resolution_mm = 1; % fallback end - % SimNIBS tissue label conventions: set these to what YOUR data uses - parameters.layers.brain = [1, 2]; % GM/WM (example) - parameters.layers.skin = [5]; % skin (example) - parameters.seg_labels.csf = [3]; % CSF (example) + % SimNIBS tissue label conventions (charm) + parameters.layers.brain = [1, 2]; % GM/WM + parameters.layers.skin = [5]; % skin %% --- Run skull wrap (must exist on path) --- skull_rubber_wrap(parameters, BW, medium_masks, segmented_img); diff --git a/functions/pct/pct_skullmapping.m b/functions/pct/pct_skullmapping.m index 07f2f7c5..50b4f637 100644 --- a/functions/pct/pct_skullmapping.m +++ b/functions/pct/pct_skullmapping.m @@ -102,16 +102,17 @@ function pct_skullmapping(subject_id, base_path) diffHistRestricted = y1Restricted - y2Restricted; % Difference between histograms % Define the window size for consecutive samples + smooth_window = 2; % Preallocate array for moving averages - movingAvgDiff = zeros(length(diffHistRestricted) - parameters.smooth_window + 1, 1); + movingAvgDiff = zeros(length(diffHistRestricted) - smooth_window + 1, 1); % Compute moving average of differences over the specified window size for i = 1:length(movingAvgDiff) - movingAvgDiff(i) = mean(diffHistRestricted(i:i+parameters.smooth_window-1)); + movingAvgDiff(i) = mean(diffHistRestricted(i:i+smooth_window-1)); end % Find the index of the minimum absolute moving average difference [minDiff, minIdx] = min(abs(movingAvgDiff)); % Adjust index to account for window size - crossIdx = minIdx + floor(parameters.smooth_window / 2); + crossIdx = minIdx + floor(smooth_window / 2); % Display results fprintf('Min. difference: %.2f\n', minDiff); diff --git a/functions/plot/plot_overlay.m b/functions/plot/plot_overlay.m index 502bac93..57793bbe 100644 --- a/functions/plot/plot_overlay.m +++ b/functions/plot/plot_overlay.m @@ -5,7 +5,7 @@ % % This function overlays a computed metric (e.g., intensity) % on a specific 2D slice of a 3D background image (`bg_image`). It highlights -% key positions such as the transducer position, focus position, and maximum ISppa +% key positions such as the transducer position, focus position, and maximum intensity % position. The function supports various customization options for visualization. % % Input: @@ -18,14 +18,14 @@ % * Second element: slice number along the specified axis. % trans_pos - [1x3] array specifying the transducer position in grid coordinates (row, col, slice). % focus_pos - [1x3] array specifying the focus position in grid coordinates (row, col, slice). -% max_data_pos - [1x3] array specifying the maximum ISppa position in grid coordinates (row, col, slice). +% max_data_pos - [1x3] array specifying the maximum intensity position in grid coordinates (row, col, slice). % options - Struct containing optional visualization settings: % * show_rectangles: Boolean flag to show rectangles for key positions (default: 1). % * grid_step: Grid step size in mm (default: from parameters). % * rect_size: Size of rectangles for key positions (default: 2). -% * overlay_threshold_low/high: Thresholds for alpha scaling of ISppa map. -% * overlay_color_range: Range for ISppa map color scaling. -% * color_scale: Colormap for ISppa map (default: 'viridis'). +% * overlay_threshold_low/high: Thresholds for alpha scaling of intensity map. +% * overlay_color_range: Range for intensity map color scaling. +% * color_scale: Colormap for intensity map (default: 'viridis'). % * rotation: Rotation angle for visualization (default: 0). % * show_colorbar: Boolean flag to display colorbar (default: 1). % @@ -49,7 +49,7 @@ focus_pos (:,3) max_data_pos (1,3) options.show_rectangles = 1 - options.grid_step = parameters.grid_step_mm + options.grid_step = parameters.grid.resolution_mm options.rect_size = 2 options.overlay_threshold_low (1,1) = min(overlay_image(:)) options.overlay_threshold_high (1,1) = min(overlay_image(:)) + ... @@ -74,11 +74,11 @@ error('Transducer point is outside of image boundaries'); end if any(max_data_pos > size(bg_image)) - warning('Max ISPPA point is outside of image boundaries. Regularizing to max. of dimensions'); + warning('Max intensity point is outside of image boundaries. Regularizing to max. of dimensions'); max_data_pos = min([max_data_pos; size(bg_image)]); end - %% Set thresholds and color range for ISPPA map + %% Set thresholds and color range for intensity map if options.overlay_threshold_low == options.overlay_threshold_high options.overlay_threshold_low = options.overlay_threshold_low - 0.05; end @@ -186,39 +186,10 @@ axis image; axis off; - % draw transducer - if ~isempty(trans_pos) - focal_slope = (trans_pos-focus_pos)/norm(trans_pos-focus_pos); - focal_angle = atan2(focal_slope(2),focal_slope(1)); - - grid_step = options.grid_step; - ex_plane_pos = trans_pos - (parameters.transducer(1).curv_radius_mm-parameters.transducer(1).dist_to_plane_mm)/parameters.grid_step_mm*[cos(focal_angle); sin(focal_angle)]; - geom_focus_pos = trans_pos - (parameters.transducer(1).curv_radius_mm)/grid_step*[cos(focal_angle), sin(focal_angle)]; - max_od = max(parameters.transducer(1).Elements_OD_mm); - dist_to_ep = 0.5*sqrt(4*parameters.transducer(1).curv_radius_mm^2-max_od^2)/grid_step; - ex_plane_pos_trig = geom_focus_pos + dist_to_ep *[cos(focal_angle), sin(focal_angle)]; - ort_angle = atan(-focal_slope(1)/focal_slope(2)); - - arc_halfangle = atan(max_od/2/dist_to_ep/grid_step); - - r = max(parameters.transducer(1).Elements_OD_mm)/2/grid_step; - - hold on - lineWidth = 1; - - boxColor = [235, 185, 47]/255*(1-overlay_weight) + overlay_color*overlay_weight; - LineSmoothing = 'on'; - - trans_full_depth = 16/grid_step; - trans_back = ex_plane_pos_trig+trans_full_depth*focal_slope; - line([trans_back(2)-r*sin(ort_angle), trans_back(2) + r*sin(ort_angle)], [trans_back(1)-r*cos(ort_angle), trans_back(1) + r*cos(ort_angle)], 'LineWidth', lineWidth, 'Color', boxColor,'LineSmoothing',LineSmoothing ) - line([ex_plane_pos_trig(2), trans_back(2)]-r*sin(ort_angle), [ex_plane_pos_trig(1), trans_back(1)]-r*cos(ort_angle), 'LineWidth', lineWidth,'Color', boxColor,'LineSmoothing',LineSmoothing ) - line([ex_plane_pos_trig(2), trans_back(2)]+r*sin(ort_angle), [ex_plane_pos_trig(1), trans_back(1)]+r*cos(ort_angle), 'LineWidth', lineWidth,'Color', boxColor,'LineSmoothing',LineSmoothing ) - % exit plane - line([ex_plane_pos_trig(2), ex_plane_pos_trig(2)]+r*sin(ort_angle), [trans_back(1)-r*cos(ort_angle), trans_back(1) + r*cos(ort_angle)], 'LineWidth', lineWidth,'Color', boxColor,'LineStyle', ':', 'LineSmoothing',LineSmoothing ) - % transducer curvature - [arc_x, arc_y] = get_arc(geom_focus_pos, parameters.transducer(1).curv_radius_mm/grid_step, focal_angle-arc_halfangle, focal_angle+arc_halfangle ); - plot(arc_y, arc_x, 'Color',boxColor,'LineWidth', lineWidth,'LineSmoothing',LineSmoothing ) + % draw transducer (if grid was properly set up) + if ~isempty(trans_pos) && parameters.modules.run_grid_setup == 1 + options.grid_step = parameters.grid.resolution_mm; + plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, options, 0.3, [0.2 0.6 1]) end if options.overlay_segmented @@ -231,13 +202,13 @@ ax2 = axes; if options.use_overlay_alpha - isppa_alpha = rescale(overlay_image, 'InputMin', options.overlay_threshold_low, 'InputMax', options.overlay_threshold_high); + intensity_alpha = rescale(overlay_image, 'InputMin', options.overlay_threshold_low, 'InputMax', options.overlay_threshold_high); else - isppa_alpha = ones(size(overlay_image)); - isppa_alpha(overlay_image==min(overlay_image(:))) = 0; + intensity_alpha = ones(size(overlay_image)); + intensity_alpha(overlay_image==min(overlay_image(:))) = 0; end - imagesc(ax2, overlay_image,'alphadata', isppa_alpha); + imagesc(ax2, overlay_image,'alphadata', intensity_alpha); if exist("clim")==2 % renamed in R2022a clim(options.overlay_color_range); @@ -258,23 +229,9 @@ if options.show_rectangles rect_size = options.rect_size; - if ~isempty(trans_pos) - rectangle('Position', [trans_pos(2)-1-rect_size/2 trans_pos(1)-rect_size/2 rect_size*2+1 rect_size*2+1], 'EdgeColor', boxColor, 'LineWidth',2,'LineStyle','-') - text(trans_pos(2)-1, trans_pos(1)+10, [num2str(round((trans_pos(2)-1)*parameters.grid_step_mm))], 'Color', 'w') - % the following plots the onset of the grid; note: grid is in voxels, not mm - rectangle('Position', [trans_pos(2)-parameters.transducer(1).trans_pos(3)-rect_size/2 trans_pos(1)-rect_size/2 rect_size*2+1 rect_size*2+1], 'EdgeColor', 'w', 'LineWidth',1,'LineStyle',':') - rectangle('Position', [parameters.grid_dims(3)-rect_size/2 trans_pos(1)-rect_size/2 rect_size*2+1 rect_size*2+1], 'EdgeColor', 'w', 'LineWidth',1,'LineStyle',':') - % exit plane - dist_to_exit_plane = parameters.transducer(1).curv_radius_mm-parameters.transducer(1).dist_to_plane_mm; - % convert to voxels - dist_to_exit_plane_vox = round(dist_to_exit_plane*(1/parameters.grid_step_mm)); - rectangle('Position', [(trans_pos(2)-1+dist_to_exit_plane_vox)-rect_size/2 trans_pos(1)-rect_size/2 rect_size*2+1 rect_size*2+1], 'EdgeColor', boxColor, 'LineWidth',1,'LineStyle',':') - %rectangle('Position', [(ex_plane_pos(4)-1)-rect_size/2 trans_pos(1)-rect_size/2 rect_size*2+1 rect_size*2+1], 'EdgeColor', boxColor, 'LineWidth',1,'LineStyle',':') - text(trans_pos(2)-1+dist_to_exit_plane_vox, trans_pos(1)+10, [num2str(round((trans_pos(2)-1+dist_to_exit_plane_vox)*parameters.grid_step_mm)), 'mm'], 'Color', 'w') - end rectangle('Position', [focus_pos(2)-rect_size/2 focus_pos(1)-rect_size/2 rect_size*2+1 rect_size*2+1], 'EdgeColor', 'r', 'LineWidth',1,'LineStyle','-') rectangle('Position', [max_data_pos(2)-rect_size/2 max_data_pos(1)-rect_size/2 rect_size*2+1 rect_size*2+1], 'EdgeColor', 'b', 'LineWidth',1,'LineStyle','-') - text(max_data_pos(2), max_data_pos(1)+10, [num2str(round(max_data_pos(2)*parameters.grid_step_mm)), 'mm'], 'Color', 'w') + text(max_data_pos(2), max_data_pos(1)+10, [num2str(round(max_data_pos(2)*parameters.grid.resolution_mm)), 'mm'], 'Color', 'w') end linkaxes([ax1, ax2], 'xy'); % Synchronize both axes to avoid shifts diff --git a/functions/plot/plot_overlay_2d.m b/functions/plot/plot_overlay_2d.m index 054b3d90..0d79305a 100644 --- a/functions/plot/plot_overlay_2d.m +++ b/functions/plot/plot_overlay_2d.m @@ -5,14 +5,14 @@ after_exit_plane_mask, ... trans_pos, ... focus_pos, ... - max_isppa_pos, ... + max_intensity_pos, ... options) % plot_overlay_2D Visualizes overlay on a background image. % % This function visualizes an overlay % on a 2D background image (`bg_image`) and highlights key positions such as -% the transducer position, focus position, and maximum ISPPA position. The +% the transducer position, focus position, and maximum intensity position. The % visualization includes additional masks for regions before and after the % transducer's exit plane. % @@ -23,14 +23,14 @@ % after_exit_plane_mask - [Nx x Ny] binary mask for regions after the transducer's exit plane. % trans_pos - [1x2] array specifying the transducer position in grid coordinates (row, col). % focus_pos - [1x2] array specifying the focus position in grid coordinates (row, col). -% max_isppa_pos - [1x2] array specifying the maximum ISppa position in grid coordinates (row, col). +% max_intensity_pos - [1x2] array specifying the maximum intensity position in grid coordinates (row, col). % options - Struct containing optional visualization settings: % * show_rectangles: Boolean flag to show rectangles for key positions (default: 1). % * rect_size: Size of rectangles for key positions (default: 2). -% * overlay_threshold_low/high: Thresholds for alpha scaling of ISppa map. +% * overlay_threshold_low/high: Thresholds for alpha scaling of intensity map. % * overlay_color_range: Range for overlay map color scaling. % * bg_bw_range: Black/white min-max range for background map. -% * color_scale: Colormap for ISppa map (default: 'viridis'). +% * color_scale: Colormap for intensity map (default: 'viridis'). % * show_colorbar: Boolean flag to display colorbar (default: 1). % Output: % h - Handle to the created figure. @@ -42,7 +42,7 @@ after_exit_plane_mask (:,:) trans_pos (:,2) focus_pos (:,2) - max_isppa_pos (:,2) + max_intensity_pos (:,2) options.show_rectangles = 1 options.rect_size = 2 options.overlay_threshold_low (1,1) = min(overlay_image(:)) @@ -55,7 +55,7 @@ options.show_colorbar = 1 end - %% Set thresholds and color range for ISppa map + %% Set thresholds and color range for intensity map if options.overlay_threshold_low == options.overlay_threshold_high options.overlay_threshold_low = options.overlay_threshold_low - 0.05; end @@ -88,13 +88,13 @@ ax2 = axes; if options.use_overlay_alpha - isppa_alpha = rescale(overlay_image, 'InputMin', options.overlay_threshold_low, 'InputMax', options.overlay_threshold_high); + intensity_alpha = rescale(overlay_image, 'InputMin', options.overlay_threshold_low, 'InputMax', options.overlay_threshold_high); else - isppa_alpha = ones(size(overlay_image)); - isppa_alpha(overlay_image==min(overlay_image(:))) = 0; + intensity_alpha = ones(size(overlay_image)); + intensity_alpha(overlay_image==min(overlay_image(:))) = 0; end - imagesc(ax2, overlay_image,'alphadata', isppa_alpha); + imagesc(ax2, overlay_image,'alphadata', intensity_alpha); if exist("clim")==2 % renamed in R2022a clim(options.overlay_color_range); @@ -144,8 +144,8 @@ % Maximum intensity position (blue rectangle) rectangle(... - 'Position', [max_isppa_pos(2) - rect_size / 2, ... - max_isppa_pos(1) - rect_size / 2, ... + 'Position', [max_intensity_pos(2) - rect_size / 2, ... + max_intensity_pos(1) - rect_size / 2, ... rect_size_horizontal, ... rect_size * 2 + 1], ... 'EdgeColor', 'b', 'LineWidth', 1, 'LineStyle', '-'); diff --git a/functions/plot/plot_t1_with_transducer.m b/functions/plot/plot_t1_with_transducer.m index c929b9ae..1d882686 100644 --- a/functions/plot/plot_t1_with_transducer.m +++ b/functions/plot/plot_t1_with_transducer.m @@ -30,13 +30,30 @@ plot_options.slice_ind (1,1) double = 0 % Default slice index (transducer position) end + %% Pad T1 image to ensure it includes transducer/focus position (Negative/Pre-padding) + min_coords = min([1 1 1; trans_pos_grid; focus_pos_grid]); + if any(min_coords < 1) + pad_amount = ceil(1 - min_coords); % Amount to shift to make min 1 + % Pad at the beginning (pre) + t1_image = padarray(t1_image, pad_amount, 0, 'pre'); + + % Shift coordinates + trans_pos_grid = trans_pos_grid + pad_amount; + focus_pos_grid = focus_pos_grid + pad_amount; + + % Shift slice index if provided + if ~isempty(plot_options.slice_ind) && plot_options.slice_ind ~= 0 + plot_options.slice_ind = plot_options.slice_ind + pad_amount(plot_options.slice_dim); + end + end + %% Determine slice index if not provided if isempty(plot_options.slice_ind) || plot_options.slice_ind == 0 plot_options.slice_ind = trans_pos_grid(plot_options.slice_dim); % Use transducer position as default slice index end - %% Pad T1 image to ensure it includes transducer position - im_size = max(size(t1_image), trans_pos_grid); % Determine required image size based on transducer position + %% Pad T1 image to ensure it includes transducer position (Positive/Post-padding) + im_size = max([size(t1_image); trans_pos_grid; focus_pos_grid]); if ~isequal(im_size, size(t1_image)) t1_image = padarray(t1_image, im_size - size(t1_image), 'post'); % Pad image to include transducer position end diff --git a/functions/plot/plot_transducer_overlay.m b/functions/plot/plot_transducer_overlay.m new file mode 100644 index 00000000..31d38286 --- /dev/null +++ b/functions/plot/plot_transducer_overlay.m @@ -0,0 +1,119 @@ +function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, options, overlay_weight, overlay_color) +%PLOT_TRANSDUCER_OVERLAY Complete transducer visualization with rectangles and labels +% Inputs: +% parameters - Struct with .transducer(1).{curv_radius_mm, dist_to_plane_mm, +% Elements_OD_mm, grid.resolution_mm, trans_pos, grid_dims} +% trans_pos - [2x1] Transducer center position (grid units) +% focus_pos - [2x1] Acoustic focus position (grid units) +% max_data_pos - [2x1] Max data position for visualization context +% options - Struct with .grid_step, .show_rectangles, .rect_size +% overlay_weight - [0,1] Blend weight for overlay_color +% overlay_color - [1x3] RGB color for blending + +if isempty(trans_pos) + return; +end + +%% 1. GEOMETRY CALCULATIONS (curved transducer details) +focal_slope = (trans_pos(:) - focus_pos(:)) / norm(trans_pos(:) - focus_pos(:)); +focal_angle = atan2(focal_slope(2), focal_slope(1)); + +grid_step = options.grid_step; +max_od = max(parameters.transducer(1).Elements_OD_mm); +r = max_od / 2 / grid_step; + +% Exit plane positions (two methods for consistency) +dist_to_exit_plane_mm = parameters.transducer(1).curv_radius_mm - parameters.transducer(1).dist_to_plane_mm; +dist_to_exit_plane_vox = round(dist_to_exit_plane_mm / parameters.grid.resolution_mm); +ex_plane_x_simple = trans_pos(2) - 1 + dist_to_exit_plane_vox; % Rectangle method + +% Full geometry (for curved visualization) +geom_focus_pos = trans_pos(:)' - (parameters.transducer(1).curv_radius_mm / grid_step) * [cos(focal_angle), sin(focal_angle)]; +dist_to_ep = 0.5 * sqrt(4*parameters.transducer(1).curv_radius_mm^2 - max_od^2) / grid_step; +ex_plane_pos_trig = geom_focus_pos + dist_to_ep * [cos(focal_angle), sin(focal_angle)]; +ort_angle = atan2(-focal_slope(1), focal_slope(2)); + +%% 2. VISUAL PROPERTIES +hold on; +lineWidth = 1.5; +boxColor = [235, 185, 47]/255 * (1-overlay_weight) + overlay_color * overlay_weight; +LineSmoothing = 'on'; +rect_size = options.rect_size; + +%% 3. RECTANGLE OVERLAYS (if enabled) +if options.show_rectangles + % TRANSDUCER CENTER + rectangle('Position', [trans_pos(2)-1-rect_size/2, trans_pos(1)-rect_size/2, ... + rect_size*2+1, rect_size*2+1], ... + 'EdgeColor', boxColor, 'LineWidth', 2, 'LineStyle', '-'); + text(trans_pos(2)-1, trans_pos(1)+10, ... + num2str(round((trans_pos(2)-1)*parameters.grid.resolution_mm)), ... + 'Color', 'w', 'FontSize', 10, 'FontWeight', 'bold'); + + % GRID ONSET MARKERS + try + rectangle('Position', [trans_pos(2)-parameters.transducer(1).trans_pos(3)-rect_size/2, ... + trans_pos(1)-rect_size/2, rect_size*2+1, rect_size*2+1], ... + 'EdgeColor', 'w', 'LineWidth', 1, 'LineStyle', ':'); + rectangle('Position', [parameters.grid.dims(3)-rect_size/2, trans_pos(1)-rect_size/2, ... + rect_size*2+1, rect_size*2+1], ... + 'EdgeColor', 'w', 'LineWidth', 1, 'LineStyle', ':'); + catch ME + disp(ME); + end + + % EXIT PLANE (simple method matching original) + rectangle('Position', [ex_plane_x_simple-rect_size/2, trans_pos(1)-rect_size/2, ... + rect_size*2+1, rect_size*2+1], ... + 'EdgeColor', boxColor, 'LineWidth', 1, 'LineStyle', ':'); + text(ex_plane_x_simple, trans_pos(1)+10, ... + sprintf('%.0f mm', round(ex_plane_x_simple*parameters.grid.resolution_mm)), ... + 'Color', 'w', 'FontSize', 10, 'FontWeight', 'bold'); +end + +%% 4. FOCUS AND DATA POSITION +if options.show_rectangles + rectangle('Position', [focus_pos(2)-rect_size/2, focus_pos(1)-rect_size/2, ... + rect_size*2+1, rect_size*2+1], ... + 'EdgeColor', 'r', 'LineWidth', 1, 'LineStyle', '-'); + rectangle('Position', [max_data_pos(2)-rect_size/2, max_data_pos(1)-rect_size/2, ... + rect_size*2+1, rect_size*2+1], ... + 'EdgeColor', 'b', 'LineWidth', 1, 'LineStyle', '-'); + text(max_data_pos(2), max_data_pos(1)+10, ... + sprintf('%.0f mm', round(max_data_pos(2)*parameters.grid.resolution_mm)), ... + 'Color', 'w', 'FontSize', 10); +end + +%% 5. DETAILED TRANSDUCER GEOMETRY (always shown) +trans_full_depth = 16 / grid_step; +trans_back = ex_plane_pos_trig + trans_full_depth * focal_slope(:)'; + +% BACK PLANE, SIDE WALLS, EXIT CHORD (as before) +line([trans_back(2)-r*sin(ort_angle), trans_back(2)+r*sin(ort_angle)], ... + [trans_back(1)-r*cos(ort_angle), trans_back(1)+r*cos(ort_angle)], ... + 'LineWidth', lineWidth, 'Color', boxColor, 'LineSmoothing', LineSmoothing); + +line([ex_plane_pos_trig(2), trans_back(2)] - r*sin(ort_angle), ... + [ex_plane_pos_trig(1), trans_back(1)] - r*cos(ort_angle), ... + 'LineWidth', lineWidth, 'Color', boxColor, 'LineSmoothing', LineSmoothing); + +line([ex_plane_pos_trig(2), trans_back(2)] + r*sin(ort_angle), ... + [ex_plane_pos_trig(1), trans_back(1)] + r*cos(ort_angle), ... + 'LineWidth', lineWidth, 'Color', boxColor, 'LineSmoothing', LineSmoothing); + +% Exit plane chord +left_edge_x = ex_plane_pos_trig(2) - r * sin(ort_angle); +left_edge_y = ex_plane_pos_trig(1) - r * cos(ort_angle); +right_edge_x = ex_plane_pos_trig(2) + r * sin(ort_angle); +right_edge_y = ex_plane_pos_trig(1) + r * cos(ort_angle); +line([left_edge_x, right_edge_x], [left_edge_y, right_edge_y], ... + 'LineWidth', lineWidth, 'Color', boxColor, 'LineStyle', ':', 'LineSmoothing', LineSmoothing); + +% Curved front surface +arc_halfangle = atan((max_od/2) / (dist_to_ep * grid_step)); +[arc_x, arc_y] = get_arc(geom_focus_pos, parameters.transducer(1).curv_radius_mm/grid_step, ... + focal_angle-arc_halfangle, focal_angle+arc_halfangle); +plot(arc_y, arc_x, 'Color', boxColor, 'LineWidth', lineWidth, 'LineSmoothing', LineSmoothing); + +hold off; +end \ No newline at end of file diff --git a/functions/plot/show_3d_head.m b/functions/plot/show_3d_head.m index 11ed2de2..10b2725e 100644 --- a/functions/plot/show_3d_head.m +++ b/functions/plot/show_3d_head.m @@ -92,7 +92,7 @@ function show_3d_head(segmented_img, target_xyz, trans_xyz, parameters, pixel_si for_caps = segmented_img_ds; % discard segmentation layers that are not GM/WM/CSF/bone availableLayers = unique(for_caps); - relevantLayers = getidx(parameters.seg_labels, {'wm'; 'gm'; 'csf'; 'skull'}); + relevantLayers = getidx(charm_seg_labels(), {'wm'; 'gm'; 'csf'; 'skull'}); discardedLayers = availableLayers(~ismember(availableLayers, relevantLayers)); for_caps(ismember(for_caps, discardedLayers)) = 0; cap_surf = isocaps(for_caps*10,4); diff --git a/functions/plot/show_positioning_plots.m b/functions/plot/show_positioning_plots.m index a65785c8..63fa3219 100644 --- a/functions/plot/show_positioning_plots.m +++ b/functions/plot/show_positioning_plots.m @@ -85,7 +85,7 @@ function show_positioning_plots(segmented_img_orig, t1_pixel_size, trans_pos_ori [trans_pos_final(1,1) focus_pos_final(1,1)], 'Color', 'white'); % Add bounding box for visualization - get_transducer_box(trans_pos_final(1,[1,3])', focus_pos_final(1,[1,3])', parameters.grid_step_mm, parameters); + get_transducer_box(trans_pos_final(1,[1,3])', focus_pos_final(1,[1,3])', parameters.grid.resolution_mm, parameters); colormap(ax3,[0.3 0.3 0.3; lines(12)]); diff --git a/functions/single_subject_pipeline.m b/functions/prestus_pipeline.m similarity index 60% rename from functions/single_subject_pipeline.m rename to functions/prestus_pipeline.m index c92591dd..1841cda2 100644 --- a/functions/single_subject_pipeline.m +++ b/functions/prestus_pipeline.m @@ -1,13 +1,11 @@ -function [parameters] = single_subject_pipeline(subject_id, parameters, options) +function [parameters] = prestus_pipeline(parameters, options) arguments - subject_id parameters struct - options.adopted_heatmap (:,:,:) = [] - options.sequential_configs struct = struct() + options struct = struct() end % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % - % Single subject pipeline % + % PRESTUS pipeline % % % % This serves as the main pipeline for simulating the sonication % % effects on the bone and neural tissue of individual subjects. % @@ -20,7 +18,7 @@ % run the pipeline. % % % % Some notes: % - % - Matlab 2022b+, SimNIBS 4.0, and k-Wave 1.4 have been tested % + % - Matlab 2023b*, SimNIBS 4, and k-Wave 1.4.1 have been tested % % - 'subject_id' must be a number. % % - 'parameters' is a structure (see default_config for options) % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % @@ -32,10 +30,10 @@ currentLoc = fileparts(mfilename("fullpath")); % add functions here to detect path setup function addpath(genpath(fullfile(currentLoc, '..', 'functions'))); - [parameters] = path_log_setup(parameters, get_prestus_path, subject_id); + [parameters] = path_log_setup(parameters, get_prestus_path); fprintf('Starting processing for subject %i %s\n',... - parameters.subject_id, parameters.results_filename_affix) + parameters.subject_id, parameters.io.output_affix) % ==================================================================== %% SEGMENT planning image (structural MRI) with SimNIBS @@ -45,22 +43,19 @@ fprintf('========================================\n'); fprintf('SEGMENTATION \n'); fprintf('========================================\n\n'); + log_timer('start','segmentation', parameters.path.seg); - if contains(parameters.simulation_medium, {'layered'}) - log_timer('start','segmentation', parameters.seg_path); - preproc_segmentation(parameters) - log_timer('stop','segmentation'); + if contains(parameters.simulation.medium, {'layered'}) + parameters = preproc_segmentation(parameters); else disp('No head segmentation necessary...') end + log_timer('stop','segmentation'); - % EXIT is no simulation is requested (= segmentation only) - if ~any([parameters.run_source_setup, ... - parameters.run_acoustic_sims, ... - parameters.run_heating_sims, ... - parameters.run_posthoc_water_sims]) - disp(newline) - disp('No simulation requested...') + if isfield(parameters.modules, 'segmentation_only') && parameters.modules.segmentation_only + fprintf('Only segmentation requested: finishing.\n'); + log_timer('stop','prestus_pipeline') + diary('off') return; end @@ -73,59 +68,63 @@ fprintf('========================================\n'); fprintf('GRID SETUP & HEAD PREPROC \n'); fprintf('========================================\n\n'); + log_timer('start','preproc', parameters.io.output_dir); - log_timer('start','preproc', parameters.output_dir); - - % Focal distance calculation (if not specified) - parameters = focal_distance_calculation(parameters); - - % Set up grid by preprocessing the planning image or reading in phantom - [parameters, medium_masks, segmentation, bone, planimg] = ... - grid_tissue_setup(parameters); - - % Position the transducer(s) in the grid - [parameters] = grid_transducer_location(parameters, planimg); - - % Adapt grid to axisymmetry (if requested) - [parameters, segmentation, bone, medium_masks] = ... - grid_axisymmetry(parameters, segmentation, bone, medium_masks); - - % Extract variables for quick access - trans_pos = parameters.transducer(1).trans_pos; - focus_pos = parameters.transducer(1).focus_pos; - + if ~isfield(parameters.modules, 'run_grid_setup') || parameters.modules.run_grid_setup==1 + % Focal distance calculation (if not specified) + parameters = focal_distance_calculation(parameters); + + % Set up grid by preprocessing the planning image or reading in phantom + [parameters, medium_masks, segmentation, bone, planimg] = ... + grid_tissue_setup(parameters); + + % Position the transducer(s) in the grid + [parameters] = grid_transducer_location(parameters, planimg); + + % Adapt grid to axisymmetry (if requested) + [parameters, segmentation, bone, medium_masks] = ... + grid_axisymmetry(parameters, segmentation, bone, medium_masks); + + % Extract variables for quick access + trans_pos = parameters.transducer(1).trans_pos; + focus_pos = parameters.transducer(1).focus_pos; + else + disp('No grid setup requested...no simulations will be performed.') + end log_timer('stop','preproc'); % ==================================================================== %% SETUP MEDIUM % ==================================================================== % For more documentation, see 'medium_setup' - + fprintf('========================================\n'); fprintf('MEDIUM PROPERTY MAPPING \n'); fprintf('========================================\n\n'); + log_timer('start','medium', parameters.io.output_dir); - log_timer('start','medium', parameters.output_dir); - - if parameters.usepseudoCT == 1 - kwave_medium = medium_setup(parameters, medium_masks, planimg, bone); - else - kwave_medium = medium_setup(parameters, medium_masks, planimg); - end - - % split temp_0 & absorption_fraction from kwave_medium (to pass internal kwave checks) - if isfield(parameters, 'adopted_heatmap') && parameters.adopted_heatmap == 1 && isfile(parameters.adopted_heatmap) - heatmap_image = niftiread(parameters.adopted_heatmap); - fprintf('\nAdopting heatmap %s from previous simulation\n', parameters.adopted_heatmap) - medium_plus.temp_0 = double(tformarray(heatmap_image, maketform("affine", planimg.transf), ... - makeresampler('nearest', 'fill'), [1 2 3], [1 2 3], size(medium_masks), [], 0)); + if ~isfield(parameters.modules, 'run_medium_setup') || parameters.modules.run_medium_setup==1 + if parameters.pct.enabled == 1 + kwave_medium = medium_setup(parameters, medium_masks, planimg, bone); + else + kwave_medium = medium_setup(parameters, medium_masks, planimg); + end + + % split temp_0 & absorption_fraction from kwave_medium (to pass internal kwave checks) + if isfield(parameters.io, 'adopted_heatmap') && parameters.io.adopted_heatmap == 1 && isfile(parameters.io.adopted_heatmap) + heatmap_image = niftiread(parameters.io.adopted_heatmap); + fprintf('\nAdopting heatmap %s from previous simulation\n', parameters.io.adopted_heatmap) + medium_plus.temp_0 = double(tformarray(heatmap_image, maketform("affine", planimg.transf), ... + makeresampler('nearest', 'fill'), [1 2 3], [1 2 3], size(medium_masks), [], 0)); + else + medium_plus.temp_0 = kwave_medium.temp_0; + end + kwave_medium = rmfield(kwave_medium, 'temp_0'); + medium_plus.absorption_fraction = kwave_medium.absorption_fraction; + kwave_medium = rmfield(kwave_medium, 'absorption_fraction'); else - medium_plus.temp_0 = kwave_medium.temp_0; + disp('No medium mapping requested...no simulations will be performed.') end - kwave_medium = rmfield(kwave_medium, 'temp_0'); - medium_plus.absorption_fraction = kwave_medium.absorption_fraction; - kwave_medium = rmfield(kwave_medium, 'absorption_fraction'); - log_timer('stop','medium'); % ==================================================================== @@ -137,10 +136,9 @@ fprintf('========================================\n'); fprintf('K-WAVE SOURCE SETUP \n'); fprintf('========================================\n\n'); + log_timer('start','source', parameters.io.output_dir); - if parameters.run_source_setup - log_timer('start','source', parameters.output_dir); - + if ~isfield(parameters.modules, 'run_source_setup') || parameters.modules.run_source_setup==1 max_sound_speed = max(kwave_medium.sound_speed(:)); [kgrid, source, sensor, source_labels] = ... source_sensor_setup(... @@ -150,21 +148,21 @@ focus_pos); % Check stability & adjust source time step if necessary - if isfield(parameters, 'source_limit_fraction') && parameters.source_limit_fraction ~=0 + if isfield(parameters.grid, 'source_limit_fraction') && parameters.grid.source_limit_fraction ~=0 disp('Check stability...') dt_stability_limit = checkStability(kgrid, kwave_medium); fprintf('Stability limit estimate for time step: %.1d.\n', dt_stability_limit); if ~isinf(dt_stability_limit) && kgrid.dt > dt_stability_limit disp('Adapt time step for simulation stability...') % Use (by default 90%) fraction of the theoretical limit (which is only an approximation in the heterogenous medium case: http://www.k-wave.org/documentation/checkStability.php) - grid_time_step = dt_stability_limit*parameters.source_limit_fraction; + grid_time_step = dt_stability_limit*parameters.grid.source_limit_fraction; [kgrid, source, sensor, source_labels] = source_sensor_setup(parameters, max_sound_speed, trans_pos, focus_pos, grid_time_step); end end - log_timer('stop', 'source'); else - disp('No source setup requested... no simulations will be performed.') + disp('No source setup requested...no simulations will be performed.') end + log_timer('stop', 'source'); % ==================================================================== %% ACOUSTIC SIMULATION @@ -174,18 +172,17 @@ fprintf('========================================\n'); fprintf('ACOUSTIC SIMULATION \n'); fprintf('========================================\n\n'); + log_timer('start','acoustic', parameters.io.output_dir); - log_timer('start','acoustic', parameters.output_dir); - - filename_sensor_data = fullfile(parameters.output_dir, ... + filename_sensor_data = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_%s_results%s.mat', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); - - parameters.acoustics_available = 0; - if parameters.run_acoustic_sims && ... - confirm_overwriting(filename_sensor_data, parameters) && ... - (parameters.interactive == 0 || ... - confirmation_dlg('Running the simulations will take a long time, are you sure?', 'Yes', 'No')) + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + + parameters.state.acoustics_available = 0; + if isfield(parameters.modules, 'run_acoustic_sims') && parameters.modules.run_acoustic_sims &&... + confirm_overwriting(filename_sensor_data, parameters) && ... + (parameters.simulation.interactive == 0 || ... + confirmation_dlg('Running the simulations will take a long time, are you sure?', 'Yes', 'No')) [sensor_data, parameters, segmentation, medium_masks, kwave_medium, kgrid, source, source_labels] = ... acoustic_wrapper(... @@ -199,15 +196,17 @@ segmentation, ... source_labels); - parameters.acoustics_available = 1; + parameters.state.acoustics_available = 1; + elseif exist(filename_sensor_data, 'file') disp('Skipping acoustic simulation, loading existing output file.') load(filename_sensor_data); - parameters.acoustics_available = 1; + parameters.state.acoustics_available = 1; else - parameters.acoustics_available = 0; + disp('No acoustic simulation available or requested ... skipping analysis') + parameters.state.acoustics_available = 0; + parameters.modules.run_acoustic_analysis = 0; end - log_timer('stop', 'acoustic'); % ========================================================================= @@ -217,22 +216,20 @@ fprintf('========================================\n'); fprintf('ACOUSTIC ANALYSIS \n'); fprintf('========================================\n\n'); + log_timer('start','acoustic_analysis', parameters.io.output_dir); - if parameters.acoustics_available == 1 - log_timer('start','acoustic_analysis', parameters.output_dir); - - % perform acoustic analysis - [results_acoustic, acoustic_isppa, acoustic_MI, acoustic_pressure, highlighted_pos] = ... + if (~isfield(parameters.modules, 'run_acoustic_analysis') || parameters.modules.run_acoustic_analysis) + [results_acoustic, acoustic_Ipa, acoustic_MI, acoustic_pressure, highlighted_pos] = ... acoustic_analysis(parameters, kwave_medium, medium_masks, sensor_data, segmentation, source_labels); - log_timer('stop', 'acoustic_analysis'); else - disp('No acoustic simulation results available. Skipping analysis...') + disp('No acoustic simulation results available (or requested). Skipping analysis...') results_acoustic = []; - acoustic_isppa = []; + acoustic_Ipa = []; acoustic_MI = []; acoustic_pressure = []; highlighted_pos = []; end + log_timer('stop', 'acoustic_analysis'); % ========================================================================= %% THERMAL SIMULATIONS @@ -241,21 +238,20 @@ fprintf('========================================\n'); fprintf('THERMAL SIMULATIONS \n'); fprintf('========================================\n\n'); + log_timer('start','thermal', parameters.io.output_dir); - log_timer('start','thermal', parameters.output_dir); - - parameters.heating_available = 0; - if isfield(parameters, 'run_heating_sims') && parameters.run_heating_sims && parameters.acoustics_available == 1 + parameters.state.heating_available = 0; + if isfield(parameters.modules, 'run_heating_sims') && parameters.modules.run_heating_sims && parameters.state.acoustics_available == 1 disp('Starting thermal simulations...') % Name of thermal simulation output file - filename_heating_data = fullfile(parameters.output_dir,... + filename_heating_data = fullfile(parameters.io.output_dir,... sprintf('sub-%03d_%s_heating_res%s.mat', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); % Check whether thermal results axist and - if so - should be overwritten - if confirm_overwriting(filename_heating_data, parameters) && (parameters.interactive == 0 || ... - confirmation_dlg('Running the thermal simulations will take a long time, are you sure?', 'Yes', 'No')) + if confirm_overwriting(filename_heating_data, parameters) && (parameters.simulation.interactive == 0 || ... + confirmation_dlg('Running the thermal simulations will take a long time, are you sure?', 'Yes', 'No')) % Pass thermally relevant (but kwave-irregular) medium fields kwave_medium.temp_0 = medium_plus.temp_0; @@ -263,7 +259,7 @@ clear medium_plus; % convert medium fields to 3D (if axisymmetry was used) - if isfield(parameters, 'axisymmetric') && parameters.axisymmetric == 1 + if isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 kwave_medium.temp_0 = radialExpand2DTo3D(kwave_medium.temp_0); kwave_medium.absorption_fraction = radialExpand2DTo3D(kwave_medium.absorption_fraction); end @@ -288,7 +284,7 @@ planimg.transf, ... medium_masks); - if isfield(parameters, 'savemat') && parameters.savemat==0 + if isfield(parameters.io, 'save_matrices') && parameters.io.save_matrices==0 disp("Not saving thermal simulation output matrices ...") else save(filename_heating_data, ... @@ -299,17 +295,19 @@ 'kwave_medium', ... '-v7.3'); end - parameters.heating_available = 1; + parameters.state.heating_available = 1; elseif exist(filename_heating_data, 'file') disp('Skipping thermal simulation, loading existing output file.') load(filename_heating_data); - parameters.heating_available = 1; - else + parameters.state.heating_available = 1; + else warning('Heating simulations requested, but no acoustic results available. Other misspecification is possible.') - parameters.heating_available = 0; + parameters.state.heating_available = 0; + parameters.modules.run_thermal_analysis = 0; + results_heating = []; end else - parameters.heating_available = 0; + parameters.state.heating_available = 0; results_heating = []; end log_timer('stop','thermal'); @@ -321,15 +319,16 @@ fprintf('========================================\n'); fprintf('THERMAL ANALYSIS \n'); fprintf('========================================\n\n'); + log_timer('start','thermal_analysis', parameters.io.output_dir); - if parameters.heating_available == 1 - log_timer('start','thermal_analysis', parameters.output_dir); + if parameters.state.heating_available == 1 && ... + (~isfield(parameters.modules, 'run_thermal_analysis') || parameters.modules.run_thermal_analysis) thermal_analysis(parameters, results_heating, time_status_seq, ... medium_masks, highlighted_pos, segmentation); - log_timer('stop','thermal_analysis'); else - disp('No heating simulation results available. Skipping thermal analysis...') + disp('No heating simulation results available (or requested). Skipping thermal analysis...') end + log_timer('stop','thermal_analysis'); % ================================================================ %% CREATE NIFTI IMAGES @@ -339,12 +338,15 @@ fprintf('========================================\n'); fprintf('NIFTI IMAGES \n'); fprintf('========================================\n\n'); - - log_timer('start','nifti', parameters.output_dir); + log_timer('start','nifti', parameters.io.output_dir); - simulation_nifti(parameters, planimg, results_acoustic, ... - acoustic_isppa, acoustic_MI, acoustic_pressure, ... - medium_masks, results_heating, kwave_medium, highlighted_pos) + if ~isfield(parameters.modules, 'run_nifti_creation') || parameters.modules.run_nifti_creation==1 + simulation_nifti(parameters, planimg, results_acoustic, ... + acoustic_Ipa, acoustic_MI, acoustic_pressure, ... + medium_masks, results_heating, kwave_medium, highlighted_pos) + else + disp('No nifti creation requested...') + end log_timer('stop','nifti'); @@ -360,14 +362,14 @@ fprintf('========================================\n\n'); % capture time, RAM, & GB load of pipeline - log_timer('stop','single_subject_pipeline') + log_timer('stop','prestus_pipeline') % indicate success disp('Pipeline finished successfully'); % Generate HTML simulation report (after all timers, before diary closes) - if isfield(parameters, 'generate_report') && parameters.generate_report - generate_simulation_report(parameters, results_acoustic, results_heating, highlighted_pos); + if isfield(parameters.modules, 'generate_report') && parameters.modules.generate_report + generate_simulation_report(parameters); end % end logging @@ -378,8 +380,8 @@ % ==================================================================== % To check sonication parameters of the transducer in free water - if isfield(parameters, 'run_posthoc_water_sims') && parameters.run_posthoc_water_sims && ... - contains(parameters.simulation_medium, {'layered', 'phantom'}) + if isfield(parameters.modules, 'run_posthoc_water_sims') && parameters.modules.run_posthoc_water_sims && ... + contains(parameters.simulation.medium, {'layered', 'phantom'}) fprintf('POST-HOC ACOUSTIC WATER SIMULATION \n'); @@ -389,23 +391,21 @@ 'Consider running separate configs to test individual transducers.']); end water_parameters = parameters; - water_parameters.simulation_medium = 'water'; - water_parameters.run_heating_sims = 0; - water_parameters.run_posthoc_water_sims = 0; - water_parameters.debug = 0; + water_parameters.simulation.medium = 'water'; + water_parameters.modules.run_heating_sims = 0; + water_parameters.modules.run_posthoc_water_sims = 0; + water_parameters.simulation.debug = 0; % run with the same grid dimension as the real simulation - water_parameters.default_grid_dims = water_parameters.grid_dims; + water_parameters.grid.default_dims = water_parameters.grid.dims; % restore subject-specific path to original path if done earlier in this function - if isfield(water_parameters,'subject_subfolder') && water_parameters.subject_subfolder == 1 - water_parameters.output_dir = fileparts(water_parameters.output_dir); + if isfield(water_parameters.io,'subject_subfolder') && water_parameters.path.subject_subfolder == 1 + water_parameters.io.output_dir = fileparts(water_parameters.io.output_dir); end % inherit submit medium from main pipeline - switch parameters.hpc_submit_medium - case 'slurm' - single_subject_pipeline_with_slurm(subject_id, water_parameters, 0); - case 'matlab' - single_subject_pipeline(parameters.subject_id, water_parameters); - end + water_parameters.hpc.timelimit = '05:00:00'; + water_parameters.hpc.memorylimit = 40; + water_parameters.hpc.wait_for_job = false; + prestus_pipeline_start(water_parameters); clear water_parameters; end @@ -413,7 +413,7 @@ %% FOLLOW-UP SIMULATION WITH IDENTICAL MEDIUM % ==================================================================== - if ~isempty(fieldnames(options.sequential_configs)) + if any(strcmp(fieldnames(options), 'sequential_configs')) fprintf('========================================\n'); fprintf('FOLLOW-UP SIMULATION WITH IDENTICAL MEDIUM \n'); fprintf('========================================\n\n'); @@ -426,17 +426,17 @@ sequential_parameters = sequential_configs.(lowestField); sequential_configs = rmfield(sequential_configs, lowestField); % restore subject-specific path to original path if done earlier in this function - sequential_parameters.adopted_heatmap = fullfile(parameters.output_dir, sprintf('sub-%03d_final_%s_orig_coord%s',... - parameters.subject_id, 'heating_end', parameters.results_filename_affix)); - sequential_parameters.adopted_cem43 = fullfile(parameters.output_dir, sprintf('sub-%03d_final_%s_orig_coord%s',... - parameters.subject_id, 'CEM43_end', parameters.results_filename_affix)); + sequential_parameters.io.adopted_heatmap = fullfile(parameters.io.output_dir, sprintf('sub-%03d_final_%s_orig_coord%s',... + parameters.subject_id, 'heating_end', parameters.io.output_affix)); + sequential_parameters.io.adopted_cem43 = fullfile(parameters.io.output_dir, sprintf('sub-%03d_final_%s_orig_coord%s',... + parameters.subject_id, 'CEM43_end', parameters.io.output_affix)); fprintf('Running subsequent heating simulation on %s\n', lowestField); - % Limitation: currently only support SLURM if ~isempty(fieldnames(sequential_configs)) - single_subject_pipeline_with_slurm(parameters.subject_id, sequential_parameters, false, '24:00:00', 64, 'sequential_configs', sequential_configs); + options.sequential_configs = sequential_configs; else - single_subject_pipeline_with_slurm(parameters.subject_id, sequential_parameters, false, '24:00:00', 64); + options = rmfield(options, 'sequential_configs'); end + prestus_pipeline_start(parameters, options) end end \ No newline at end of file diff --git a/functions/source/grid_axisymmetry.m b/functions/source/grid_axisymmetry.m index 7755f87d..44329ced 100644 --- a/functions/source/grid_axisymmetry.m +++ b/functions/source/grid_axisymmetry.m @@ -3,16 +3,15 @@ % adapt grid dimensions to axisymmetry (if requested) % grid should be specified as [axial, radial x 2] if numel(parameters.transducer(1).focus_pos) == 2 && ... - isfield(parameters, 'axisymmetric') && parameters.axisymmetric == 1 + isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 if numel(parameters.transducer) > 1 error('Axisymmetric simulations with multiple transducers are not supported (only a single transducer is allowed when axisymmetric == 1)'); end trans_pos = parameters.transducer(1).trans_pos; focus_pos = parameters.transducer(1).focus_pos; % ensure that radial(y) dim is shorter than axial (x) dim - if parameters.grid_dims(2) > parameters.grid_dims(1) - parameters.grid_dims = fliplr(parameters.grid_dims); - parameters.default_grid_dims = fliplr(parameters.default_grid_dims); + if parameters.grid.dims(2) > parameters.grid.dims(1) + parameters.grid.dims = fliplr(parameters.grid.dims); trans_pos = fliplr(trans_pos); focus_pos = fliplr(focus_pos); segmentation = segmentation'; @@ -21,9 +20,8 @@ end % halve the grid along the radial axis % see http://www.k-wave.org/documentation/kspaceFirstOrderAS.php - Ny_half = floor(parameters.grid_dims(2)/2); - parameters.grid_dims(2) = Ny_half; - parameters.default_grid_dims(2) = Ny_half; + Ny_half = floor(parameters.grid.dims(2)/2); + parameters.grid.dims(2) = Ny_half; segmentation = segmentation(:,Ny_half+1:end); bone = bone(:,Ny_half+1:end); medium_masks = medium_masks(:,Ny_half+1:end); diff --git a/functions/source/grid_tissue_setup.m b/functions/source/grid_tissue_setup.m index 953f7924..7e11b4a3 100644 --- a/functions/source/grid_tissue_setup.m +++ b/functions/source/grid_tissue_setup.m @@ -3,7 +3,7 @@ % for ultrasound neuromodulation simulations, either by preprocessing a % subject-specific head model (T1-weighted MRI) or loading phantom/alternative grids. -if contains(parameters.simulation_medium, {'layered'}) +if contains(parameters.simulation.medium, {'layered'}) % Set up a grid containing a layered medium % (1) Align planning image to transducer and rescale to requested grid resolution @@ -27,31 +27,27 @@ return; end - parameters.grid_dims = size(medium_masks); - parameters.n_sim_dims = numel(parameters.grid_dims); + parameters.grid.dims = size(medium_masks); else % In case simulations are not run in a skull of layered tissue, % alternative grid dimensions are set up - if strcmp(parameters.simulation_medium, 'phantom') + if strcmp(parameters.simulation.medium, 'phantom') % read in phantoms directly as medium masks - segmentation_folder = fullfile(parameters.seg_path, sprintf('m2m_sub-%03d', parameters.subject_id)); + segmentation_folder = fullfile(parameters.path.seg, sprintf('m2m_sub-%03d', parameters.subject_id)); filename_segmented = fullfile(segmentation_folder, 'final_tissues.nii.gz'); segmented_img = niftiread(filename_segmented); - if size(segmented_img) == parameters.default_grid_dims - parameters.grid_dims = parameters.default_grid_dims; - parameters.n_sim_dims = length(parameters.grid_dims); + if size(segmented_img) == parameters.grid.default_dims + parameters.grid.dims = parameters.grid.default_dims; disp('Check passed: phantom dimensions fit requested grid...'); - elseif length(size(segmented_img))==2 && all(size(segmented_img') == parameters.default_grid_dims) - parameters.grid_dims = parameters.default_grid_dims; - parameters.n_sim_dims = length(parameters.grid_dims); + elseif length(size(segmented_img))==2 && all(size(segmented_img') == parameters.grid.default_dims) + parameters.grid.dims = parameters.grid.default_dims; segmented_img = segmented_img'; disp('Check passed: phantom dimensions fit requested grid after rotating phantom...'); else - parameters.grid_dims = size(segmented_img); - parameters.n_sim_dims = length(parameters.grid_dims); + parameters.grid.dims = size(segmented_img); disp('Setting grid according to phantom dimensions...') - sprintf('%dD grid dimensions: [%d, %d, %d]', parameters.n_sim_dims, parameters.grid_dims); + sprintf('%dD grid dimensions: [%d, %d, %d]', numel(parameters.grid.dims), parameters.grid.dims); end % create medium mask according to indices in parameters.layers (see preproc_smooth_and_crop.m) [medium_masks] = preproc_medium_mask(segmented_img, parameters); @@ -62,15 +58,14 @@ [parameters] = check_layers(parameters, segmentation); else % e.g., water % set up default grid dimensions - assert(isfield(parameters, 'default_grid_dims'), ... - 'The parameters structure should have the field grid_dims for the grid dimensions') - parameters.grid_dims = squeeze(parameters.default_grid_dims); - parameters.n_sim_dims = length(parameters.default_grid_dims); - sprintf('Using default %dD grid dimensions: [%d, %d, %d]', parameters.n_sim_dims, parameters.grid_dims); + assert(isfield(parameters, 'grid') && isfield(parameters.grid, 'default_dims'), ... + 'parameters.grid.default_dims must be set for water simulations') + parameters.grid.dims = squeeze(parameters.grid.default_dims); + sprintf('Using default %dD grid dimensions: [%d, %d, %d]', numel(parameters.grid.dims), parameters.grid.dims); % set up empty medium masks and segmentations - medium_masks = ones(parameters.grid_dims); % single water layer - segmentation = zeros(parameters.grid_dims); - bone = zeros(parameters.grid_dims); + medium_masks = ones(parameters.grid.dims); % single water layer + segmentation = zeros(parameters.grid.dims); + bone = zeros(parameters.grid.dims); % update present layer (only water) [parameters] = check_layers(parameters, segmentation); end @@ -78,7 +73,7 @@ % specify that no transformation was applied planimg.t1_image_orig = []; planimg.t1_header = []; - planimg.transf = zeros(parameters.grid_dims); - planimg.inv_transf = zeros(parameters.grid_dims); + planimg.transf = zeros(parameters.grid.dims); + planimg.inv_transf = zeros(parameters.grid.dims); end \ No newline at end of file diff --git a/functions/source/grid_transducer_location.m b/functions/source/grid_transducer_location.m index 20a9b4d9..f8bfb8e2 100644 --- a/functions/source/grid_transducer_location.m +++ b/functions/source/grid_transducer_location.m @@ -1,7 +1,7 @@ function [parameters] = grid_transducer_location(parameters, planimg) % Position transducer(s) in the grid - if contains(parameters.simulation_medium, {'layered'}) + if contains(parameters.simulation.medium, {'layered'}) % map all transducers from T1 grid to sim grid using the same transform for ti = 1:numel(parameters.transducer) tr = parameters.transducer(ti); @@ -29,7 +29,7 @@ % for water medium remove potential position specifications % the grid has an arbitrary size that does not necessarily map onto the planning image - if strcmp(parameters.simulation_medium, 'water') + if strcmp(parameters.simulation.medium, 'water') parameters.transducer.trans_pos = []; parameters.transducer.focus_pos = []; end @@ -45,8 +45,8 @@ % y: first position beyond pml layer % x: halfway trans_pos = round(... - [parameters.grid_dims(1:(parameters.n_sim_dims-1))/2, ... - parameters.pml_size+1]); + [parameters.grid.dims(1:(numel(parameters.grid.dims)-1))/2, ... + parameters.grid.pml_size+1]); else trans_pos = parameters.transducer.trans_pos; % Adjust if the positions are transposed @@ -61,15 +61,18 @@ % position focus at expected distance from transducer % index dimension depends on 2D/3D % this already accounts for PML size + if ~isfield(parameters, 'expected_focal_distance_bowl') + parameters = focal_distance_calculation(parameters); + end focus_pos = trans_pos; - focus_pos(parameters.n_sim_dims) = ... - round(focus_pos(parameters.n_sim_dims) + ... - parameters.expected_focal_distance_mm/parameters.grid_step_mm); + focus_pos(numel(parameters.grid.dims)) = ... + round(focus_pos(numel(parameters.grid.dims)) + ... + parameters.transducer(1).expected_focal_distance_bowl/parameters.grid.resolution_mm); else focus_pos = parameters.transducer.focus_pos; % Adjust if the positions are transposed (2D only) % In 2D, we expect the second index as the axial dimension - if parameters.n_sim_dims == 2 && focus_pos(1)>focus_pos(2) + if numel(parameters.grid.dims) == 2 && focus_pos(1)>focus_pos(2) warning('Specified focus position appears transposed...adjusting'); focus_pos = focus_pos'; end @@ -85,10 +88,10 @@ for ti = 1:numel(parameters.transducer) tp = parameters.transducer(ti).trans_pos; fp = parameters.transducer(ti).focus_pos; - assert(min(abs([repmat(0, 1, numel(parameters.grid_dims));parameters.grid_dims]-... - tp ),[],'all') > parameters.pml_size, ... + assert(min(abs([repmat(0, 1, numel(parameters.grid.dims));parameters.grid.dims]-... + tp ),[],'all') > parameters.grid.pml_size, ... sprintf('The minimal distance between the transducer %i and the simulation grid boundary should be larger than the PML size. Adjust transducer position or the PML size', ti)) - assert(min(abs([repmat(0, 1, numel(parameters.grid_dims));parameters.grid_dims]-... - fp ),[],'all') > parameters.pml_size, ... + assert(min(abs([repmat(0, 1, numel(parameters.grid.dims));parameters.grid.dims]-... + fp ),[],'all') > parameters.grid.pml_size, ... sprintf('The minimal distance between the focus position of transducer %i and the simulation grid boundary should be larger than the PML size. Adjust transducer position or the PML size', ti)) end \ No newline at end of file diff --git a/functions/source/source_create.m b/functions/source/source_create.m index 842f4065..4ada9dcd 100644 --- a/functions/source/source_create.m +++ b/functions/source/source_create.m @@ -19,15 +19,15 @@ nT = numel(parameters.transducer); - if parameters.use_kWaveArray ~= 0 && nT > 1 + if parameters.grid.use_kWaveArray ~= 0 && nT > 1 error(['Multiple transducers with kWaveArray (use_kWaveArray ~= 0) ' ... 'are not implemented yet. Use use_kWaveArray == 0 or a single transducer.']); end %% Validate / broadcast positions - if isequal(size(trans_pos), [parameters.n_sim_dims 1]), trans_pos = trans_pos'; end - if isequal(size(focus_pos), [parameters.n_sim_dims 1]), focus_pos = focus_pos'; end + if isequal(size(trans_pos), [numel(parameters.grid.dims) 1]), trans_pos = trans_pos'; end + if isequal(size(focus_pos), [numel(parameters.grid.dims) 1]), focus_pos = focus_pos'; end if size(trans_pos,1) == 1, trans_pos = repmat(trans_pos, nT, 1); end if size(focus_pos,1) == 1, focus_pos = repmat(focus_pos, nT, 1); end @@ -35,23 +35,23 @@ if size(trans_pos,1) ~= nT || size(focus_pos,1) ~= nT error('trans_pos and focus_pos must have one row per transducer.'); end - if size(trans_pos,2) ~= parameters.n_sim_dims || size(focus_pos,2) ~= parameters.n_sim_dims - error('trans_pos and focus_pos columns must match parameters.n_sim_dims.'); + if size(trans_pos,2) ~= numel(parameters.grid.dims) || size(focus_pos,2) ~= numel(parameters.grid.dims) + error('trans_pos and focus_pos columns must match numel(parameters.grid.dims).'); end %% Convert element diameters from mm to grid points (for all transducers) transducer_pars = parameters.transducer; - grid_step_mm = parameters.grid_step_mm; + grid.resolution_mm = parameters.grid.resolution_mm; for it = 1:nT tp = transducer_pars(it); - tp.Elements_OD = 2 * floor(tp.Elements_OD_mm / grid_step_mm / 2) + 1; - tp.Elements_ID = 2 * floor(tp.Elements_ID_mm / grid_step_mm / 2) + 1; + tp.Elements_OD = 2 * floor(tp.Elements_OD_mm / grid.resolution_mm / 2) + 1; + tp.Elements_ID = 2 * floor(tp.Elements_ID_mm / grid.resolution_mm / 2) + 1; tp.Elements_ID(tp.Elements_ID_mm == 0) = 0; - tp.radius_grid = round(tp.curv_radius_mm / grid_step_mm); + tp.radius_grid = round(tp.curv_radius_mm / grid.resolution_mm); if it == 1 % initialise struct array with full field set of tp @@ -65,7 +65,7 @@ %% Branch 1: Custom element geometry (non-kWaveArray setup, multi-transducer) - if parameters.use_kWaveArray == 0 + if parameters.grid.use_kWaveArray == 0 % --- per-transducer CW signals (per-element) --- @@ -96,7 +96,7 @@ % --- geometry: per-element bowls, numeric source_labels = global element index --- - grid_dims = parameters.grid_dims; + grid_dims = parameters.grid.dims; transducer_mask = false(grid_dims); source_labels = zeros(grid_dims); @@ -109,17 +109,17 @@ global_el = offsets(it) + el_i; % 1..n_elements_total % outer element aperture - if parameters.n_sim_dims == 3 + if numel(parameters.grid.dims) == 3 bowl = makeBowl(grid_dims, trans_pos_i, tp.radius_grid, ... tp.Elements_OD(el_i), focus_pos_i); else bowl = makeArc(grid_dims, trans_pos_i, tp.radius_grid, ... tp.Elements_OD(el_i), focus_pos_i); end - + % subtract inner aperture if applicable if tp.Elements_ID(el_i) > 0 - if parameters.n_sim_dims == 3 + if numel(parameters.grid.dims) == 3 bowl = bowl - makeBowl(grid_dims, trans_pos_i, ... tp.radius_grid, tp.Elements_ID(el_i), focus_pos_i); else @@ -179,8 +179,8 @@ tp.source_phase_rad); % [n_elements x Nt] % Determine if axisymmetric mode should be enabled - if parameters.n_sim_dims == 2 && ... - isfield(parameters, 'axisymmetric') && parameters.axisymmetric == 1 + if numel(parameters.grid.dims) == 2 && ... + isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 axisymmetric = true; disp("Using axisymmetric setup for 2D input..."); else @@ -194,7 +194,7 @@ 'BLIType', 'sinc'); % Set focus position and transducer position vectors in physical coordinates - if parameters.n_sim_dims == 3 + if numel(parameters.grid.dims) == 3 % 3D annular array pos_vec = [kgrid.x_vec(trans_pos_1(1)), kgrid.y_vec(trans_pos_1(2)), kgrid.z_vec(trans_pos_1(3))]; focus_vec = [kgrid.x_vec(focus_pos_1(1)), kgrid.y_vec(focus_pos_1(2)), kgrid.z_vec(focus_pos_1(3))]; @@ -204,7 +204,7 @@ [tp.Elements_ID_mm; tp.Elements_OD_mm] * 1e-3, ... focus_vec); - elseif parameters.n_sim_dims == 2 && axisymmetric == false + elseif numel(parameters.grid.dims) == 2 && axisymmetric == false % 2D arc-shaped element pos_vec = [kgrid.x_vec(trans_pos_1(1)), kgrid.y_vec(trans_pos_1(2))]; @@ -221,7 +221,7 @@ kgrid_mirrored = kWaveGrid(kgrid.Nx, kgrid.dx, 2*kgrid.Ny - 1, kgrid.dy); karray_full = kWaveArray('Axisymmetric', false, 'BLITolerance', 0.01, 'UpsamplingRate', 100); - x_offset = (trans_pos_1(1)-1) * (1/parameters.grid_step_mm); + x_offset = (trans_pos_1(1)-1) * (1/parameters.grid.resolution_mm); position_base = [kgrid.x_vec(1) + x_offset*kgrid.dx, 0+eps]; focus_pos_full = [0, 0+eps]; diff --git a/functions/source/source_sensor_setup.m b/functions/source/source_sensor_setup.m index 54e52e69..92d27cda 100644 --- a/functions/source/source_sensor_setup.m +++ b/functions/source/source_sensor_setup.m @@ -10,13 +10,13 @@ % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % Creates a simulation grid in 3 or 2 dimensions - if parameters.n_sim_dims == 3 - kgrid = kWaveGrid(parameters.grid_dims(1), parameters.grid_step_mm/1e3, ... - parameters.grid_dims(2), parameters.grid_step_mm/1e3, ... - parameters.grid_dims(3), parameters.grid_step_mm/1e3); - elseif parameters.n_sim_dims == 2 - kgrid = kWaveGrid(parameters.grid_dims(1), parameters.grid_step_mm/1e3, ... - parameters.grid_dims(2), parameters.grid_step_mm/1e3); + if numel(parameters.grid.dims) == 3 + kgrid = kWaveGrid(parameters.grid.dims(1), parameters.grid.resolution_mm/1e3, ... + parameters.grid.dims(2), parameters.grid.resolution_mm/1e3, ... + parameters.grid.dims(3), parameters.grid.resolution_mm/1e3); + elseif numel(parameters.grid.dims) == 2 + kgrid = kWaveGrid(parameters.grid.dims(1), parameters.grid.resolution_mm/1e3, ... + parameters.grid.dims(2), parameters.grid.resolution_mm/1e3); end % Backward-compatible access to (first) transducer @@ -35,16 +35,16 @@ if nargin < 5 % Calculate the time step using an integer number of points per period % PPW: Spatial samples per wavelength at source freq; ensures dx resolves waves (target β‰₯3). - if ~isfield(parameters, 'source_ppw') || isempty(parameters.source_ppw) - points_per_wavelength = max_sound_speed /(tx.source_freq_hz * parameters.grid_step_mm/1e3); + if ~isfield(parameters.grid, 'source_ppw') || isempty(parameters.grid.source_ppw) + points_per_wavelength = max_sound_speed /(tx.source_freq_hz * parameters.grid.resolution_mm/1e3); else - points_per_wavelength = parameters.source_ppw; + points_per_wavelength = parameters.grid.source_ppw; end % Courant-Friedrichs-Lewy: Fraction of dx/c for dt; k-Wave default. - if ~isfield(parameters, 'source_cfl') || isempty(parameters.source_cfl) + if ~isfield(parameters.grid, 'source_cfl') || isempty(parameters.grid.source_cfl) cfl = 0.3; else - cfl = parameters.source_cfl; + cfl = parameters.grid.source_cfl; end % Temporal samples per wave period. points_per_period = ceil(points_per_wavelength / cfl); @@ -66,16 +66,16 @@ kgrid.setTime(simulation_time_points, grid_time_step); % Create source (transducer with focuspoint) - parameters.kwave_source_filename = fullfile(parameters.output_dir, ... + parameters.io.kwave_source_filename = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_%s_kwave_source%s.mat', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); - if confirm_overwriting(parameters.kwave_source_filename, parameters) + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + if confirm_overwriting(parameters.io.kwave_source_filename, parameters) % build per-transducer positions for geometry when not using kWaveArray - if numel(parameters.transducer)>1 && parameters.use_kWaveArray == 0 + if numel(parameters.transducer)>1 && parameters.grid.use_kWaveArray == 0 nT = numel(parameters.transducer); - trans_pos = zeros(nT, parameters.n_sim_dims); - focus_pos = zeros(nT, parameters.n_sim_dims); + trans_pos = zeros(nT, numel(parameters.grid.dims)); + focus_pos = zeros(nT, numel(parameters.grid.dims)); for ti = 1:nT tr = parameters.transducer(ti); @@ -110,19 +110,19 @@ % Create the source matrix [source, source_labels, ~] = source_create(parameters, kgrid, trans_pos, focus_pos); % Save the source matrix (unless otherwise requested) - if isfield(parameters, 'savemat') && parameters.savemat==0 + if isfield(parameters.io, 'save_matrices') && parameters.io.save_matrices==0 disp("Not saving kwave source matrix ...") else - save(parameters.kwave_source_filename, 'source', 'source_labels','-v7.3'); + save(parameters.io.kwave_source_filename, 'source', 'source_labels','-v7.3'); end else - load(parameters.kwave_source_filename); + load(parameters.io.kwave_source_filename); end % Creates a sensor that records the the maximum and final pressure % values in every point of the grid sensor = struct(); - sensor.mask = ones(parameters.grid_dims); + sensor.mask = ones(parameters.grid.dims); sensor.record = {'p_max_all','p_final'}; % Record the last 3 cycles in steady state (when sonic waves have traversed the entire medium) diff --git a/functions/thermal/thermal_analysis.m b/functions/thermal/thermal_analysis.m index d0f71f77..fc02e0ee 100644 --- a/functions/thermal/thermal_analysis.m +++ b/functions/thermal/thermal_analysis.m @@ -23,14 +23,14 @@ % Sets up an empty medium mask if none is specified if isempty(medium_masks) - medium_masks = zeros(parameters.grid_dims); + medium_masks = zeros(parameters.grid.dims); end % Get tissue-specific masks mask = tissuemask_binary(parameters, medium_masks); % Creates an output table for temperature readings - results_thermal = readtable(parameters.filename_output_table); + results_thermal = readtable(parameters.io.filename_output_table); results_thermal.maxT = max(results_heating.maxT, [], 'all'); results_thermal.endT = max(results_heating.heating_endT, [], 'all'); @@ -38,7 +38,7 @@ results_thermal.maxCEM43end = max(results_heating.CEM43_end, [], 'all'); % Encode layer-specific estimates - if contains(parameters.simulation_medium, {'layered'; 'phantom'}) + if contains(parameters.simulation.medium, {'layered'; 'phantom'}) % temperature maximum results_thermal.maxT_brain = masked_max_3d(results_heating.maxT, mask.brain); results_thermal.maxT_skull = masked_max_3d(results_heating.maxT, mask.skull); @@ -66,17 +66,17 @@ end % Save overview table - writetable(results_thermal, parameters.filename_output_table); + writetable(results_thermal, parameters.io.filename_output_table); % Creates a visual overlay of the transducer (if 3D T1 image is available) if exist('planimg') && isfield(planimg, 't1_header') - grid_step_mm = planimg.t1_header.PixelDimensions(1); + grid.resolution_mm = planimg.t1_header.PixelDimensions(1); [~, source_labels] = transducer_setup(... parameters.transducer(1), ... parameters.transducer(1).trans_pos, ... parameters.transducer(1).focus_pos, ... size(segmentation), ... - grid_step_mm); + grid.resolution_mm); else source_labels = zeros(size(segmentation)); end @@ -122,8 +122,8 @@ 'overlay_color_range', temp_color_range, ... 'bg_bw_range', [0, numel(fieldnames(parameters.layers))]); end - output_plot_filename = fullfile(parameters.output_dir,... + output_plot_filename = fullfile(parameters.io.output_dir,... sprintf('sub-%03d_%s_maxT%s.png',... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png') close(h); \ No newline at end of file diff --git a/functions/thermal/thermal_parameters.m b/functions/thermal/thermal_parameters.m index dfb90fb5..13bbb160 100644 --- a/functions/thermal/thermal_parameters.m +++ b/functions/thermal/thermal_parameters.m @@ -10,7 +10,7 @@ % Use as: % params_thermal = thermal_parameters(cfg) % -% Required input (in parameters.thermal): +% Required input (in parameters.timing): % .pd - pulse duration [s] % .pri - pulse repetition interval [s] % .ptd - pulse train duration [s] @@ -42,13 +42,14 @@ end thermal = parameters.thermal; +timing = parameters.timing; % === Pulse / Pulse Train (fine timestep: pt_timestep) === -pri = thermal.pri; -pd = thermal.pd; -ptd = thermal.ptd; -pt_dt = thermal.pt_timestep; +pri = timing.pri; +pd = timing.pd; +ptd = timing.ptd; +pt_dt = timing.pt_timestep; dc = pd / pri; % Duty cycle (computed) % encode the above in params_thermal @@ -58,7 +59,7 @@ params_thermal.pt_dt = pt_dt; params_thermal.dc = dc; -if thermal.equal_step_duration == 0 +if timing.equal_step_duration == 0 % Different durations, fixed steps=1 params_thermal.pt_on_steps_n = 1; params_thermal.pt_on_steps_dur = pd; @@ -78,17 +79,17 @@ % === PTRI repetition === -params_thermal.ptrd = thermal.ptrd; -params_thermal.ptri = thermal.ptri; -params_thermal.ptri_off = thermal.ptri - ptd; +params_thermal.ptrd = timing.ptrd; +params_thermal.ptri = timing.ptri; +params_thermal.ptri_off = timing.ptri - ptd; params_thermal.n_ptri_reps = round_if_integer(params_thermal.ptrd / params_thermal.ptri, ... 'PTRI reps in PTRD must be integer'); % === PTRI-off & post-PTRI (coarse timestep: post_pt_timestep) === -params_thermal.post_pt_timestep = thermal.post_pt_timestep; -post_dt = thermal.post_pt_timestep; +params_thermal.post_pt_timestep = timing.post_pt_timestep; +post_dt = timing.post_pt_timestep; % PTRI-OFF (between PTs) if params_thermal.ptri_off <= 0 @@ -100,8 +101,8 @@ 'PTRI-OFF steps must be integer'); end -% Post PTRD stead-state -post_dur = thermal.post_ptri_dur; +% Post PTRD steady-state +post_dur = timing.post_ptri_dur; params_thermal.post_ptri_dur = post_dur; if post_dur <= 0 params_thermal.post_ptri_steps_n = 0; diff --git a/functions/thermal/thermal_plot_protocol.m b/functions/thermal/thermal_plot_protocol.m index 6917ef05..8f7b007a 100644 --- a/functions/thermal/thermal_plot_protocol.m +++ b/functions/thermal/thermal_plot_protocol.m @@ -149,9 +149,9 @@ function thermal_plot_protocol(params_thermal, parameters, varargin) 'FontSize', 15, 'FontWeight', 'bold'); % save plot -output_plot_filename = fullfile(parameters.output_dir,... +output_plot_filename = fullfile(parameters.io.output_dir,... sprintf('sub-%03d_%s_thermal_protocol%s.png',... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); saveas(h, output_plot_filename, 'png') close(h); diff --git a/functions/thermal/thermal_plot_sim.m b/functions/thermal/thermal_plot_sim.m index 08556982..b65fc7af 100644 --- a/functions/thermal/thermal_plot_sim.m +++ b/functions/thermal/thermal_plot_sim.m @@ -22,12 +22,12 @@ function thermal_plot_sim(focal_planeT, time_status_seq, parameters, trans_pos, % transducer, not the maximum of observed values! %% Define output file paths for plots - output_plot = fullfile(parameters.output_dir, sprintf('sub-%03d_%s_thermal%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); - output_plot_rise = fullfile(parameters.output_dir, sprintf('sub-%03d_%s_thermalrise%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); - output_plot_CEM = fullfile(parameters.output_dir, sprintf('sub-%03d_%s_CEM%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + output_plot = fullfile(parameters.io.output_dir, sprintf('sub-%03d_%s_thermal%s.png', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + output_plot_rise = fullfile(parameters.io.output_dir, sprintf('sub-%03d_%s_thermalrise%s.png', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + output_plot_CEM = fullfile(parameters.io.output_dir, sprintf('sub-%03d_%s_CEM%s.png', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); %% Convert GPU arrays to CPU if necessary if gpuDeviceCount == 0 @@ -204,12 +204,12 @@ function thermal_plot_sim(focal_planeT, time_status_seq, parameters, trans_pos, %% Plot timeseries for maximum in medium - output_plot = fullfile(parameters.output_dir, sprintf('sub-%03d_%s_thermal_max%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); - output_plot_rise = fullfile(parameters.output_dir, sprintf('sub-%03d_%s_thermalrise_max%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); - output_plot_CEM = fullfile(parameters.output_dir, sprintf('sub-%03d_%s_CEM_max%s.png', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + output_plot = fullfile(parameters.io.output_dir, sprintf('sub-%03d_%s_thermal_max%s.png', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + output_plot_rise = fullfile(parameters.io.output_dir, sprintf('sub-%03d_%s_thermalrise_max%s.png', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + output_plot_CEM = fullfile(parameters.io.output_dir, sprintf('sub-%03d_%s_CEM_max%s.png', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); % [layer-max] temperature over time @@ -298,27 +298,27 @@ function thermal_plot_sim(focal_planeT, time_status_seq, parameters, trans_pos, %% Save values for post-hoc group analysis - output_HEAT = fullfile(parameters.output_dir, sprintf('sub-%03d_%s_HEAT%s.mat', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix)); + output_HEAT = fullfile(parameters.io.output_dir, sprintf('sub-%03d_%s_HEAT%s.mat', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); save(output_HEAT, 'HEAT', 'timeseries'); %% Create a video of sagittal in-plane heating (if requested) - if ~isfield(parameters, 'heatingvideo') - parameters.heatingvideo = 1; % default: create & save video + if ~isfield(parameters.io, 'save_heatingvideo') + parameters.io.save_heatingvideo = 1; % default: create & save video end - if parameters.heatingvideo == 1 + if parameters.io.save_heatingvideo == 1 color_limits = [min(focal_planeT(:)), max(focal_planeT(:))]; if ndims(medium_masks) == 3 brain_slice = mat2gray(squeeze(medium_masks(:,parameters.transducer.trans_pos(2),:))); elseif ndims(medium_masks) == 2 brain_slice = mat2gray(squeeze(medium_masks)); end - output_video_name = fullfile(parameters.output_dir,... + output_video_name = fullfile(parameters.io.output_dir,... sprintf('sub-%03d_%s_heating_animation%s.avi', ... parameters.subject_id, ... - parameters.simulation_medium, ... - parameters.results_filename_affix)); + parameters.simulation.medium, ... + parameters.io.output_affix)); v = VideoWriter(output_video_name,'Uncompressed AVI'); v.FrameRate = 2; % frames per second % Create a video writer object for the output video file and open the object for writing. diff --git a/functions/thermal/thermal_simulation.m b/functions/thermal/thermal_simulation.m index f8adcb2e..7100f37a 100644 --- a/functions/thermal/thermal_simulation.m +++ b/functions/thermal/thermal_simulation.m @@ -52,23 +52,24 @@ for i = 1:2 heating_window_dims(:,i) = [... max(1, -parameters.thermal.sensor_xy_halfsize + parameters.transducer(1).trans_pos(i)), ... - min(parameters.grid_dims(i), parameters.thermal.sensor_xy_halfsize + parameters.transducer(1).trans_pos(i))]; + min(parameters.grid.dims(i), parameters.thermal.sensor_xy_halfsize + parameters.transducer(1).trans_pos(i))]; end % Define a simulation window -if length(size(parameters.grid_dims))>2 - heating_window_dims(2,3) = parameters.grid_dims(3); +if length(size(parameters.grid.dims))>2 + heating_window_dims(2,3) = parameters.grid.dims(3); end sensor.mask(heating_window_dims(1,1):heating_window_dims(2,1), heating_window_dims(1,2):heating_window_dims(2,2), :) = 1; -% [(pseudo-)CT] if 'k-plan' setup is used: -% density and sound speed in bone are fixed for heating estimation +% [(pseudo-)CT] if 'k-plan' mapping is used for density: +% skull bone density is fixed for thermal simulation +% (note that downstream metrics such as sound speed contribute to pressure, but are not input to kWaveDiffusion) % https://dispatch.k-plan.io/static/docs/simulation-pipeline.html -if parameters.usepseudoCT ==1 && strcmp(parameters.pseudoCT_variant, 'k-plan') +if parameters.pct.enabled ==1 && ... + (~isfield(parameters, 'pct') || ~isfield(parameters.pct, 'mapping_density') || strcmp(parameters.pct.mapping_density, 'k-plan')) skull_i = find(ismember(fieldnames(parameters.layers), {'skull', 'skull_cortical', 'skull_trabecular'})); + % [k-Plan] use fixed density for thermal simulation kwave_medium.density(ismember(medium_masks,skull_i)) = 1850; - kwave_medium.sound_speed(ismember(medium_masks,skull_i)) = ... - 1.33*kwave_medium.density(ismember(medium_masks,skull_i))+167; clear skull_i; end @@ -87,9 +88,9 @@ % alpha_np = (100 * kwave_medium.alpha_coeff .* (parameters.transducer.source_freq_hz/10^6)^kwave_medium.alpha_power)/8.686; % save absorption coefficient [Np] for debugging -if contains(parameters.simulation_medium, {'layered', 'phantom'}) && parameters.debug == 1 +if contains(parameters.simulation.medium, {'layered', 'phantom'}) && parameters.simulation.debug == 1 try - filename_absorption = fullfile(parameters.output_dir, 'debug', sprintf('matrix_absorption')); + filename_absorption = fullfile(parameters.io.output_dir, 'debug', sprintf('matrix_absorption')); niftiwrite(alpha_np, filename_absorption, 'Compressed',true); catch warning("Error with saving absorption debug image...") @@ -131,15 +132,15 @@ % Set precision and enable GPU mode (if requested) if use_datacast == true - if strcmp(parameters.code_type, 'matlab_gpu') || strcmp(parameters.code_type, 'cpp_gpu') - datacast = ['gpuArray-', char(parameters.precision)]; + if strcmp(parameters.simulation.code_type, 'matlab_gpu') || strcmp(parameters.simulation.code_type, 'cpp_gpu') + datacast = ['gpuArray-', char(parameters.simulation.precision)]; else - datacast = char(parameters.precision); + datacast = char(parameters.simulation.precision); end end % Build final input args -thermal_args = {'PlotSim', boolean(parameters.interactive)}; +thermal_args = {'PlotSim', boolean(parameters.simulation.interactive)}; if use_datacast thermal_args = [thermal_args, {'DataCast', datacast}]; end @@ -153,22 +154,22 @@ thermal_args{:}); % initialize field temperature -if strcmp(parameters.code_type, 'matlab_gpu') || strcmp(parameters.code_type, 'cpp_gpu') +if strcmp(parameters.simulation.code_type, 'matlab_gpu') || strcmp(parameters.simulation.code_type, 'cpp_gpu') thermal_diff_obj.T = gpuArray(thermal_diff_obj.T); -end +end T_max = thermal_diff_obj.T; % initialize field for cem43 if isfield(parameters, 'adopted_cem43') - cumulative_heat_image = niftiread(parameters.adopted_cem43); - fprintf('\nAdopting CEM43 heatmap %s from previous simulation\n', parameters.adopted_cem43) + cumulative_heat_image = niftiread(parameters.io.adopted_cem43); + fprintf('\nAdopting CEM43 heatmap %s from previous simulation\n', parameters.io.adopted_cem43) thermal_diff_obj.cem43 = double(tformarray(cumulative_heat_image, ... maketform("affine", transf), ... makeresampler('nearest', 'fill'), [1 2 3], [1 2 3], size(medium_masks), [], 0)); else thermal_diff_obj.cem43 = zeros(size(thermal_diff_obj.T)); end -if strcmp(parameters.code_type, 'matlab_gpu') || strcmp(parameters.code_type, 'cpp_gpu') +if strcmp(parameters.simulation.code_type, 'matlab_gpu') || strcmp(parameters.simulation.code_type, 'cpp_gpu') thermal_diff_obj.cem43 = gpuArray(thermal_diff_obj.cem43); end CEM43_max = thermal_diff_obj.cem43; diff --git a/functions/transducer/focal_distance_calculation.m b/functions/transducer/focal_distance_calculation.m index defa3343..baa08f53 100644 --- a/functions/transducer/focal_distance_calculation.m +++ b/functions/transducer/focal_distance_calculation.m @@ -2,7 +2,7 @@ % Compute expected focal distances for (multi-)transducer setup % % DESCRIPTION: -% Ensures all transducers in parameters.transducer have a valid expected_focal_distance_mm field. +% Ensures all transducers in parameters.transducer have a valid expected_focal_distance_bowl field. % Supports legacy single-value propagation, transducer-specific values, and automatic derivation % from T1-weighted MRI grid positions when needed. Essential preprocessing for acoustic simulations % requiring precise geometric focus-targeting. @@ -10,82 +10,95 @@ % INPUTS: % parameters - (struct) Simulation configuration with: % .transducer - (1Γ—N struct array) Transducer configurations where each element may contain: -% .expected_focal_distance_mm - (scalar) [mm] Expected geometric focal distance (optional) +% .expected_focal_distance_bowl - (scalar) [mm] Expected geometric focal distance (optional) % .trans_pos - (1Γ—3 vector) Transducer position [voxels] in T1 grid (optional) % .focus_pos - (1Γ—3 vector) Target focus position [voxels] in T1 grid (optional) -% .expected_focal_distance_mm - (scalar) [mm] Legacy global focal distance (optional) +% .expected_focal_distance_bowl - (scalar) [mm] Legacy global focal distance (optional) % .data_path - (char) Path to subject data % .t1_path_template - (char) sprintf template for T1w filename e.g. 'sub-%03d_T1w.nii.gz' % % OUTPUTS: -% parameters - (struct) Updated with expected_focal_distance_mm populated for all transducers +% parameters - (struct) Updated with expected_focal_distance_bowl populated for all transducers % % ALGORITHM: -% 1. PROPAGATE LEGACY: If global .expected_focal_distance_mm exists, copy to all missing transducer fields +% 1. PROPAGATE LEGACY: If global .expected_focal_distance_bowl exists, copy to all missing transducer fields % 2. GEOMETRIC FALLBACK: For remaining unset transducers, compute Euclidean distance between .trans_pos % and .focus_pos in T1 space, scaled by voxel dimensions from NIfTI header % 3. ERROR CHECKS: Validates T1 existence and required grid positions; fails gracefully with diagnostics % % NOTES: % - Requires subject_id in scope for T1 loading (closure variable) -% - T1 grid computation: ||focus_pos - trans_pos|| Γ— t1_grid_step_mm +% - T1 grid computation: ||focus_pos - trans_pos|| Γ— t1_grid.resolution_mm % - Multi-transducer aware: handles heterogeneous focal distances across array % - No modification to existing valid values (preserves transducer-specific overrides) % % EXAMPLE: -% parameters.transducer(1).expected_focal_distance_mm = 70; % Set explicitly for primary +% parameters.transducer(1).expected_focal_distance_bowl = 70; % Set explicitly for primary % parameters.transducer(2).trans_pos = [100 120 80]; % Will auto-compute for secondary % parameters.transducer(2).focus_pos = [100 120 30]; % parameters = focal_distance_calculation(parameters); % % 1) Implement provided expected focal distance -if isfield(parameters, 'expected_focal_distance_mm') && ~isempty(parameters.expected_focal_distance_mm) + +% Update everything if expected_focal_distance_ep is specified +if isfield(parameters, 'expected_focal_distance_ep') && ~isempty(parameters.expected_focal_distance_ep) for ti = 1:numel(parameters.transducer) - if ~isfield(parameters.transducer(ti), 'expected_focal_distance_mm') || ... - isempty(parameters.transducer(ti).expected_focal_distance_mm) - parameters.transducer(ti).expected_focal_distance_mm = parameters.expected_focal_distance_mm; + if ~isfield(parameters.transducer(ti), 'expected_focal_distance_ep') || ... + isempty(parameters.transducer(ti).expected_focal_distance_ep) + parameters.transducer(ti).expected_focal_distance_ep = parameters.expected_focal_distance_ep; + % calculate focal distance offset (between transducer bowl and exit plane for annular arrays) + parameters.transducer(ti).focal_distance_offset = parameters.transducer(ti).curv_radius_mm - parameters.transducer(ti).dist_to_plane_mm; + % calculate focal distance (from bowl) + parameters.transducer(ti).expected_focal_distance_bowl = parameters.transducer(ti).expected_focal_distance_ep+parameters.transducer(ti).focal_distance_offset; end end -end - -% 2) T1-based geometric derivation for unspecified distances -needs_t1 = false; -for ti = 1:numel(parameters.transducer) - tr = parameters.transducer(ti); - if ~isfield(tr, 'expected_focal_distance_mm') || isempty(tr.expected_focal_distance_mm) - needs_t1 = true; - break + % copy to parameters main structure (for first transducer) + if ~isfield(parameters, 'expected_focal_distance_bowl') || isempty(parameters.expected_focal_distance_bowl) + parameters.expected_focal_distance_bowl = parameters.transducer(1).expected_focal_distance_bowl; end end -if needs_t1 - disp('Expected focal distance not specified for all transducers, trying to get it from positions on T1 grid') - - % Load T1 header once - filename_t1 = dir(fullfile(parameters.data_path, sprintf(parameters.t1_path_template, subject_id))); - if isempty(filename_t1) - error('File does not exist for T1 (t1_path_template): %s', ... - fullfile(parameters.data_path, sprintf(parameters.t1_path_template, subject_id))); +if isfield(parameters, 'expected_focal_distance_bowl') && ~isempty(parameters.expected_focal_distance_bowl) + for ti = 1:numel(parameters.transducer) + if ~isfield(parameters.transducer(ti), 'expected_focal_distance_bowl') || ... + isempty(parameters.transducer(ti).expected_focal_distance_bowl) + parameters.transducer(ti).expected_focal_distance_bowl = parameters.expected_focal_distance_bowl; + % calculate focal distance offset (between transducer bowl and exit plane for annular arrays) + parameters.transducer(ti).focal_distance_offset = parameters.transducer(ti).curv_radius_mm - parameters.transducer(ti).dist_to_plane_mm; + % calculate focal distance (from exit plane) + parameters.transducer(ti).expected_focal_distance_ep = parameters.transducer(ti).expected_focal_distance_bowl-parameters.transducer(ti).focal_distance_offset; + end + end + % copy to parameters main structure (for first transducer) + if ~isfield(parameters, 'expected_focal_distance_ep') || isempty(parameters.expected_focal_distance_ep) + parameters.expected_focal_distance_ep = parameters.transducer(1).expected_focal_distance_ep; end - filename_t1 = fullfile(filename_t1(1).folder, filename_t1(1).name); % first match - t1_info = niftiinfo(filename_t1); - t1_grid_step_mm = t1_info.PixelDimensions(1); +end - % Fill missing expected_focal_distance_mm per transducer - for ti = 1:numel(parameters.transducer) - tr = parameters.transducer(ti); - if ~isfield(tr, 'expected_focal_distance_mm') || isempty(tr.expected_focal_distance_mm) - if ~isfield(tr, 'trans_pos') || isempty(tr.trans_pos) || ... - ~isfield(tr, 'focus_pos') || isempty(tr.focus_pos) - error('Transducer %d: trans_pos or focus_pos missing; cannot compute expected focal distance.', ti); - end +% 2) Rely on specification of transducer and target position +if ~isfield(parameters, 'expected_focal_distance_ep') || ~isfield(parameters, 'expected_focal_distance_bowl') + warning('Expected focal distance not specified for all transducers, trying to get it from transducer and target positions ...') +end - focal_distance_t1 = norm(tr.focus_pos - tr.trans_pos); - parameters.transducer(ti).expected_focal_distance_mm = focal_distance_t1 * t1_grid_step_mm; +% Fill missing expected_focal_distance_bowl per transducer +for ti = 1:numel(parameters.transducer) + tr = parameters.transducer(ti); + if ~isfield(tr, 'expected_focal_distance_bowl') || isempty(tr.expected_focal_distance_bowl) + if ~isfield(tr, 'trans_pos') || isempty(tr.trans_pos) || ... + ~isfield(tr, 'focus_pos') || isempty(tr.focus_pos) + warning('Transducer %d: trans_pos or focus_pos missing; cannot compute expected focal distance.', ti); end - end + % calculate grid distance between transducer bowl and focus + focal_distance = norm(tr.focus_pos - tr.trans_pos); + % scale grid distance by grid step to calculate mm + parameters.transducer(ti).expected_focal_distance_bowl = focal_distance * parameters.grid.resolution_mm; + % calculate focal distance offset (between transducer bowl and exit plane for annular arrays) + parameters.transducer(ti).focal_distance_offset = parameters.transducer(ti).curv_radius_mm - parameters.transducer(ti).dist_to_plane_mm; + % calculate focal distance (from exit plane) + parameters.transducer(ti).expected_focal_distance_ep = parameters.transducer(ti).expected_focal_distance_bowl-parameters.transducer(ti).focal_distance_offset; - clear filename_t1 t1_info t1_grid_step_mm focal_distance_t1 + end end + end diff --git a/functions/transducer/tp_candidate_mesh.m b/functions/transducer/tp_candidate_mesh.m new file mode 100644 index 00000000..0e4932a0 --- /dev/null +++ b/functions/transducer/tp_candidate_mesh.m @@ -0,0 +1,95 @@ +function mesh = tp_candidate_mesh(img, target, parameters, pixel_size) +% TP_CANDIDATE_MESH Build coordinate mesh and candidate transducer geometry +% +% Constructs: +% - Full 3D voxel coordinate grid (CPU + GPU) +% - Skin and skull boundary point clouds (GPU) +% - Candidate outer-surface positions within tp_dist_close of target +% - Geometry for transducer axis, geometric focus, and exit plane (all candidates) +% +% INPUT +% img - Segmented head volume [Nx Ny Nz] +% target - 1x3 target position [x y z] in voxel space +% parameters - Struct with fields: +% .tp_dist_close (mm), .transducer.curv_radius_mm, +% .transducer.Elements_OD_mm (vector of element ODs in mm) +% pixel_size - Scalar voxel size in mm +% +% OUTPUT (struct mesh) +% mesh.coord_mesh - struct with fields x,y,z,xyz (all gpuArray) +% mesh.skin_coords - [N_skin x 3] gpuArray skin boundary points +% mesh.skull_coords - [N_skull x 3] gpuArray skull boundary points +% mesh.outer_boundary - logical volume (CPU) outer surface mask +% mesh.close_enough_idx- linear indices of candidate positions (CPU) +% mesh.trans_pos - [N_cand x 3] gpuArray shifted transducer positions +% mesh.norm_v - [N_cand x 3] gpuArray unit direction vectors +% mesh.geom_focus - [N_cand x 3] gpuArray geometric focus positions +% mesh.ex_plane - [N_cand x 3] gpuArray exit plane centers +% mesh.max_od_grid - scalar, aperture diameter in voxels +% mesh.all_masks_idx - gpuArray linear indices of tissue voxels + + %--- 3D voxel grid (CPU + GPU) --------------------------------------- + sz = size(img); + [t1_x, t1_y, t1_z] = ndgrid(1:sz(1), 1:sz(2), 1:sz(3)); + + coord_mesh.x = gpuArray(t1_x); + coord_mesh.y = gpuArray(t1_y); + coord_mesh.z = gpuArray(t1_z); + coord_mesh.xyz = gpuArray([reshape(t1_x,[],1), ... + reshape(t1_y,[],1), ... + reshape(t1_z,[],1)]); % [Nx*Ny*Nz x 3] + + %--- Tissue masks and boundaries ------------------------------------- + all_masks = img > 0; % Brain+skull+skin + outer_boundary = imdilate(all_masks, strel('sphere',1)) - all_masks; % Outer surface + + skin_boundary = all_masks - imerode(all_masks, strel('sphere',1)); + skin_coords = gpuArray(coord_mesh.xyz(find(skin_boundary), :)); + + img_cp = img; + img_cp(img_cp == 7 | img_cp == 8) = 4; % Merge trabecular/CSF β†’ compact bone + skull = img_cp == 4; + skull_fill = imfill(img_cp > 0 & img_cp <= 4, 'holes'); + skull_fill = imerode(skull_fill, strel('sphere',1)); + skull_boundary = skull & ~skull_fill; + skull_coords = gpuArray(coord_mesh.xyz(find(skull_boundary), :)); + + %--- Candidate positions near target --------------------------------- + outer_idx = find(outer_boundary); + outer_coords = coord_mesh.xyz(outer_idx, :); + coord_rel_to_targ = outer_coords - target; + distances_to_target = sqrt(sum(coord_rel_to_targ.^2, 2)); + + close_enough_idx = outer_idx(distances_to_target < (parameters.placement.heuristic.dist_close / pixel_size)); + trans_pos_coords = coord_mesh.xyz(close_enough_idx, :); % [N_cand x 3] + + %--- Transducer axis + geometry -------------------------------------- + norm_v = gpuArray((trans_pos_coords - target) ./ ... + repmat(sqrt(sum((trans_pos_coords - target).^2, 2)), [1, 3])); + + max_od_mm = max(parameters.transducer.Elements_OD_mm); + dist_gf_to_ep_mm = 0.5 * sqrt(4*parameters.transducer.curv_radius_mm^2 - max_od_mm^2); + dist_tp_to_ep_mm = parameters.transducer.curv_radius_mm - dist_gf_to_ep_mm; + + pos_shift_mm = 5 + dist_tp_to_ep_mm; + shifted_trans_pos_coords = trans_pos_coords + norm_v * (pos_shift_mm / pixel_size); + + geom_focus_pos_all = shifted_trans_pos_coords - norm_v * (parameters.transducer.curv_radius_mm / pixel_size); + ex_plane_pos_all = geom_focus_pos_all + norm_v * (dist_gf_to_ep_mm / pixel_size); + + all_masks_indx = find(img > 0); + max_od_grid = max_od_mm / pixel_size; + + %--- Pack outputs ---------------------------------------------------- + mesh.coord_mesh = coord_mesh; + mesh.skin_coords = skin_coords; + mesh.skull_coords = skull_coords; + mesh.outer_boundary = outer_boundary; + mesh.close_enough_idx = close_enough_idx; + mesh.trans_pos = shifted_trans_pos_coords; + mesh.norm_v = norm_v; + mesh.geom_focus = geom_focus_pos_all; + mesh.ex_plane = ex_plane_pos_all; + mesh.max_od_grid = max_od_grid; + mesh.all_masks_idx = all_masks_indx; +end \ No newline at end of file diff --git a/functions/transducer/tp_evaluate_candidate_positions.m b/functions/transducer/tp_evaluate_candidate_positions.m new file mode 100644 index 00000000..1d18a1fc --- /dev/null +++ b/functions/transducer/tp_evaluate_candidate_positions.m @@ -0,0 +1,61 @@ +function [tpos_pars, mesh] = tp_evaluate_candidate_positions(img, target, parameters, pixel_size) +%% TP_EVALUATE_CANDIDATE_POSITIONS Evaluate candidate transducer positions +% SEARCH CRITERIA: +% 1. Skull exterior surface points (img==0 β†’ imdilate gradient) +% 2. Within parameters.placement.heuristic.dist_close mm Euclidean distance from target +% 3. Exit aperture intersects skin surface (>0% overlap required) +% 4. Scores: skin_distance uniformity, skull_distance uniformity, intersection fraction +% +% Exhaustively evaluates all candidate transducer positions within distance threshold using GPU. +% Computes intersection fraction, skin/skull distances, and variance metrics for position optimization. +% +% INPUT +% img - Segmented head image (nifti final_tissues.nii.gz) +% target - 1x3 target coordinates [x,y,z] in voxel space +% parameters - Struct with .tp_dist_close, .transducer properties +% pixel_size - Scalar voxel size in mm +% +% OUTPUT +% tpos_pars - Table with columns: idx, trans_x/y/z, targ_x/y/z, dist_to_target, +% prop_intersect, mean_dist_skin, var_dist_skin, mean_dist_skull, var_dist_skull +% mesh - Structure with mesh information + +disp("[TP] Evaluating candidate position via intersection of skull with expanding sphere ...") + +%--- Build candidate mesh and candidate geometry --------------------------------- +mesh = tp_candidate_mesh(img, target, parameters, pixel_size); + +%--- Extract required variables for position analysis ---------------------- +coord_mesh = mesh.coord_mesh; +skin_coords = mesh.skin_coords; +skull_coords = mesh.skull_coords; +close_enough_idx = mesh.close_enough_idx; +norm_v = mesh.norm_v; +ex_plane = mesh.ex_plane; +all_masks_idx = mesh.all_masks_idx; +max_od_grid = mesh.max_od_grid; +trans_pos = mesh.trans_pos; + +%% Parallel evaluation of all candidate positions + +N_cand = length(close_enough_idx); +[prop_intersect, mean_dts, var_dts, mean_dist_skull, var_dist_skull] = ... + arrayfun(@(x) transducer_analyze_position_fast(x, norm_v, ex_plane, coord_mesh, ... + all_masks_idx, skin_coords, skull_coords, max_od_grid), 1:N_cand); + +%% Assemble results table (CPU) + +% Ensure all components are on CPU before table assembly +close_enough_idx = gather(close_enough_idx); +trans_pos = gather(trans_pos); +metrics = gather([prop_intersect; mean_dts; var_dts; mean_dist_skull; var_dist_skull]'); +dist_to_target = gather(pdist2(trans_pos, target)); + +tpos_pars = array2table([close_enough_idx, trans_pos, ... + repmat(target, N_cand, 1), ... + dist_to_target, ... + metrics], ... + 'VariableNames', ["idx","trans_x","trans_y","trans_z","targ_x","targ_y","targ_z",... + "dist_to_target","prop_intersect","mean_dist_skin","var_dist_skin","mean_dist_skull","var_dist_skull"]); + +end \ No newline at end of file diff --git a/functions/transducer/tp_find_initial_candidate.m b/functions/transducer/tp_find_initial_candidate.m new file mode 100644 index 00000000..2396d7e3 --- /dev/null +++ b/functions/transducer/tp_find_initial_candidate.m @@ -0,0 +1,63 @@ +function [trans_candidate, outer_sphere_3d, parameters] = ... + tp_find_initial_candidate(img, target, pixel_size, parameters) + +% TP_FIND_INITIAL_CANDIDATE Find candidate transducer positions on skull surface +% +% Finds candidate transducer positions by expanding a sphere from target until it intersects +% skull exterior surface (air-tissue boundary). Generates initial positioning plots and computes +% geometric properties (focus, exit plane, normal vector). +% +% INPUT +% img - Segmented head image (nifti final_tissues.nii.gz) +% target - 1x3 target coordinates [x,y,z] in voxel space +% pixel_size - Scalar voxel size in mm (mean of PixelDimensions) +% parameters - Struct with transducer properties (.transducer.curv_radius_mm, etc.) +% +% OUTPUT +% trans_candidate - Initial random transducer position [x,y,z] +% outer_sphere_3d - 3D logical mask of valid transducer positions on skull surface +% parameters - Parameters with updated min_focal_distance_mm + +disp("[TP] Finding candidate position via intersection of skull with expanding sphere ...") + +sz = size(img); % Get image dimensions [Nx Ny Nz] +[t1_x, t1_y, t1_z] = ndgrid(1:sz(1), 1:sz(2), 1:sz(3)); % Create 3D voxel coordinate grids + +% Initialize minimum focal distance (bowl radius in voxels) +if ~isfield(parameters, 'min_focal_distance_mm') + parameters.min_focal_distance_mm = parameters.expected_focal_distance_bowl; % Default to bowl geometry +end + +outer_sphere_3d = []; outer_sphere = []; % Pre-allocate for while loop +while numel(find(outer_sphere)) < 1 % Expand until sphere intersects skull exterior (img==0) + + % Compute Euclidean distance from target voxel to every voxel in volume + grid_dist = sqrt((t1_x-target(1)).^2 + (t1_y-target(2)).^2 + (t1_z-target(3)).^2); + + % Create binary sphere shell: voxels within Β±0.5 voxel of current radius + dist_sphere = abs(grid_dist - parameters.min_focal_distance_mm/pixel_size) < 0.5; + + % 3D: Find skull exterior intersection (sphere shell & outside brain/skull) + outer_sphere_3d = dist_sphere & (img == 0); + + % Extract & visualize central Y-slice through target for debugging + img_slice = ind2rgb(squeeze(img(:,target(2),:)), viridis(max(img(:))+1)); % Colormap segmentation + outer_sphere = squeeze(dist_sphere(:,target(2),:)) & squeeze(img(:,target(2),:)) == 0; % 2D slice intersection + + % Overlay white intersection points on slice (for visual verification) + img_slice(outer_sphere) = 1; % RGB [1 1 1] = white + + % Increment radius by 3mm for next iteration + parameters.min_focal_distance_mm = parameters.min_focal_distance_mm + 3; +end + +% Random transducer candidate from skull surface intersection +outer_idx = find(outer_sphere_3d&t1_y==target(2)); +trans_idx = randsample(outer_idx, 1); +trans_pos = [t1_x(trans_idx), t1_y(trans_idx), t1_z(trans_idx)]; +trans_xz = trans_pos([1,3]); + +trans_candidate.outer_idx = outer_idx; +trans_candidate.trans_idx = trans_idx; +trans_candidate.trans_pos = trans_pos; +trans_candidate.trans_xz = trans_xz; \ No newline at end of file diff --git a/functions/transducer/tp_plot_candidate_positions.m b/functions/transducer/tp_plot_candidate_positions.m new file mode 100644 index 00000000..d5a758d7 --- /dev/null +++ b/functions/transducer/tp_plot_candidate_positions.m @@ -0,0 +1,49 @@ +function tp_plot_candidate_positions(img, target, trans_candidate, ... + pixel_size, parameters, subject_id, target_name) + % TP_PLOT_CANDIDATE_POSITIONS Plot transducer candidate positions on skull surface slice + % + % Creates bounds visualization showing target (red), random transducer candidate (blue), + % and search sphere intersection with skull surface. + % + % INPUT + % img - 3D tissue segmentation image + % target - 1x3 target coordinates [x,y,z] + % trans_candidate - Structure with info on transducer candidate + % pixel_size - Voxel size (mm) + % parameters - Transducer parameters for get_transducer_box + % subject_id - Scalar ID for filename + % target_name - String for filename + % + % OUTPUT + % Saves: sub-XXX_bounds_TARGET.png + + disp("[TP] Plotting initial candidate position ...") + + trans_xz = trans_candidate.trans_xz; + target_xyz = trans_candidate.trans_pos; + + % Get image slice + img_slice = ind2rgb(squeeze(img(:,target_xyz(2),:)), viridis(max(img(:))+1)); + + h = figure; + colormap([0.3 0.3 0.3; lines(12)]) + imagesc(img_slice); axis image; hold on; + + % Target (red rectangle) + target_xz = target([1,3]); + rectangle('Position',[flip(target_xz)-2, 4, 4], 'Curvature',[0,0], ... + 'EdgeColor','r', 'LineWidth',2, 'LineStyle','-'); + + % Candidate position (blue rectangle) + rectangle('Position',[flip(trans_xz)-2, 4, 4], 'Curvature',[0,0], ... + 'EdgeColor','b', 'LineWidth',2, 'LineStyle','-'); + + % Transducer visualization box + get_transducer_box(trans_xz, target_xz, pixel_size, parameters); + + % Save + output_plot = fullfile(parameters.io.output_dir, ... + sprintf('sub-%03d_bounds_%s.png', subject_id, target_name)); + saveas(h, output_plot, 'png'); + close(h); +end diff --git a/functions/transducer/tp_plot_geometry_overlay.m b/functions/transducer/tp_plot_geometry_overlay.m new file mode 100644 index 00000000..25dd57bb --- /dev/null +++ b/functions/transducer/tp_plot_geometry_overlay.m @@ -0,0 +1,65 @@ +function tp_plot_geometry_overlay(img, target_pos, trans_pos, pixel_size, parameters, subject_id, target_name, outer_sphere_3d) +%% TP_PLOT_GEOMETRY_OVERLAY Visualize transducer geometry on skin segmentation slice +% Overlays transducer positions, geometric focus, exit plane, and ray path +% on central Y-slice through target for positioning validation + +disp("[TP] Generating and visualizing geometric overlay ...") + +sz = size(img); % Get image dimensions [Nx Ny Nz] - overrides input sz +[t1_x, t1_y, t1_z] = ndgrid(1:sz(1), 1:sz(2), 1:sz(3)); % 3D voxel coordinate grids (X,Y,Z) + +% Transducer geometry parameters (physical β†’ voxel space) +max_od_mm = max(parameters.transducer.Elements_OD_mm); % Largest element diameter (mm) - aperture size +% Sagitta calculation: distance from geometric focus to exit plane +% h = R - sqrt(R^2 - (D/2)^2) where R=curvature radius, D=aperture diameter +% Exit plane lies at h/2 from sphere center along optical axis +dist_gf_to_ep_mm = 0.5 * sqrt(4*parameters.transducer.curv_radius_mm^2 - max_od_mm^2); + +% Unit normal vector: direction from transducer β†’ target (propagation axis) +norm_v = (trans_pos - target_pos) / norm(target_pos - trans_pos); + +% Geometric focus: sphere center (curvature radius along normal from trans_pos) +geom_focus_pos = trans_pos - norm_v * (parameters.transducer.curv_radius_mm / pixel_size); + +% Exit plane: halfway between geometric focus and aperture plane +ex_plane_pos = geom_focus_pos + norm_v * (dist_gf_to_ep_mm / pixel_size); + +% Aperture disk radius in voxel units +max_od_grid = max_od_mm / pixel_size; + +% Define exit plane disk: orthogonal to propagation axis, centered at ex_plane_pos +d = sum(norm_v .* ex_plane_pos); % Plane equation: nΒ·x = d +orth_plane_disk = abs(t1_x*norm_v(1) + t1_y*norm_v(2) + t1_z*norm_v(3) - d) < 0.5 & ... + sqrt((t1_x-ex_plane_pos(1)).^2 + (t1_y-ex_plane_pos(2)).^2 + (t1_z-ex_plane_pos(3)).^2) < max_od_grid/2; + +% Create visualization mask: skin(5)=skin, disk=2, sphere_intersection=3 +skin_only = uint8(img == 5); % Extract skin surface only +skin_only(orth_plane_disk) = 2; % Mark exit plane aperture +skin_only(find(outer_sphere_3d)) = 3; % Mark skull intersection sphere (from tp_find_candidate_positions) + +%% Plot central Y-slice through target + +h = figure; +imagesc(squeeze(skin_only(:, target_pos(2), :))); hold on; % X-Z slice at target Y +colormap(gray); % Monochrome for segmentation clarity + +% Extract X,Z coordinates for 2D overlay (flip for imagesc convention) +trans_xz = trans_pos([1,3]); target_xz = target_pos([1,3]); + +% Draw position markers (4-voxel boxes) +rectangle('Position', [flip(trans_xz)-2, 4, 4], 'Curvature', [0,0], 'EdgeColor', 'b', 'LineWidth', 2); % Transducer (blue) +rectangle('Position', [flip(geom_focus_pos([1,3]))-2, 4, 4], 'Curvature', [0,0], 'EdgeColor', 'yellow', 'LineWidth', 2); % Geo focus (yellow) +rectangle('Position', [flip(ex_plane_pos([1,3]))-2, 4, 4], 'Curvature', [0,0], 'EdgeColor', 'white', 'LineWidth', 2); % Exit plane (white) +rectangle('Position', [flip(target_xz)-2, 4, 4], 'Curvature', [0,0], 'EdgeColor', 'r', 'LineWidth', 2); % Target (red) + +% Draw propagation path (white line) +line([trans_xz(2) target_xz(2)], [trans_xz(1) target_xz(1)], 'Color', 'white', 'LineWidth', 2); + +% Add 3D transducer bowl visualization +get_transducer_box(trans_xz, target_xz, pixel_size, parameters); + +% Save geometry validation plot +saveas(h, fullfile(parameters.io.output_dir, sprintf('sub-%03d_geometry_%s.png', subject_id, target_name)), 'png'); +close(h); + +end diff --git a/functions/transducer/tp_plot_heuristic_position.m b/functions/transducer/tp_plot_heuristic_position.m new file mode 100644 index 00000000..3bc7892c --- /dev/null +++ b/functions/transducer/tp_plot_heuristic_position.m @@ -0,0 +1,91 @@ +function tp_plot_heuristic_position(trans_pos, target_pos, img, img_header, parameters, pixel_size, target_name, subject_id) +% TP_PLOT_HEURISTIC_POSITION Create visualization of heuristic transducer placement +% +% INPUT +% trans_pos - Heuristic Transducer position (xyz) [grid] +% target_pos - Target position (xyz) [grid] +% img - Segmented head image +% img_header - NIfTI header info +% parameters - Parameters struct +% pixel_size - Scalar voxel size in mm +% target_name - String target identifier +% subject_id - Scalar subject ID + +trans_xyz = gather(trans_pos); +target_xyz = gather(target_pos); +img = gather(img); + +sz = size(img); +[t1_x, t1_y, t1_z] = ndgrid(1:sz(1), 1:sz(2), 1:sz(3)); +coord_mesh.xyz = [reshape(t1_x,[],1), reshape(t1_y,[],1), reshape(t1_z,[],1)]; + +%% Align to focal axis + +[img_rotated, trans_xyz_rotated, target_xyz_rotated, transformation_matrix, ~, ~, ~, ~] = ... + preproc_align_to_focal_axis(... + img, img_header, round(trans_xyz)', target_xyz', 1, parameters); + +%% Visualization + +h = figure('units', 'normalized', 'position', [0 0 1 .5]); +subplot(1,3,1); + colormap([0.3 0.3 0.3; lines(5)]); + imagesc(squeeze(img_rotated(:,round(trans_xyz_rotated(2)),:))); + + % TARGET - Red box + rectangle('Position', [target_xyz_rotated([3,1]) - 2, 4, 4], ... + 'Curvature', [0,0], ... + 'EdgeColor', 'r', ... + 'LineWidth', 2, 'LineStyle', '-'); + + % TRANSDUCER (heuristic position) - Blue box + rectangle('Position', [trans_xyz_rotated([3,1]) - 2, 4, 4],... + 'Curvature', [0,0],... + 'EdgeColor', 'b', ... + 'LineWidth', 2, 'LineStyle', '-'); + + line([trans_xyz_rotated(3) target_xyz_rotated(3)], ... + [trans_xyz_rotated(1) target_xyz_rotated(1)], 'Color', 'white'); + + get_transducer_box(... + trans_xyz_rotated([1,3]), target_xyz_rotated([1,3]), pixel_size, parameters); + +subplot(1,3,2); + + show_3d_head(img, ... + target_xyz, ... + trans_xyz, ... + parameters, ... + pixel_size, ... + coord_mesh.xyz, ... + [0 0 0],... + [0,0],... + 0) + +subplot(1,3,3); + + show_3d_head(img, ... + target_xyz, ... + trans_xyz, ... + parameters, ... + pixel_size, ... + coord_mesh.xyz, ... + [0 0 0],... + [0,0],... + 0) + + view([-175,0]) + +% save figure in output directory +output_plot = fullfile(parameters.io.output_dir,... + sprintf('sub-%03d_heuristic_%s.png', subject_id, target_name)); +saveas(h, output_plot, 'png') + +%% [Optional] Save copy in localite directory +if isfield(parameters.path, 'localite') && ~isempty(parameters.path.localite) + saveas(h, fullfile(parameters.path.localite, ... + sprintf("sub-%03.0f_%s", subject_id, target_name)), 'png'); +end + +% close figure +close(h); diff --git a/functions/transducer/tp_remove_ear_locations.m b/functions/transducer/tp_remove_ear_locations.m new file mode 100644 index 00000000..9de3f1f6 --- /dev/null +++ b/functions/transducer/tp_remove_ear_locations.m @@ -0,0 +1,43 @@ +function [locs] = tp_remove_ear_locations(parameters, locs) +%% Exclude ear entries from heuristic transducer positions +% Removes transducer positions within tp_ear_radius of ear centers +% Only runs if all three ear parameters are specified in parameters struct + +% Only run if all three ear parameters are specified +if isfield(parameters, 'placement') && isfield(parameters.placement, 'heuristic') && ... + isfield(parameters.placement.heuristic, 'ear_radius') && ... + isfield(parameters.placement.heuristic, 'left_ear_center') && ... + isfield(parameters.placement.heuristic, 'right_ear_center') + % Validate complete specification + if ~isempty(parameters.placement.heuristic.ear_radius) && ~isempty(parameters.placement.heuristic.left_ear_center) && ~isempty(parameters.placement.heuristic.right_ear_center) + + % keep copy of original positions + locs_original = locs; + + % Define nogo zone spheres + ear_radius = parameters.placement.heuristic.ear_radius; + left_ear_center = parameters.placement.heuristic.left_ear_center; + right_ear_center = parameters.placement.heuristic.right_ear_center; + + % Calculate Euclidean distances from each transducer position to both ears + locs.dist_to_left_ear = sqrt((locs.trans_x - left_ear_center(1)).^2 + ... + (locs.trans_y - left_ear_center(2)).^2 + ... + (locs.trans_z - left_ear_center(3)).^2); + + locs.dist_to_right_ear = sqrt((locs.trans_x - right_ear_center(1)).^2 + ... + (locs.trans_y - right_ear_center(2)).^2 + ... + (locs.trans_z - right_ear_center(3)).^2); + + % Keep only positions outside both ear exclusion zones + locs = locs(locs.dist_to_left_ear > ear_radius & locs.dist_to_right_ear > ear_radius, :); + + fprintf('[TP_REMOVE] Removed %d/%d positions in ear exclusion zones\n', ... + height(locs_original) - height(locs), height(locs_original)); + else + fprintf('[TP_REMOVE] Ear exclusion zones specified but incomplete - skipping\n'); + end +else + fprintf('[TP_REMOVE] Ear exclusion parameters missing - all positions kept\n'); +end + +end diff --git a/functions/transducer/tp_select_heuristic_position.m b/functions/transducer/tp_select_heuristic_position.m new file mode 100644 index 00000000..01ea67fe --- /dev/null +++ b/functions/transducer/tp_select_heuristic_position.m @@ -0,0 +1,177 @@ +function [trans_pos_grid, target_pos_grid, best_trans_pos] = ... + tp_select_heuristic_position(locs, subject_id, target_name, parameters, img_header) +%% TP_SELECT_HEURISTIC_POSITION Select optimal transducer position and export Localite coordinates +% +% Multi-step HEURISTIC selection: +% 1. Filter by intersection fraction < criterion_intersection (default 0.05) +% 2. Auto-expand criterion if no candidates found +% 3. Select minimum distance to target from valid candidates +% +% INPUT +% locs - Table with transducer candidate metrics (from tp_evaluate_candidate_positions) +% subject_id - Scalar subject ID +% target_name - String target identifier (e.g. 'right_PUL') +% parameters - [OPTIONAL] Struct with .tp_criterion_intersection, .output_dir, .localite_path +% img_header - Header of planning image +% +% OUTPUT +% trans_pos_grid - [1x3] integer voxel coordinates [x y z] +% target_pos_grid - [1x3] integer voxel coordinates [x y z] +% best_trans_pos - Full table row of chosen HEURISTIC position with +% added Localite & RAS conversions + +%% Select heuristic position (multi-criteria heuristic) + +% Default parameter values if not provided +defaults = struct( ... + 'criterion_intersection', 0.05, ... + 'criterion_skin_mean', NaN, ... + 'criterion_skull_mean', NaN, ... + 'criterion_skin_var', NaN, ... + 'criterion_skull_var', NaN, ... + 'expand_step', 0.01 ... +); + +% Fill in missing parameter fields +if ~isfield(parameters, 'placement') || ~isfield(parameters.placement, 'heuristic') + parameters.placement.heuristic = struct(); +end +fields = fieldnames(defaults); +for k = 1:numel(fields) + if ~isfield(parameters.placement.heuristic, fields{k}) || isempty(parameters.placement.heuristic.(fields{k})) + parameters.placement.heuristic.(fields{k}) = defaults.(fields{k}); + fprintf('[TP_HEURISTIC] Using default %s = %.3f\n', ... + fields{k}, defaults.(fields{k})); + end +end + +%% STEP 1 β€” Intersection (adaptive expansion) + +if ~isnan(parameters.placement.heuristic.criterion_intersection) + criterion = parameters.placement.heuristic.criterion_intersection; + tppf = locs(locs.prop_intersect < criterion, :); + + while isempty(tppf) && criterion < 0.3 + criterion = criterion + parameters.placement.heuristic.expand_step; + tppf = locs(locs.prop_intersect < criterion, :); + end +else + tppf = locs; +end + +%% STEP 2 β€” Skin proximity +if ~isnan(parameters.placement.heuristic.criterion_skin_mean) && ~isempty(tppf) + % Use quantile + skin_lim = quantile(tppf.mean_dist_skin, parameters.placement.heuristic.criterion_skin_mean); + tmp = tppf(tppf.mean_dist_skin <= skin_lim, :); + if ~isempty(tmp), tppf = tmp; + else, fprintf('[TP_HEURISTIC] Skipping skin_mean criterion.\n'); end +end + +%% STEP 3 β€” Skull variance + +if ~isnan(parameters.placement.heuristic.criterion_skull_var) && ~isempty(tppf) + % Use quantile + skull_var_lim = quantile(tppf.var_dist_skull, parameters.placement.heuristic.criterion_skull_var); + tmp = tppf(tppf.var_dist_skull <= skull_var_lim, :); + if ~isempty(tmp), tppf = tmp; + else, fprintf('[TP_HEURISTIC] Skipping skull_var criterion.\n'); end +end + +%% STEP 4 β€” Skull mean distance (always applied) +if ~isnan(parameters.placement.heuristic.criterion_skull_mean) && ~isempty(tppf) + % Use quantile + skull_mean_lim = quantile(tppf.mean_dist_skull, parameters.placement.heuristic.criterion_skull_mean); + tmp = tppf(tppf.mean_dist_skull <= skull_mean_lim, :); + if ~isempty(tmp), tppf = tmp; + else, fprintf('[TP_HEURISTIC] Skipping skull_mean criterion.\n'); end +end + +%% STEP 5 β€” Skin variance +if ~isnan(parameters.placement.heuristic.criterion_skin_var) && ~isempty(tppf) + % Use quantile + skin_var_lim = quantile(tppf.var_dist_skin, parameters.placement.heuristic.criterion_skin_var); + tmp = tppf(tppf.var_dist_skin <= skin_var_lim, :); + if ~isempty(tmp) + tppf = tmp; + else + fprintf('[TP_HEURISTIC] Skipping skin_var criterion.\n'); + end +end + +%% STEP 6 β€” Minimum distance to target + +if ~isempty(tppf) + tppf = tppf(tppf.dist_to_target == min(tppf.dist_to_target), :); + i = find(locs.idx == tppf.idx(1)); + fprintf('[TP_HEURISTIC] Selected %d final candidate(s).\n', size(tppf,1)); +else + warning('[TP_HEURISTIC] No candidates remain.'); + i = []; +end + +%% Extract HEURISTIC position + +best_trans_pos = locs(i, :); +trans_pos_grid = floor(table2array(best_trans_pos(1, ["trans_x", "trans_y", "trans_z"]))); +target_pos_grid = floor(table2array(best_trans_pos(1, ["targ_x", "targ_y", "targ_z"]))); + +%% Convert to Localite physical coordinates (mm) +% IMPORTANT: Assumes that localite planning image has +% canonical_affine_transform applied! Always visually check coordinates ... + +trans_pos_localite = round(trans_pos_grid .* img_header.PixelDimensions(1:3), 2); +target_pos_localite = round(target_pos_grid .* img_header.PixelDimensions(1:3), 2); + +%% Convert to RAS+ physical coordinates (mm) + +trans_pos_ras = round(transform_coordinates(parameters, trans_pos_grid, 'grid', 'ras_plus', img_header),2); +target_pos_ras = round(transform_coordinates(parameters, target_pos_grid, 'grid', 'ras_plus', img_header),2); + +%% Add Localite coordinates to output table + +best_trans_pos.trans_x = round(best_trans_pos.trans_x); +best_trans_pos.trans_y = round(best_trans_pos.trans_y); +best_trans_pos.trans_z = round(best_trans_pos.trans_z); + +best_trans_pos.LOCALITE_Transducer_mm = Inf; + +best_trans_pos.loc_trans_x_mm = trans_pos_localite(1); +best_trans_pos.loc_trans_y_mm = trans_pos_localite(2); +best_trans_pos.loc_trans_z_mm = trans_pos_localite(3); + +best_trans_pos.LOCALITE_Target_mm = Inf; + +best_trans_pos.loc_targ_x_mm = target_pos_localite(1); +best_trans_pos.loc_targ_y_mm = target_pos_localite(2); +best_trans_pos.loc_targ_z_mm = target_pos_localite(3); + +best_trans_pos.RAS_Transducer_mm = Inf; + +best_trans_pos.ras_trans_x_mm = trans_pos_ras(1); +best_trans_pos.ras_trans_y_mm = trans_pos_ras(2); +best_trans_pos.ras_trans_z_mm = trans_pos_ras(3); + +best_trans_pos.RAS_Target_mm = Inf; + +best_trans_pos.ras_targ_x_mm = target_pos_ras(1); +best_trans_pos.ras_targ_y_mm = target_pos_ras(2); +best_trans_pos.ras_targ_z_mm = target_pos_ras(3); + +%% Export Localite-compatible table + +table_formatted = rows2vars(best_trans_pos); + +table_path = fullfile(parameters.io.output_dir, sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); +writetable(table_formatted, table_path, 'Delimiter', 'tab', 'WriteVariableNames', false); +fprintf('[TP_HEURISTIC] Saved heuristic position: %s\n', table_path); + +%% [Optional] Save copy in localite directory +if isfield(parameters.path, 'localite') && ~isempty(parameters.path.localite) + localite_path = fullfile(parameters.path.localite,... + sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); + writetable(table_formatted, localite_path, 'Delimiter', 'tab', 'WriteVariableNames', false); + fprintf('[TP_HEURISTIC] Additional Localite copy saved: %s\n', localite_path); +end + +end \ No newline at end of file diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index a1b2213c..de6481ea 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -1,485 +1,129 @@ -function transducer_positioning(parameters, pn, subject_id, target_name, mni_targets) - -% TRANSDUCER_POSITIONING Determine heuristic transducer placement for a given target. -% -% This function calculates the heuristic placement of a transducer for a specified target -% based on segmented brain images and MNI coordinates. It performs the following tasks: -% - Adds required paths to MATLAB's environment. -% - Converts MNI coordinates to subject-specific coordinates. -% - Identifies valid transducer positions based on distance constraints. -% - Computes geometric properties (e.g., focus position, exit plane) for transducer placement. -% - Visualizes segmentation results and transducer placement. -% -% Input: -% parameters - Struct containing simulation parameters (e.g., paths, transducer settings). -% pn - Struct containing subject-specific paths (e.g., segmentation folder). -% subject_id - Integer specifying the subject ID. -% target_name - String specifying the name of the target (e.g., 'motor_cortex'). -% mni_targets - Struct containing MNI coordinates for each target. -% -% Output: -% None. The function saves visualizations and transducer placement results in the specified output directory. - - % Adds the paths to the 'functions' and 'toolboxes' folders - currentLoc = fileparts(mfilename("fullpath")); - functionsLoc = fullfile(currentLoc,'..','functions'); - toolboxesLoc = fullfile(currentLoc,'..','toolboxes'); - allPaths = regexp(path,pathsep,'Split'); - - if ~any(ismember(functionsLoc,allPaths)) - addpath(functionsLoc); - disp(['Adding ', functionsLoc]); - else - end +function transducer_positioning(parameters, pn, target_name, mni_targets) +% TRANSDUCER_POSITIONING Heuristic transducer placement for MNI targets +arguments + parameters struct + pn struct + target_name string + mni_targets struct +end + +%% 1. PATHS & VALIDATION + +currentLoc = fileparts(mfilename("fullpath")); +% add functions here to detect path setup function +addpath(genpath(fullfile(currentLoc, '..'))); + +[parameters] = path_log_setup(parameters, get_prestus_path); +subject_id = parameters.subject_id; + +%% 2. LOAD SEGMENTATION DATA + +m2m_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); +filename = fullfile(m2m_folder, 'final_tissues.nii.gz'); +img = niftiread(filename); +img = gather(img); % Ensure img is on CPU +img_header = niftiinfo(filename); +voxel_size = mean(img_header.PixelDimensions); + +% [DEBUG] plot the segmentation +if parameters.simulation.debug + h = figure; + im_center = round(size(img)/2); + montage({rot90(squeeze(img(im_center(1),:,:))), ... + rot90(squeeze(img(:,im_center(2),:))), ... + squeeze(img(:,:,im_center(3)))}, ... + viridis(8), 'Size', [1 3]); + saveas(h, fullfile(parameters.io.debug_dir, ... + sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); + close(h); + clear im_center; +end - if ~any(ismember(toolboxesLoc,allPaths)) - addpath(genpath(toolboxesLoc)); - disp(['Adding ', toolboxesLoc, 'and subfolders']); - else - end +%% heuristic positioning - % If there are paths to be added, add them; this is mostly for batch runs - if isfield(parameters,'paths_to_add') && ~isempty(parameters.paths_to_add) - for nPaths = 1:length(parameters.paths_to_add) - addpath(parameters.paths_to_add{nPaths}) - disp(['Adding ', parameters.paths_to_add{nPaths}]); - end - end +% specify output file +tpos_output_file = fullfile(parameters.io.output_dir, ... + sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); - % If the path and subpaths need to be added, use this instead - if isfield(parameters,'subpaths_to_add') && ~isempty(parameters.subpaths_to_add) - for nPaths = 1:length(parameters.subpaths_to_add) - addpath(genpath(parameters.subpaths_to_add{nPaths})) - disp(['Adding ', parameters.subpaths_to_add{nPaths}, 'and subfolders']); - end - end +if confirm_overwriting(tpos_output_file, parameters) - % test that kwave is added - if ~exist('makeBowl','file') - error('kwave not added'); - end + %% Convert target from MNI (mm) to subject grid space (voxels) - headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); - filename_segmented_headreco = fullfile(headreco_folder,'final_tissues.nii.gz'); + fprintf('➀ Target: %s\n', target_name); + target_mni = mni_targets.(target_name); + target_vox = transform_coordinates(... + parameters, target_mni, 'mni', 'grid', img_header); - segmented_img_orig = niftiread(filename_segmented_headreco); - segmented_img_head = niftiinfo(filename_segmented_headreco); - pixel_size = mean(segmented_img_head.PixelDimensions); - - im_center = round(size(segmented_img_orig)/2); + %% Find candidate transducer positions on skull (expanding sphere) - % original target loop + [trans_candidate, outer_sphere_3d, parameters] = ... + tp_find_initial_candidate(img, target_vox, voxel_size, parameters); - fprintf('Current target: %s\n', target_name) - tpos_output_file = fullfile(parameters.output_dir, sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); - - % Get the subject-specific position of the specified MNI coordinate - % Note: with SimNIBS 4, we have to use a fix that correctly calls - % the shell script (see issue: https://github.com/simnibs/simnibs/issues/106) - - %simnibs_coords = mni2subject_coords(mni_targets.(target_name), sprintf('%s/m2m_sub-%03i', pn.seg_path, subject_id)) - simnibs_coords = mni2subject_coords_LDfix(mni_targets.(target_name), fullfile(pn.seg_path,sprintf('m2m_sub-%03i', subject_id)), parameters) - target = round(transformPointsInverse(segmented_img_head.Transform, simnibs_coords)) + %% Plot initial candidate - % plot the segmented image + tp_plot_candidate_positions(... + img, target_vox, trans_candidate, ... + voxel_size, parameters, subject_id, target_name); - h = figure; - montage({rot90(squeeze(segmented_img_orig(im_center(1),:,:))),... - rot90(squeeze(segmented_img_orig(:,im_center(2),:))),... - squeeze(segmented_img_orig(:,:,im_center(3)))}, viridis(8), 'Size',[1 3]) - output_plot = fullfile(parameters.output_dir,sprintf('sub-%03d_segmentation.png', subject_id)); - saveas(h, output_plot, 'png') - close(h); - - % get list of coordinates at expected focal distance of transducer - % increase distance until target is found in the axial plane (used for plotting) - - % Note: This may be too restrictive depending on the application. This - % restricts the solution to assume a placement within a sphere that - % includes the skin boundary in the axial plane. This is only relevant - % if the original transducer focus is too short. - - if ~isfield(parameters, 'min_focal_distance_mm') - parameters.min_focal_distance_mm = parameters.expected_focal_distance_mm; - end - outer_sphere = []; - while numel(find(outer_sphere)) < 1 - [t1_x, t1_y, t1_z] = ndgrid(1:size(segmented_img_orig, 1),1:size(segmented_img_orig, 2),1:size(segmented_img_orig, 3)); - grid_dist = sqrt((t1_x-target(1)).^2+(t1_y-target(2)).^2+(t1_z-target(3)).^2); % 3D euclidian distance - dist_sphere = abs(grid_dist-parameters.min_focal_distance_mm/pixel_size)<0.5; - outer_sphere_3d = dist_sphere&segmented_img_orig==0; - - segm_img_slice = ind2rgb(squeeze(segmented_img_orig(:,target(2),:)), viridis(max(segmented_img_orig(:))+1)); - outer_sphere = squeeze(dist_sphere(:,target(2),:))&squeeze(segmented_img_orig(:,target(2),:))==0; - segm_img_slice(outer_sphere) = 1; - parameters.min_focal_distance_mm = parameters.min_focal_distance_mm + 3; % expand search by 3 mm if necessary - end + %% Plot geometry - h = figure; - colormap([0.3 0.3 0.3; lines(12)]) - %imagesc(squeeze(grid_dist(:,target(2),:))); - imagesc(segm_img_slice); - axis image - hold on - target_xz = target([1,3]); - - rectangle('Position',[flip(target_xz) - 2, 4, 4],... - 'Curvature',[0,0], 'EdgeColor','r',... - 'LineWidth',2,'LineStyle','-'); - - outer_idx = find(outer_sphere_3d&t1_y==target(2)); - - trans_idx = randsample(outer_idx, 1); - trans_pos = [t1_x(trans_idx), t1_y(trans_idx), t1_z(trans_idx)]; - trans_xz = trans_pos([1,3]); - rectangle('Position',[flip(trans_xz) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','b',... - 'LineWidth',2,'LineStyle','-'); - - - get_transducer_box(trans_xz, target_xz, pixel_size, parameters); - output_plot = fullfile(parameters.output_dir,sprintf('sub-%03d_bounds_%s.png', subject_id, target_name)); - saveas(h, output_plot, 'png') - close(h); + tp_plot_geometry_overlay(img, target_vox, trans_candidate.trans_pos, ... + voxel_size, parameters, subject_id, target_name, outer_sphere_3d); - max_od_mm = max(parameters.transducer.Elements_OD_mm); + %% Evaluate candidate according to criteria - % normal vector - dist_gf_to_ep_mm = 0.5*sqrt(4*parameters.transducer.curv_radius_mm^2-max_od_mm^2); + tpos = tp_evaluate_candidate_positions(... + img, target_vox, parameters, voxel_size); - norm_v = (trans_pos-target)/norm(target-trans_pos); - geom_focus_pos = trans_pos - norm_v*(parameters.transducer.curv_radius_mm)/pixel_size; - ex_plane_pos = geom_focus_pos+norm_v*dist_gf_to_ep_mm/pixel_size; - max_od_grid = max(parameters.transducer.Elements_OD_mm)/pixel_size; + %% Save results table - d = sum(norm_v.*ex_plane_pos); - orth_plane_disk = abs(t1_x*norm_v(1)+t1_y*norm_v(2)+t1_z*norm_v(3)-d)<0.5 & sqrt((t1_x-ex_plane_pos(1)).^2+(t1_y-ex_plane_pos(2)).^2+(t1_z-ex_plane_pos(3)).^2) < max_od_grid/2; + writetable(tpos, tpos_output_file, 'Delimiter', ','); - skin_only = uint8(segmented_img_orig==5); +else + disp('Skipping positioning, loading existing output file...') + tpos = readtable(tpos_output_file, 'Delimiter', ','); +end - skin_only(orth_plane_disk)=2; - skin_only(outer_sphere_3d) = 3; +%% [Optional] Remove ear locations - h = figure; - imagesc(squeeze(skin_only(:,target(2),:))) - hold on - trans_xz = trans_pos([1,3]); - ex_pl_xz = ex_plane_pos([1,3]); - rectangle('Position',[flip(trans_xz) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','b',... - 'LineWidth',2,'LineStyle','-'); - rectangle('Position',[flip(geom_focus_pos([1,3])) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','yellow',... - 'LineWidth',2,'LineStyle','-'); - rectangle('Position',[flip(ex_pl_xz) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','white',... - 'LineWidth',2,'LineStyle','-'); - - rectangle('Position',[flip(target_xz) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','r',... - 'LineWidth',2,'LineStyle','-'); - line([trans_xz(2) target_xz(2)], [trans_xz(1) target_xz(1)], 'Color', 'white') - get_transducer_box(trans_xz, target_xz, pixel_size, parameters) - - % another figure - h = figure; - colormap([0.3 0.3 0.3; lines(12)]) - %imagesc(squeeze(grid_dist(:,target(2),:))); - imagesc(segm_img_slice); - axis image - hold on - target_xz = target([1,3]); - - rectangle('Position',[flip(target_xz) - 2, 4, 4],... - 'Curvature',[0,0], 'EdgeColor','r',... - 'LineWidth',2,'LineStyle','-'); - - outer_idx = find(outer_sphere_3d&t1_y==target(2)); - - trans_idx = randsample(outer_idx, 1); - trans_pos = [t1_x(trans_idx), t1_y(trans_idx), t1_z(trans_idx)]; - trans_xz = trans_pos([1,3]); - rectangle('Position',[flip(trans_xz) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','b',... - 'LineWidth',2,'LineStyle','-'); - - - get_transducer_box(trans_xz, target_xz, pixel_size, parameters); - output_plot = fullfile(parameters.output_dir,sprintf('sub-%03d_bounds_scalp_%s.png', subject_id, target_name)); - saveas(h, output_plot, 'png') - close(h); +[tpos] = tp_remove_ear_locations(parameters, tpos); + +%% Select heuristic transducer position + +[trans_pos, target_pos, ~] = ... + tp_select_heuristic_position(tpos, subject_id, target_name, parameters, img_header); + +%% Plot heuristic transducer position + +tp_plot_heuristic_position(... + trans_pos, target_pos, img, img_header, parameters, voxel_size, target_name, subject_id); + +fprintf('Heuristic transducer placement: %s (sub-%03d) β†’ %s\n', ... + target_name, subject_id, tpos_output_file); + +%% [Optional] Save T1w image with localite-ready header + +if isfield(parameters, 'placement') && isfield(parameters.placement, 'localite') && ... + isfield(parameters.placement.heuristic, 'save_localite_t1') && parameters.placement.heuristic.save_localite_t1 && ... + isfield(parameters.path, 'localite') && ~isempty(parameters.path.localite) + disp("Requested to transform T1 header for localite ...") + path_t1 = fullfile(m2m_folder, 'T1.nii.gz'); + path_localite_out = fullfile(parameters.path.localite, ... + ['sub-', sprintf('%03.0f_T1_forneuronav.nii', subject_id)]); + if confirm_overwriting(path_localite_out, parameters) + canonical_affine_transform(path_t1, path_localite_out); + fprintf('Localite-ready T1 (sub-%03d) β†’ %s\n', subject_id, path_localite_out); + else + disp('... but localite T1 file already exists ... skipping.') + end +end + +%% Finish + +% capture time, RAM, & GB load of pipeline +log_timer('stop','prestus_pipeline') + +% end logging +diary('off') - all_masks = segmented_img_orig>0; - all_masks_dilated = imdilate((all_masks), strel('sphere', 1)); - outer_boundary = all_masks_dilated-all_masks; - %imagesc(squeeze(outer_boundary(:,target(2),:))) - all_masks_eroded = imerode(all_masks, strel('sphere', 1)); - skin_boundary = all_masks-all_masks_eroded; - %imagesc(squeeze(skin_boundary(:,target(2),:))) - - % create bone as conjunction of compact (7) and spongy (8) bone - segmented_img_orig(segmented_img_orig==7|segmented_img_orig==8)=4; - skull = segmented_img_orig==4; - skull_boundary = imfill(segmented_img_orig>0&segmented_img_orig<=4,'holes'); - skull_boundary = imerode(skull_boundary, strel('sphere', 1)); - skull_boundary = skull & ~skull_boundary; - %h = figure; - %imagesc(squeeze(skull_boundary(:,target(2),:))) - - csf_box = regionprops3(segmented_img_orig==3); - csf_box = csf_box.BoundingBox; % first 3 numbers are x,y,z of a corner, the other three are dimensions - - % The following likely has to be flipped for left targets, but I am not - % quite sure what this is trying to achieve in the first place. Maybe - % restrict the solution to a solution nearby? - -% csf_box(1:3) = csf_box(1:3) - 35; -% csf_box(4:6) = csf_box(4:6) + 70; -% csf_box(1:3) -% inside_box = zeros(size(segmented_img_orig)); -% inside_box((t1_x>csf_box(1))&(t1_x<(csf_box(1)+csf_box(4)))&... -% (t1_y>csf_box(2))&(t1_y<(csf_box(2)+csf_box(5)))&... -% (t1_z>csf_box(3))&(t1_z<(csf_box(3)+csf_box(6))))=1; -% tmp = segmented_img_orig; -% tmp(~inside_box) = 0; -% %imagesc(squeeze(tmp(:,target(2),:))) -% -% outer_boundary(~inside_box) = 0; -% skin_boundary(~inside_box) = 0; -% %imagesc(squeeze(skin_boundary(:,target(2),:))) - - % for each point, put the transducer there, oriented towards the focus, and compute the amount of - % intersection and the average distance to the scalp - - coord_mesh = struct; - coord_mesh.x = gpuArray(t1_x); - coord_mesh.y = gpuArray(t1_y); - coord_mesh.z = gpuArray(t1_z); - - coord_mesh.xyz = gpuArray([reshape(t1_x,[],1) reshape(t1_y,[],1) reshape(t1_z,[],1)]); - - skin_boundary_coords = gpuArray(coord_mesh.xyz(find(skin_boundary),:)); - skull_boundary_coords = gpuArray(coord_mesh.xyz(find(skull_boundary),:)); - - outer_idx = find(outer_boundary); - - coord_rel_to_targ = coord_mesh.xyz - target; - distances_to_target = sqrt(sum(coord_rel_to_targ.^2,2)); - distances_to_target = distances_to_target(outer_idx); - - % figure; histogram(distances_to_target/pixel_size) - - close_enough_idx = outer_idx(distances_to_target<(parameters.dist_close/pixel_size)); - %close_enough_idx_yplane = intersect(close_enough_idx, find(t1_y==target(2))); - - trans_pos_coords = coord_mesh.xyz(close_enough_idx,:); - - norm_v = gpuArray((trans_pos_coords-target)./repmat(sqrt(sum((trans_pos_coords-target).^2,2)),[1, 3])); - - max_od_mm = max(parameters.transducer.Elements_OD_mm); - dist_gf_to_ep_mm = 0.5*sqrt(4*parameters.transducer.curv_radius_mm^2-max_od_mm^2); - dist_tp_to_ep_mm = parameters.transducer.curv_radius_mm - dist_gf_to_ep_mm; - %parameters.transducer.curv_radius_mm - parameters.transducer.dist_to_plane_mm - pos_shift_mm = 5 + dist_tp_to_ep_mm; - - shifted_trans_pos_coords = trans_pos_coords + norm_v*pos_shift_mm/pixel_size; - - geom_focus_pos_all = shifted_trans_pos_coords - norm_v*(parameters.transducer.curv_radius_mm)/pixel_size; - ex_plane_pos_all = gpuArray(geom_focus_pos_all+norm_v*dist_gf_to_ep_mm/pixel_size); - all_masks_indx_gpu = gpuArray(find(all_masks>0)); - - max_od_mm = max(parameters.transducer.Elements_OD_mm); - max_od_grid = max_od_mm / pixel_size; - - [prop_intersect, mean_dts, var_dts, mean_dist_skull, var_dist_skull] = ... - arrayfun(@(x) transducer_analyze_position_fast(... - x, norm_v, ex_plane_pos_all, coord_mesh, all_masks_indx_gpu, ... - skin_boundary_coords, skull_boundary_coords, max_od_grid ), ... - 1:length(close_enough_idx)); - - tpos_pars = array2table(gather([close_enough_idx, shifted_trans_pos_coords, repmat(target,[length(close_enough_idx),1]), pdist2( shifted_trans_pos_coords, target), [prop_intersect; mean_dts; var_dts; mean_dist_skull; var_dist_skull]']),... - 'VariableNames',["idx","trans_x","trans_y","trans_z","targ_x","targ_y","targ_z","dist_to_target","prop_intersect","mean_dist_skin","var_dist_skin","mean_dist_skull", "var_dist_skull"]); - writetable(tpos_pars, tpos_output_file, 'Delimiter',',') - - %% Extra plots of "best" results - -% sort_var = var_dist_skull; -% sort_var(prop_intersect>0.1) = max(var_dist_skull)+100; -% [v, sort_idx] = sort(sort_var); -% i = sort_idx(1); - - tppf = tpos_pars(tpos_pars.prop_intersect<0.05,:); - tppf = tppf(tppf.mean_dist_skull <= quantile(tppf.mean_dist_skull, 0.5) & tppf.var_dist_skull <= quantile(tppf.var_dist_skull, 0.1),:); - tppf = tppf(tppf.var_dist_skin==min(tppf.var_dist_skin),:); - - i = find(close_enough_idx==tppf.idx(1)); - % empty_t1 = zeros(size(outer_boundary)); - % empty_t1(intersect(close_enough_idx, find(t1_y==target(2)))) = 1; - %empty_t1 = empty_t1 + skin_boundary*2; - %i = find(close_enough_idx == randsample(intersect(close_enough_idx, find(t1_y==target(2))),1)); - - d = sum(norm_v(i,:).*ex_plane_pos_all(i,:)); - - % empty_t1(orth_plane_disk) = 1; - - target_xyz = [tppf.targ_x, tppf.targ_y, tppf.targ_z]; - trans_xyz = gather(shifted_trans_pos_coords(i,:)); - ex_pl_xyz = gather(ex_plane_pos_all(i,:)); - gf_xyz = gather(geom_focus_pos_all(i,:)); - - [rotated_img, trans_xyz, target_xyz, transformation_matrix, rotation_matrix, angle_x_rad, angle_y_rad, montage_img] = ... - preproc_align_to_focal_axis(segmented_img_orig, segmented_img_head, round(trans_xyz)', target_xyz', 1, parameters); - - TF = maketform('affine', transformation_matrix); - - ex_pl_xyz = gather(ex_plane_pos_all(i,:)); - gf_xyz = gather(geom_focus_pos_all(i,:)); - ex_pl_xyz = round(tformfwd([ex_pl_xyz]', TF)); - gf_xyz = round(tformfwd([gf_xyz]', TF)); - - h = figure('units', 'normalized', 'position', [0 0 1 1]); - subplot(2,3,1); - colormap([0.3 0.3 0.3; lines(5)]) - imagesc(squeeze(rotated_img(:,round(trans_xyz(2)),:))) - rectangle('Position',[target_xyz([3,1]) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','r',... - 'LineWidth',2,'LineStyle','-'); - rectangle('Position',[trans_xyz([3,1]) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','b',... - 'LineWidth',2,'LineStyle','-'); - rectangle('Position',[gf_xyz([3,1]) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','yellow',... - 'LineWidth',2,'LineStyle','-'); - rectangle('Position',[ex_pl_xyz([3,1]) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','white',... - 'LineWidth',2,'LineStyle','-'); - - line([trans_xyz(3) target_xyz(3)], [trans_xyz(1) target_xyz(1)], 'Color', 'white') - get_transducer_box(trans_xyz([1,3]), target_xyz([1,3]), pixel_size, parameters) - - subplot(2,3,2); - colormap([0.3 0.3 0.3; lines(12)]) - %imagesc(squeeze(grid_dist(:,target(2),:))); - imagesc(segm_img_slice); - axis image - hold on - target_xz = target([1,3]); - - rectangle('Position',[flip(target_xz) - 2, 4, 4],... - 'Curvature',[0,0], 'EdgeColor','r',... - 'LineWidth',2,'LineStyle','-'); - - outer_idx = find(outer_sphere_3d&t1_y==target(2)); - - trans_idx = randsample(outer_idx, 1); - trans_pos = [t1_x(trans_idx), t1_y(trans_idx), t1_z(trans_idx)]; - trans_xz = trans_pos([1,3]); - rectangle('Position',[flip(trans_xz) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','b',... - 'LineWidth',2,'LineStyle','-'); - - - get_transducer_box(trans_xz, target_xz, pixel_size, parameters); - - subplot(2,3,3); - - orth_plane_disk = find(abs(sum(coord_mesh.xyz.*norm_v(i,:),2)-d)<0.5); - orth_plane_disk = orth_plane_disk(sqrt(sum((coord_mesh.xyz(orth_plane_disk,:)-ex_plane_pos_all(i,:)).^2,2)) < max_od_grid /2); - - orth_plane_3d = zeros(size(segmented_img_orig)); - orth_plane_3d(orth_plane_disk) = 1; - orth_plane_3d = smooth3(orth_plane_3d); - skin_idx = find(skin_boundary); - max_od_grid = max(parameters.transducer.Elements_OD_mm)/pixel_size; - - %show_3d_head(segmented_img_orig, target, shifted_trans_pos_coords(i,:), parameters, pixel_size, coord_mesh.xyz, [-1 1 0],[0,0],0) - show_3d_head(segmented_img_orig, target, shifted_trans_pos_coords(i,:), parameters, pixel_size, coord_mesh.xyz, [0 0 0],[0,0],0) - - [t1_x_r, t1_y_r, t1_z_r] = ndgrid(1:size(rotated_img, 1),1:size(rotated_img, 2),1:size(rotated_img, 3)); - t1_xyz_r = gpuArray([reshape(t1_x_r,[],1) reshape(t1_y_r,[],1) reshape(t1_z_r,[],1)]); - - sphere_3d = zeros(size(rotated_img)); - sphere_3d(pdist2(t1_xyz_r, target_xyz)<3) = 1; - - subplot(2,3,4); - - norm_v_r = (trans_xyz-target_xyz)/norm(trans_xyz-target_xyz); - %norm_v_r = norm_v_r'; - d = sum(norm_v_r.*ex_pl_xyz); - orth_plane_disk = find(abs(sum(t1_xyz_r.*norm_v_r,2)-d)<0.5); - - orth_plane_disk = orth_plane_disk(sqrt(sum((t1_xyz_r(orth_plane_disk,:)-ex_pl_xyz).^2,2)) < max_od_grid /2); - orth_plane_3d = zeros(size(rotated_img)); - orth_plane_3d(orth_plane_disk) = 1; - orth_plane_3d = smooth3(orth_plane_3d); - - segmented_img_to_plot = rotated_img; - % sM = size(segmented_img_to_plot); - % N = norm_v(i,:); - % N_orth = null(N(:).') - % N_orth = N_orth(:,1) - % segmented_img_to_plot(:,floor(target_xyz(2)):size(segmented_img_to_plot,2),:) = []; - %segmented_img_to_plot(:,:,floor(target_xyz(3)):size(segmented_img_to_plot,3)) = []; - - % Dist = reshape(((1:sM(1)) - target(1)) * N_orth(1), [sM(1), 1, 1]) + ... - % reshape(((1:sM(2)) - target(2)) * N_orth(2), [1, sM(2), 1]) + ... - % reshape(((1:sM(3)) - target(3)) * N_orth(3), [1, 1, sM(3)]); - % - % segmented_img_to_plot(Dist>0) = 0; - segmented_img_to_plot = segmented_img_to_plot(1:2:end,1:2:end,1:2:end); - - for_caps = segmented_img_to_plot; - for_caps(for_caps>4) = 0; - - Ds = smooth3(gpuArray(double(segmented_img_to_plot>0))); - skin_isosurface = isosurface(Ds,0.5); - %figure - colormap(gray(80)) - - hiso = patch(skin_isosurface,... - 'FaceColor',[1,.75,.65],... - 'EdgeColor','none'); - isonormals(Ds,hiso); - - hcap = patch(isocaps(for_caps*10,4),... - 'FaceColor','interp',... - 'EdgeColor','none'); - lightangle(45,30); - lighting gouraud - hcap.AmbientStrength = 0.6; - hiso.SpecularColorReflectance = 0; - hiso.SpecularExponent = 50; - - trans_obj = patch(isosurface(orth_plane_3d(1:2:end,1:2:end,1:2:end)),... - 'FaceColor','blue',... - 'EdgeColor','none'); - - patch(isosurface(smooth3(sphere_3d(1:2:end,1:2:end,1:2:end))),'FaceColor','red','EdgeColor','red') - view(90,90) - - subplot(2,3,5) - colormap([0.3 0.3 0.3; lines(12)]) - %imagesc(squeeze(grid_dist(:,target(2),:))); - imagesc(segm_img_slice); - axis image - hold on - target_xz = target([1,3]); - - rectangle('Position',[flip(target_xz) - 2, 4, 4],... - 'Curvature',[0,0], 'EdgeColor','r',... - 'LineWidth',2,'LineStyle','-'); - - outer_idx = find(outer_sphere_3d&t1_y==target(2)); - - trans_idx = randsample(outer_idx, 1); - trans_pos = [t1_x(trans_idx), t1_y(trans_idx), t1_z(trans_idx)]; - trans_xz = trans_pos([1,3]); - rectangle('Position',[flip(trans_xz) - 2, 4 4],... - 'Curvature',[0,0], 'EdgeColor','b',... - 'LineWidth',2,'LineStyle','-'); - - - get_transducer_box(trans_xz, target_xz, pixel_size, parameters); - output_plot = fullfile(parameters.output_dir,sprintf('sub-%03d_optimal_%s.png', subject_id, target_name)); - saveas(h, output_plot, 'png') - close(h); \ No newline at end of file +end diff --git a/functions/transducer/transducer_positioning_start.m b/functions/transducer/transducer_positioning_start.m new file mode 100644 index 00000000..ac53457c --- /dev/null +++ b/functions/transducer/transducer_positioning_start.m @@ -0,0 +1,65 @@ +function transducer_positioning_start(parameters, pn, target_name, mni_targets) + arguments + parameters struct + pn struct + target_name string + mni_targets struct + end + + % ========== PLATFORM SELECTION ========== + if ~isfield(parameters, 'hpc') || ~isfield(parameters, 'platform') || strcmp(parameters.platform, 'auto') + platform = hpc_detect_system(); + parameters.platform = platform; + fprintf('➀ auto-detected: %s\n', upper(platform)); + else + platform = parameters.platform; + fprintf('➀ deploying: %s\n', upper(platform)); + end + + % ========== DISPATCH EXECUTION ========== + switch parameters.platform + case 'matlab' + fprintf('πŸ–₯️ Running in MATLAB\n\n'); + transducer_positioning(parameters, pn, target_name, mni_targets); + + case {'slurm', 'qsub'} + % ========== HPC EXECUTION ========== + hpc_validate_parameters(parameters, platform) + + [log_dir, prestus_path, temp_data_path, temp_m_path, temp_m_file] = ... + hpc_setup_temp_files(parameters); + + % populate temp data + save(temp_data_path, "parameters", "pn", "target_name", "mni_targets"); + + fid = fopen(temp_m_path, 'w'); + fprintf(fid, 'load(''%s'');\n', temp_data_path); + fprintf(fid, 'addpath(genpath(''%s''));\n', prestus_path); + fprintf(fid, 'transducer_positioning(parameters, pn, target_name, mni_targets);\n'); + fprintf(fid, 'delete(''%s'');\n', temp_data_path); + fprintf(fid, 'delete(''%s'');\n', temp_m_path); + fclose(fid); + + parameters.hpc.job_prefix = 'TP'; + job_name = hpc_job_name(parameters); + job_id = hpc_submit_job(platform, temp_m_file, parameters, log_dir); + job_info = hpc_job_info(platform, job_id, job_name, ... + parameters.hpc.memorylimit, parameters.hpc.timelimit, log_dir, 1); + + if isfield(parameters.hpc, 'wait_for_job') && parameters.hpc.wait_for_job + fprintf('⏳ Waiting for job completion...\n'); + fprintf('═══════════════════════════════\n'); + hpc_wait_for_completion(job_id, platform, parameters.hpc.max_wait_checks); + fprintf('βœ… Job %s completed\n\n', job_id_display); + end + + % Save job ID for chaining + parameters.hpc.job_id = job_id; + + otherwise + error('Unknown platform: %s. Use ''matlab'', ''slurm'', ''qsub'', or ''auto''.', ... + parameters.platform); + end + + +end diff --git a/functions/transducer/transducer_setup.m b/functions/transducer/transducer_setup.m index cb0b91a6..da89f878 100644 --- a/functions/transducer/transducer_setup.m +++ b/functions/transducer/transducer_setup.m @@ -1,5 +1,5 @@ function [transducer_mask, source_label, transducer_pars] = ... - transducer_setup(transducer_pars, trans_pos, focus_pos, grid_dims, grid_step_mm) + transducer_setup(transducer_pars, trans_pos, focus_pos, grid_dims, grid_res_mm) % TRANSDUCER_SETUP Creates a transducer mask and label matrix for a computational grid. % @@ -27,7 +27,7 @@ % % grid_dims - [Nx, Ny, Nz] array defining the dimensions of the computational grid. % -% grid_step_mm - Scalar specifying the step size of the computational grid (in mm). +% grid_res_mm - Scalar specifying the step size of the computational grid (in mm). % % Output: % transducer_mask - Binary matrix of size `grid_dims`. Non-zero values represent @@ -55,14 +55,14 @@ end % Convert element diameters from millimeters to grid points and ensure they are odd integers - transducer_pars.Elements_OD = 2*floor(transducer_pars.Elements_OD_mm / grid_step_mm / 2) + 1; % Outer diameter in grid points - transducer_pars.Elements_ID = 2*floor(transducer_pars.Elements_ID_mm / grid_step_mm / 2) + 1; % Inner diameter in grid points + transducer_pars.Elements_OD = 2*floor(transducer_pars.Elements_OD_mm / grid_res_mm / 2) + 1; % Outer diameter in grid points + transducer_pars.Elements_ID = 2*floor(transducer_pars.Elements_ID_mm / grid_res_mm / 2) + 1; % Inner diameter in grid points % Handle cases where inner diameter is zero (e.g., for flat elements) transducer_pars.Elements_ID(transducer_pars.Elements_ID_mm == 0) = 0; % Convert the curvature radius from millimeters to grid points - transducer_pars.radius_grid = round(transducer_pars.curv_radius_mm / grid_step_mm); % Radius in grid points + transducer_pars.radius_grid = round(transducer_pars.curv_radius_mm / grid_res_mm); % Radius in grid points % Initialize computational grids for the transducer mask and source label matrix transducer_mask = zeros(grid_dims); % Binary mask representing active transducer regions diff --git a/functions/transform/canonical_affine_transform.m b/functions/transform/canonical_affine_transform.m new file mode 100644 index 00000000..2e8ec678 --- /dev/null +++ b/functions/transform/canonical_affine_transform.m @@ -0,0 +1,60 @@ +function nii_out = canonical_affine_transform(nii_in_path, output_path) +%% CANONICAL_AFFINE_TRANSFORM Set NIfTI affine to diagonal voxel dimensions (LPI origin) +% +% Purpose: Remove rotations/shear, set origin to LPI corner (0,0,0), +% diagonal elements = voxel_size. Prevents k-Wave padding issues. +% +% LOCALITE COMPATIBILITY: Ensures clean RAS+ β†’ LPI coordinate mapping for +% Localite neuronavigation import. Voxel coordinates [x,y,z] map directly to +% physical mm via simple scaling: world_pos = voxel_pos * voxel_size. +% No rotation matrix required for Localite target import. +% +% Equivalent to Python: nib.as_closest_canonical() + custom diagonal affine +% +% INPUT +% nii_in_path - Input T1 NIfTI file path +% output_path - Output T1 NIfTI file path +% +% OUTPUT +% nii_out - Modified NIfTI structure (also saved to output_path) +% Localite-ready: T*[x;y;z;1] = [vx*sx, vy*sy, vz*sz, 1]' + +%% Load input NIfTI +nii_in = niftiinfo(nii_in_path); +img_data = niftiread(nii_in); + +fprintf('Original affine:\n'); disp(nii_in.Transform.T); +fprintf('Shape: [%d %d %d]\n', size(img_data)); +fprintf('LOCALITE NOTE: Original affine may contain rotations causing coordinate mismatches\n'); + +%% Print original affine +nii_original = niftiinfo(nii_in_path); +fprintf('Original affine:\n'); disp(nii_original.Transform.T); + +%% Create new diagonal affine matrix for Localite compatibility +% [sx 0 0 0 ] LOCALITE: world_x = voxel_x * sx +% [0 sy 0 0 ] LOCALITE: world_y = voxel_y * sy +% [0 0 sz 0 ] LOCALITE: world_z = voxel_z * sz +% [0 0 0 1 ] +affine_new = eye(4); +affine_new(1,1) = nii_original.PixelDimensions(1); % X scaling (mm/voxel) +affine_new(2,2) = nii_original.PixelDimensions(2); % Y scaling (mm/voxel) +affine_new(3,3) = nii_original.PixelDimensions(3); % Z scaling (mm/voxel) + +fprintf('New LOCALITE-compatible diagonal affine:\n'); disp(affine_new); +fprintf('Mapping: [voxel_x, voxel_y, voxel_z] β†’ [%.1f*vx, %.1f*vy, %.1f*vz] mm\n', ... + affine_new(1,1), affine_new(2,2), affine_new(3,3)); + +%% Create output NIfTI structure +nii_out = nii_in; +nii_out.Transform.T = affine_new; +nii_out.Filename = output_path; +nii_out.ImageSize = size(img_data); % Preserve dimensions + +%% Write output file +niftiwrite(img_data, output_path, nii_out); +gzip(output_path); % also save compressed file +fprintf('Saved LOCALITE-ready T1: %s\n', output_path); +fprintf('Ready for: Localite β†’ k-Wave β†’ PRESTUS simulation pipeline\n'); + +end diff --git a/functions/transform/convert_axisymmetric_to_2d.m b/functions/transform/convert_axisymmetric_to_2d.m index 8388373a..c81ce266 100644 --- a/functions/transform/convert_axisymmetric_to_2d.m +++ b/functions/transform/convert_axisymmetric_to_2d.m @@ -19,16 +19,14 @@ trans_pos_final = parameters.transducer(1).trans_pos; focus_pos_final = parameters.transducer(1).focus_pos; -trans_pos_final(2) = trans_pos_final(2)+parameters.grid_dims(2); -focus_pos_final(2) = focus_pos_final(2)+parameters.grid_dims(2); +trans_pos_final(2) = trans_pos_final(2)+parameters.grid.dims(2); +focus_pos_final(2) = focus_pos_final(2)+parameters.grid.dims(2); % convert radial dimension size into diameter -parameters.grid_dims(2) = parameters.grid_dims(2)*2; -parameters.default_grid_dims(2) = parameters.default_grid_dims(2)*2; +parameters.grid.dims(2) = parameters.grid.dims(2)*2; % shift radial to x dim -parameters.grid_dims = fliplr(parameters.grid_dims); -parameters.default_grid_dims = fliplr(parameters.default_grid_dims); +parameters.grid.dims = fliplr(parameters.grid.dims); trans_pos_final = fliplr(trans_pos_final); focus_pos_final = fliplr(focus_pos_final); segmentation = segmentation'; @@ -49,7 +47,7 @@ parameters.transducer(1).focus_pos = focus_pos_final; % set up kgrid again for eventual heating sim -kgrid = kWaveGrid(parameters.grid_dims(1), parameters.grid_step_mm/1e3, ... - parameters.grid_dims(2), parameters.grid_step_mm/1e3); +kgrid = kWaveGrid(parameters.grid.dims(1), parameters.grid.resolution_mm/1e3, ... + parameters.grid.dims(2), parameters.grid.resolution_mm/1e3); end \ No newline at end of file diff --git a/functions/transform/convert_axisymmetric_to_3d.m b/functions/transform/convert_axisymmetric_to_3d.m index cfc22684..c9dd56e2 100644 --- a/functions/transform/convert_axisymmetric_to_3d.m +++ b/functions/transform/convert_axisymmetric_to_3d.m @@ -18,28 +18,20 @@ %% convert positions from 2d radial to 3d -parameters.n_sim_dims = 3; - % shift positions to diameter location trans_pos_final = parameters.transducer(1).trans_pos; focus_pos_final = parameters.transducer(1).focus_pos; -trans_pos_final(2) = trans_pos_final(2)+parameters.grid_dims(2); -focus_pos_final(2) = focus_pos_final(2)+parameters.grid_dims(2); +trans_pos_final(2) = trans_pos_final(2)+parameters.grid.dims(2); +focus_pos_final(2) = focus_pos_final(2)+parameters.grid.dims(2); % convert: radial x axial -> diameter x diameter x axial -parameters.grid_dims(2) = parameters.grid_dims(2)*2; -parameters.grid_dims = parameters.grid_dims'; -parameters.grid_dims = [... - parameters.grid_dims(2), ... - parameters.grid_dims(2), ... - parameters.grid_dims(1)]; -parameters.default_grid_dims(2) = parameters.default_grid_dims(2)*2; -parameters.default_grid_dims = parameters.default_grid_dims'; -parameters.default_grid_dims = [... - parameters.default_grid_dims(2), ... - parameters.default_grid_dims(2), ... - parameters.default_grid_dims(1)]; +parameters.grid.dims(2) = parameters.grid.dims(2)*2; +parameters.grid.dims = parameters.grid.dims'; +parameters.grid.dims = [... + parameters.grid.dims(2), ... + parameters.grid.dims(2), ... + parameters.grid.dims(1)]; % convert: transducer and focus positions trans_pos_final = fliplr(trans_pos_final); @@ -52,18 +44,18 @@ parameters.transducer.focus_pos = focus_pos_final; %% set up 3D kgrid for follow-up heating simulations -kgrid = kWaveGrid(parameters.grid_dims(1), parameters.grid_step_mm/1e3, ... - parameters.grid_dims(2), parameters.grid_step_mm/1e3, ... - parameters.grid_dims(3), parameters.grid_step_mm/1e3); +kgrid = kWaveGrid(parameters.grid.dims(1), parameters.grid.resolution_mm/1e3, ... + parameters.grid.dims(2), parameters.grid.resolution_mm/1e3, ... + parameters.grid.dims(3), parameters.grid.resolution_mm/1e3); %% DEBUG: visually inspect % % plot center of transducer -% figure; imagesc(squeeze(sensor_data.p_max_all(:,floor(parameters.grid_dims(1)/2),:))) +% figure; imagesc(squeeze(sensor_data.p_max_all(:,floor(parameters.grid.dims(1)/2),:))) % % % plot each slice along the axial dimension % figure; -% for index = 1:parameters.grid_dims(3) +% for index = 1:parameters.grid.dims(3) % imagesc(squeeze(sensor_data.p_max_all(:,:,index))) % pause(0.5) % end diff --git a/functions/transform/convert_final_to_MNI_simnibs.m b/functions/transform/convert_final_to_MNI_simnibs.m index 9817fe48..656b1f1d 100644 --- a/functions/transform/convert_final_to_MNI_simnibs.m +++ b/functions/transform/convert_final_to_MNI_simnibs.m @@ -29,15 +29,15 @@ function convert_final_to_MNI_simnibs(path_to_input_img, m2m_folder, path_to_out end % Check if LD_LIBRARY_PATH is specified and construct the export command if needed - if isfield(parameters, 'ld_library_path') - ld_command = sprintf('export LD_LIBRARY_PATH="%s"; ', parameters.ld_library_path); + if isfield(parameters.hpc, 'ld_library_path') + ld_command = sprintf('export LD_LIBRARY_PATH="%s"; ', parameters.hpc.ld_library_path); else ld_command = ''; % No library linking required end % Run SimNIBS `subject2mni` command to transform the image to MNI space system(sprintf('%s%s/subject2mni --in %s --out %s --m2mpath %s --interpolation_order %d;', ... - ld_command, parameters.simnibs_bin_path, path_to_input_img, path_to_output_img, m2m_folder, options.interpolation_order)); + ld_command, parameters.startup.simnibs_bin_path, path_to_input_img, path_to_output_img, m2m_folder, options.interpolation_order)); % Handle unnecessary affix added by SimNIBS to the output filename if ~matches(path_to_output_img, '_MNI.nii.gz') diff --git a/functions/transform/mni2subject_coords_LDfix.m b/functions/transform/mni2subject_coords_LDfix.m index b333fae5..0e6f7340 100644 --- a/functions/transform/mni2subject_coords_LDfix.m +++ b/functions/transform/mni2subject_coords_LDfix.m @@ -37,15 +37,15 @@ fn_out = [tempname,'.csv']; % include LD fix -if isfield(parameters,'ld_library_path') - ld_command = sprintf('export LD_LIBRARY_PATH="%s"; ', parameters.ld_library_path); +if isfield(parameters.hpc,'ld_library_path') + ld_command = sprintf('export LD_LIBRARY_PATH="%s"; ', parameters.hpc.ld_library_path); else ld_command = ''; end % Run mni2subject_coords [status,result] = system(sprintf('%s%s/mni2subject_coords -m %s -s %s -o %s -t %s;', ... - ld_command, parameters.simnibs_bin_path, subdir, fn_in, fn_out, transformation_type)); + ld_command, parameters.startup.simnibs_bin_path, subdir, fn_in, fn_out, transformation_type)); % system([simnibs_cli_call('mni2subject_coords')... diff --git a/functions/transform/ras_to_grid.m b/functions/transform/ras_to_grid.m index 002a596a..4ded6991 100644 --- a/functions/transform/ras_to_grid.m +++ b/functions/transform/ras_to_grid.m @@ -6,15 +6,19 @@ % to grid (voxel) coordinates using the transformation matrix from a NIfTI header. % % Input: -% ras_pos - [1x3] array specifying the position in RAS coordinates. +% ras_pos - [3x1 | 1x3] array specifying the position in RAS coordinates. % nii_header - Struct containing the NIfTI header, including the transformation matrix. % % Output: % grid_pos - [1x3] array specifying the position in grid (voxel) coordinates. + if size(ras_pos, 2) == 3 && size(ras_pos, 1) == 1 + ras_pos = ras_pos'; % Convert 1x3 β†’ 3x1 + end + % Apply the inverse transformation matrix from the NIfTI header grid_pos = round(nii_header.Transform.T' \ [ras_pos; 1]); % Extract x, y, z components (ignore homogeneous coordinate) - grid_pos = grid_pos(1:3); + grid_pos = grid_pos(1:3)'; end \ No newline at end of file diff --git a/functions/transform/subject2mni_coords_LDfix.m b/functions/transform/subject2mni_coords_LDfix.m index 74dbb4b7..4a6786f4 100644 --- a/functions/transform/subject2mni_coords_LDfix.m +++ b/functions/transform/subject2mni_coords_LDfix.m @@ -37,15 +37,15 @@ fn_out = [tempname,'.csv']; % include LD fix -if isfield(parameters,'ld_library_path') - ld_command = sprintf('export LD_LIBRARY_PATH="%s"; ', parameters.ld_library_path); +if isfield(parameters.hpc,'ld_library_path') + ld_command = sprintf('export LD_LIBRARY_PATH="%s"; ', parameters.hpc.ld_library_path); else ld_command = ''; end % Run mni2subject_coords [status,result] = system(sprintf('%s%s/subject2mni_coords -m %s -s %s -o %s -t %s;', ... - ld_command, parameters.simnibs_bin_path, subdir, fn_in, fn_out, transformation_type)); + ld_command, parameters.startup.simnibs_bin_path, subdir, fn_in, fn_out, transformation_type)); % Check if call was successefull if status ~= 0 diff --git a/functions/transform/transform_coordinates.m b/functions/transform/transform_coordinates.m new file mode 100644 index 00000000..78822315 --- /dev/null +++ b/functions/transform/transform_coordinates.m @@ -0,0 +1,66 @@ +function output_pos = transform_coordinates(parameters, input_pos, input_cs, output_cs, nii_hdr) +% TRANSFORM_COORDINATES Transform input coordinates between coordinate systems +% +% Coord systems: 'mni' | 'ras_plus' | 'grid' +% Required varargin: +% 'mni' -> 'ras_plus'/'grid' +% 'ras_plus' -> 'grid': {nii_header} +% 'grid' -> 'ras_plus': {nii_header} + + arguments + parameters struct + input_pos double + input_cs string + output_cs string + nii_hdr struct = struct() % [Mandatory] for RAS+ and grid input + end + + assert(numel(input_pos) == 3, 'input_pos must be [1x3]'); + + switch input_cs + case 'mni' + m2m_path = fullfile(parameters.path.seg, sprintf('m2m_sub-%03i', parameters.subject_id)); + + % MNI -> subject RAS+ + disp("Mapping MNI to subject RAS+...") + ras_pos = mni2subject_coords_LDfix(input_pos, m2m_path, parameters); + + if strcmp(output_cs, 'ras_plus') + output_pos = ras_pos; + elseif strcmp(output_cs, 'grid') + output_pos = transform_coordinates(parameters, ras_pos, 'ras_plus', 'grid', nii_hdr); + else + error('MNI output_cs: ''ras_plus'' or ''grid'' only'); + end + + case 'ras_plus' + + if strcmp(output_cs, 'grid') + disp("Mapping RAS+ to subject grid...") + output_pos = round(transformPointsInverse(nii_hdr.Transform, input_pos)); + % alternative: + % output_pos = ras_to_grid(input_pos, nii_hdr); + else + error('RAS+ input, output_cs: ''grid'' only'); + end + + case 'grid' + + if strcmp(output_cs, 'ras_plus') + % Voxel -> RAS+ (forward affine) + disp("Mapping subject grid to subject RAS+...") + output_pos = transformPointsForward(nii_hdr.Transform, input_pos); + % alternative: + % tmp = nii_hdr.Transform.T * [input_pos(:); 1]; + % output_pos = tmp(1:3)'; + else + error('grid voxel input, output_cs: ''ras_plus'' only'); + end + + otherwise + error('input_cs: ''mni'' | ''ras_plus'' | ''grid'''); + end + + fprintf('%s [%.1f %.1f %.1f] -> %s [%.1f %.1f %.1f]\n', ... + input_cs, input_pos, output_cs, output_pos); +end \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml new file mode 100644 index 00000000..f77e1594 --- /dev/null +++ b/mkdocs.yaml @@ -0,0 +1,89 @@ +site_name: PRESTUS - Ultrasound Simulation Toolbox +docs_dir: documentation +site_description: 'PREprocessing & Simulations Toolbox for transcranial Ultrasound neuromodulation' +site_url: https://donders-institute.github.io/PRESTUS +site_author: Donders Institute for Brain, Cognition and Behaviour + +repo_url: https://github.com/Donders-Institute/PRESTUS +repo_name: Donders-Institute/PRESTUS + +theme: + name: material + icon: + logo: material/waves + features: + - navigation.sections + - navigation.expand + - content.code.copy + palette: + # Light mode toggle + - scheme: default + primary: black + accent: cyan + toggle: + icon: material/brightness-7 + name: Switch to dark mode + + # Dark mode toggle + - scheme: slate + primary: black + accent: cyan + toggle: + icon: material/brightness-4 + name: Switch to light mode + fonts: + text: Roboto + code: Roboto Mono + +nav: + - 🏠 Home: README.md + - πŸš€ Getting Started: + - Installation: doc_installation.md + - Getting started: doc_getting-started.md + - πŸ“– Documentation: + - Transducer Setup: doc_transducer.md + - Transducer Calibration: doc_calibration.md + - Transducer Placement: doc_placement.md + - Transducer Placement (Heuristic): doc_placement_heuristic.md + - Transducer Placement (Neuronavigation): doc_placement_neuronav.md + - Coordinate Systems: doc_coordinate_systems.md + - Head Processing: doc_preproc.md + - Medium Setup: doc_medium.md + - Outputs: doc_outputs.md + - πŸ”¬ Simulations: + - Acoustic: doc_simulations-acoustic.md + - Thermal: doc_simulations-thermal.md + - (pseudo)CT: doc_pseudoCT.md + - πŸ“š References: + - Parameters: doc_parameters.md + - Functions: doc_functions.md + - Modules: doc_modules.md + - πŸ–₯️ Advanced: + - Advanced Workflows: doc_advanced.md + - Backend: doc_backend.md + - HPC: doc_hpc.md + - Group: doc_group.md + - Testing: doc_testing.md + - πŸ†˜ Troubleshooting: doc_troubleshooting.md + - πŸ“‹ Community: + - Code of Conduct: CODE_OF_CONDUCT.md + - Contributing: CONTRIBUTING.md + - Changelog: CHANGELOG.md + +plugins: + - search + +markdown_extensions: + - abbr # Abbreviations + - admonition # !!! note, !!! warning boxes + - toc: # Table of contents + permalink: true + - tables # | tables | + - pymdownx.highlight # Code highlighting + - pymdownx.superfences # ``` fences with highlighting + - pymdownx.arithmatex: + generic: true # Enables $...$ and $$...$$ + +extra_javascript: + - https://polyfill.io/v3/polyfill.min.js?features=es6 + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js \ No newline at end of file diff --git a/simple_main.m b/simple_main.m index bf5a4c15..c57e5514 100644 --- a/simple_main.m +++ b/simple_main.m @@ -5,30 +5,18 @@ % Set medium, submit medium, subject number and configuration file clear; close all; -medium = 'layered'; % water or layered -submit = 'slurm'; % run scripts via 'matlab' (debugging) or via a job using 'slurm' (recommended) or 'qsub' -subject_id = 1; % subject number, if none, choose 1 % add paths addpath(genpath('functions')) -addpath(genpath('toolboxes')) +addpath(genpath('toolboxes')) parameters = load_parameters('tutorial_config.yaml'); % load the configuration file -parameters.simulation_medium = medium; -parameters.hpc_submit_medium = submit; +parameters.subject_id = 1; % subject number +parameters.simulation.medium = 'layered'; % water or layered +parameters.simulation.code_type = 'matlab_cpu'; +parameters.simulation.interactive = 1; % for interactive debugging +parameters.platform = 'matlab'; % or 'auto' +parameters.io.overwrite_files = 'always'; % overwrite? -if strcmp(parameters.hpc_submit_medium, 'matlab') == true - parameters.code_type = 'matlab_cpu'; - single_subject_pipeline(subject_id, parameters); -elseif strcmp(parameters.hpc_submit_medium, 'qsub') == true - parameters.interactive = 0; - parameters.overwrite_files = 'always'; - single_subject_pipeline_with_qsub(subject_id, parameters); -elseif strcmp(parameters.hpc_submit_medium, 'slurm') == true - parameters.interactive = 0; - parameters.overwrite_files = 'always'; - single_subject_pipeline_with_slurm(subject_id, parameters); -else - error('Submit medium does not correspond to available options.') -end \ No newline at end of file +prestus_pipeline_start(parameters) \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..8483f94c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,87 @@ +# PRESTUS Test Suite + +Tests are organised into two tiers. + +## Tier 1 β€” Unit tests (no data required) + +| File | What it covers | +|---|---| +| `test_helper.m` | Pure helper functions: `round_if_integer`, `find_min_factor`, `get_crop_dims`, `masked_max_3d`, `charm_seg_labels`, `get_flhm_center_position`, `cast_struct`, `get_xyz_mesh`, `zip_fields`, `subset_fields` | +| `test_thermal_parameters.m` | Timing arithmetic in `thermal_parameters`: duty cycle, pulse counts, step sums, validation errors | +| `test_transform.m` | Coordinate transforms: `ras_to_grid` with known affines, axisymmetric round-trip size checks | +| `test_load_parameters.m` | Config merging in `load_parameters`: default keys, override behaviour, affix sanitisation | +| `test_head_preprocessing.m` | Head preprocessing on synthetic segmentation volumes: `get_crop_dims`, `preproc_medium_mask`, `skull_fill_holes` | + +Run all unit tests from MATLAB: + +```matlab +run_all_tests % or run_all_tests('unit') +``` + +From the command line (CI): + +```bash +matlab -batch "run_all_tests" +``` + +--- + +## Tier 2 β€” Water pipeline test (no data required) + +File: `test_integration_water.m` + +Runs the full pipeline end-to-end with `simulation.medium = 'water'` on a small synthetic grid using CPU k-Wave. No MRI, no SimNIBS, no GPU needed. Typical runtime: 2–5 min. + +```matlab +run_all_tests('water') +``` + +Fixtures used: +- `fixtures/make_minimal_parameters.m` β€” builds a valid parameter struct for a single-element bowl transducer on a small grid +- `fixtures/make_synthetic_segmentation.m` β€” generates a concentric-shell segmentation volume for head preprocessing tests + +--- + +## Tier 3 β€” Integration / smoke tests (demo data required) + +File: `test_integration_pipeline.m` + +Tests are tagged by pipeline depth. Each level is a superset of the one above. + +| Tag | What it runs | Typical duration | Data needed | +|---|---|---| +| `smoke_config` | Config loading only | seconds | none | +| `smoke_head` | Head preprocessing up to medium masks | 1–5 min | SimNIBS m2m output | +| `smoke_acoustic` | Full acoustic simulation | 10–60 min | T1/T2 + m2m | +| `smoke_thermal` | Thermal simulation (reuses acoustic outputs) | 10–60 min | completed acoustic run | + +### Prerequisites + +1. Run SimNIBS `charm` segmentation for the demo subject first (or use `modules.segmentation_only = 1`). +2. Set environment variables before starting MATLAB: + +```bash +export PRESTUS_TEST_DATA=/path/to/demo/data # folder containing m2m_sub-001/ +export PRESTUS_DEMO_CONFIG=/path/to/tutorial_config.yaml # optional; defaults to configs/tutorial_config.yaml +``` + +### Running a specific level + +```matlab +run_all_tests('head') % unit + smoke_config + smoke_head +run_all_tests('acoustic') % ... + smoke_acoustic +run_all_tests('all') % full suite +``` + +Or run a single tag directly: + +```matlab +runtests('tests/test_integration_pipeline.m', 'Tag', 'smoke_head') +``` + +--- + +## Adding new tests + +- **New pure function** β†’ add a `methods (Test)` block to the relevant `test_*.m` file, or create a new one following the same `matlab.unittest.TestCase` pattern. +- **New pipeline stage** β†’ add a tagged method to `test_integration_pipeline.m` and a case in `run_all_tests.m` if it warrants its own level. diff --git a/tests/fixtures/make_minimal_parameters.m b/tests/fixtures/make_minimal_parameters.m new file mode 100644 index 00000000..7a53e928 --- /dev/null +++ b/tests/fixtures/make_minimal_parameters.m @@ -0,0 +1,78 @@ +function parameters = make_minimal_parameters(sim_path) +% MAKE_MINIMAL_PARAMETERS Build a minimal valid parameters struct for water simulations. +% +% parameters = make_minimal_parameters(sim_path) +% +% Returns a parameters struct sufficient to run a complete water-medium +% pipeline without any MRI data, SimNIBS, or GPU. Suitable for integration +% tests and CI. +% +% Input: +% sim_path - Directory for simulation outputs (created if missing). + + if nargin < 1 || isempty(sim_path) + sim_path = fullfile(tempdir, 'prestus_test_outputs'); + end + if ~exist(sim_path, 'dir'); mkdir(sim_path); end + + % --- Load defaults, then override --- + parameters = load_parameters(); + + % Identity + parameters.subject_id = 1; + parameters.platform = 'matlab'; + + % Paths + parameters.path.sim = sim_path; + parameters.path.anat = sim_path; % unused for water but must be set + parameters.path.seg = sim_path; + + % Simulation type + parameters.simulation.medium = 'water'; + parameters.simulation.code_type = 'matlab_cpu'; + parameters.simulation.interactive = 0; + parameters.simulation.precision = 'single'; + + % I/O + parameters.io.overwrite_files = 'always'; + parameters.io.overwrite_simnibs = 0; + parameters.io.save_matrices = 0; + parameters.io.save_heatingvideo = 0; + parameters.io.output_affix = '_test'; + + % Grid β€” small for speed + parameters.grid.resolution_mm = 1.0; + parameters.grid.default_dims = [72, 72, 128]; + parameters.grid.pml_size = 10; + parameters.grid.axisymmetric = 0; + parameters.grid.use_kWaveArray = 1; + + % Modules β€” water pipeline only, no thermal + parameters.modules.run_grid_setup = 1; + parameters.modules.run_medium_setup = 1; + parameters.modules.run_source_setup = 1; + parameters.modules.run_acoustic_sims = 1; + parameters.modules.run_acoustic_analysis = 1; + parameters.modules.run_heating_sims = 0; + parameters.modules.run_thermal_analysis = 0; + parameters.modules.run_nifti_creation = 1; + parameters.modules.run_posthoc_water_sims = 0; + parameters.modules.generate_report = 0; + + % Transducer β€” single-element bowl (Ernie tutorial values, scaled down) + parameters.transducer.n_elements = 1; + parameters.transducer.Elements_ID_mm = 0; + parameters.transducer.Elements_OD_mm = 32.0; + parameters.transducer.curv_radius_mm = 63.2; + parameters.transducer.dist_to_plane_mm = 52.38; + parameters.transducer.source_freq_hz = 250e3; + parameters.transducer.source_amp = 91590; + parameters.transducer.source_phase_deg = 0; + parameters.transducer.source_phase_rad = 0; + + % Transducer & focus position (centre of grid, pointing along z) + half = round(parameters.grid.default_dims / 2); + parameters.transducer.trans_pos = [half(1), half(2), 5]; + parameters.transducer.focus_pos = [half(1), half(2), half(3)]; + +end diff --git a/tests/fixtures/make_synthetic_segmentation.m b/tests/fixtures/make_synthetic_segmentation.m new file mode 100644 index 00000000..7eeb629c --- /dev/null +++ b/tests/fixtures/make_synthetic_segmentation.m @@ -0,0 +1,41 @@ +function seg = make_synthetic_segmentation(dims) +% MAKE_SYNTHETIC_SEGMENTATION Create a synthetic SimNIBS-style segmentation volume. +% +% seg = make_synthetic_segmentation(dims) +% +% Produces a [dims(1) x dims(2) x dims(3)] uint8 label volume with the +% following concentric shell structure (charm label values): +% +% 0 β€” background (water) +% 5 β€” skin (outer shell, 3 voxels thick) +% 4 β€” skull (2 voxels thick) +% 3 β€” CSF (2 voxels thick) +% 2 β€” grey matter (2 voxels thick) +% 1 β€” white matter (core) +% +% Useful for testing head preprocessing functions without real MRI data. + + if nargin < 1 + dims = [64, 64, 80]; + end + + seg = zeros(dims, 'uint8'); + c = round(dims / 2); % centre voxel + + % Radii (voxels) for each shell boundary + r_skin_outer = min(dims)/2 - 2; + r_skull_outer = r_skin_outer - 3; + r_csf_outer = r_skull_outer - 2; + r_gm_outer = r_csf_outer - 2; + r_wm_outer = r_gm_outer - 2; + + [X, Y, Z] = ndgrid(1:dims(1), 1:dims(2), 1:dims(3)); + r = sqrt((X - c(1)).^2 + (Y - c(2)).^2 + (Z - c(3)).^2); + + seg(r <= r_skin_outer) = 5; % skin + seg(r <= r_skull_outer) = 4; % skull + seg(r <= r_csf_outer) = 3; % CSF + seg(r <= r_gm_outer) = 2; % grey matter + seg(r <= r_wm_outer) = 1; % white matter + +end diff --git a/tests/run_all_tests.m b/tests/run_all_tests.m new file mode 100644 index 00000000..ed253349 --- /dev/null +++ b/tests/run_all_tests.m @@ -0,0 +1,85 @@ +%% RUN_ALL_TESTS Entry point for the PRESTUS test suite. +% +% Usage (from repo root or tests/ folder): +% +% run_all_tests % unit tests only (default) +% run_all_tests('unit') % same as above +% run_all_tests('water') % unit + water pipeline (no MRI/SimNIBS needed) +% run_all_tests('head') % water + smoke_config + smoke_head +% run_all_tests('acoustic')% head + smoke_acoustic +% run_all_tests('all') % full suite including thermal +% +% Environment variables (for integration levels >= 'head'): +% PRESTUS_TEST_DATA β€” path to folder with demo subject data & m2m_* output +% PRESTUS_DEMO_CONFIG β€” path to study config YAML (default: tutorial_config.yaml) +% +% The script exits with a non-zero status on any failure, making it +% suitable for CI: +% matlab -batch "run_all_tests" + +function run_all_tests(level) + + if nargin < 1 + level = 'unit'; + end + + % ---- Resolve paths ------------------------------------------------ + here = fileparts(mfilename('fullpath')); + repo_root = fileparts(here); + addpath(genpath(fullfile(repo_root, 'functions'))); + addpath(fullfile(repo_root, 'configs')); + + % ---- Select test files and tags by level -------------------------- + unit_files = { + fullfile(here, 'test_helper.m') + fullfile(here, 'test_thermal_parameters.m') + fullfile(here, 'test_transform.m') + fullfile(here, 'test_load_parameters.m') + fullfile(here, 'test_head_preprocessing.m') + }; + + integration_file = fullfile(here, 'test_integration_pipeline.m'); + water_test_file = fullfile(here, 'test_integration_water.m'); + + switch lower(level) + case 'unit' + suites = testsuite(unit_files); + case 'water' + suites = [testsuite(unit_files); testsuite(water_test_file)]; + case 'head' + suites = [testsuite(unit_files); ... + testsuite(water_test_file); ... + testsuite(integration_file, 'Tag', 'smoke_config'); ... + testsuite(integration_file, 'Tag', 'smoke_head')]; + case 'acoustic' + suites = [testsuite(unit_files); ... + testsuite(water_test_file); ... + testsuite(integration_file, 'Tag', 'smoke_config'); ... + testsuite(integration_file, 'Tag', 'smoke_head'); ... + testsuite(integration_file, 'Tag', 'smoke_acoustic')]; + case 'all' + suites = [testsuite(unit_files); testsuite(water_test_file); testsuite(integration_file)]; + otherwise + error('Unknown level ''%s''. Use: unit | head | acoustic | all', level); + end + + % ---- Run ---------------------------------------------------------- + runner = matlab.unittest.TestRunner.withTextOutput(); + results = runner.run(suites); + + % ---- Summary ------------------------------------------------------ + n_pass = sum([results.Passed]); + n_fail = sum([results.Failed]); + n_skip = sum([results.Incomplete]); + + fprintf('\n========================================\n'); + fprintf(' PRESTUS test suite β€” level: %s\n', upper(level)); + fprintf(' Passed: %d Failed: %d Skipped: %d\n', n_pass, n_fail, n_skip); + fprintf('========================================\n\n'); + + if n_fail > 0 + disp(table(results)); + error('run_all_tests:failures', '%d test(s) failed.', n_fail); + end + +end diff --git a/tests/test_head_preprocessing.m b/tests/test_head_preprocessing.m new file mode 100644 index 00000000..282bb003 --- /dev/null +++ b/tests/test_head_preprocessing.m @@ -0,0 +1,134 @@ +classdef test_head_preprocessing < matlab.unittest.TestCase +% TEST_HEAD_PREPROCESSING Unit tests for head preprocessing helpers. +% +% Tests functions that operate on synthetic segmentation volumes β€” +% no SimNIBS or real MRI data required. +% +% Run with: results = runtests('tests/test_head_preprocessing.m'); + + properties + params % minimal parameters struct + end + + methods (TestMethodSetup) + function build_params(tc) + p = struct(); + p.grid.resolution_mm = 0.5; + p.headmodel.smooth_method = 'gaussian'; + p.headmodel.smooth_fwhm_mm = 1; + p.headmodel.smooth_threshold_skull = 0.5; + p.headmodel.smooth_threshold_other = 0.5; + p.headmodel.smooth_properties = false; + p.headmodel.head_pad_mm = 0; + p.headmodel.csf_expansion = 5; + p.headmodel.skull_fill_method = 'rubberwrap'; + p.headmodel.skull_wrap_radius = 3; + p.headmodel.skull_wrap_visualize = 0; + p.pct.enabled = 0; + % Fields required by skull_fill_holes + p.subject_id = 1; + p.simulation.medium = 'layered'; + p.simulation.debug = 0; + p.io.output_affix = ''; + p.io.output_dir = tempdir; + p.io.debug_dir = tempdir; + % Layers matching charm labels + p.layers.water = [0, 3, 6, 9, 10]; + p.layers.brain = [1, 2]; + p.layers.skin = [5]; + p.layers.skull = [4]; + % Medium property fields β€” order matters: index used as label ID + p.medium_properties.water = struct(); + p.medium_properties.brain = struct(); + p.medium_properties.skin = struct(); + p.medium_properties.skull = struct(); % skull must be present; index = 4 + tc.params = p; + end + end + + % ------------------------------------------------------------------ % + %% get_crop_dims with realistic head-shaped mask + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'crop'}) + + function test_crop_dims_respects_margin(tc) + vol = zeros(40, 40, 40); + vol(10:30, 10:30, 10:30) = 1; + margin = 5; + [mn, mx, ~] = get_crop_dims(vol, margin); + tc.verifyEqual(mn, [5, 5, 5]); + tc.verifyEqual(mx, [35, 35, 35]); + end + + end + + % ------------------------------------------------------------------ % + %% preproc_medium_mask β€” synthetic segmentation + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'medium_mask'}) + + function test_medium_mask_labels_in_range(tc) + % Synthetic 10x10x10 segmentation: brain inside, skull shell, skin outer + seg = ones(10, 10, 10); % label 1 = wm everywhere + seg([1,10],:,:) = 5; % skin at borders + seg([2,9],:,:) = 4; % skull just inside skin + masks = preproc_medium_mask(seg, tc.params); + n_layers = numel(fieldnames(tc.params.medium_properties)); + tc.verifyGreaterThanOrEqual(min(masks(:)), 0); + tc.verifyLessThanOrEqual(max(masks(:)), n_layers); + end + + function test_medium_mask_brain_label_assigned(tc) + seg = zeros(10, 10, 10); + seg(4:7, 4:7, 4:7) = 1; % wm in centre + masks = preproc_medium_mask(seg, tc.params); + layer_names = fieldnames(tc.params.medium_properties); + brain_id = find(strcmp(layer_names, 'brain')); + tc.verifyTrue(any(masks(:) == brain_id), ... + 'Brain label should be assigned for wm voxels'); + end + + function test_medium_mask_size_preserved(tc) + seg = uint8(ones(12, 14, 16)); + masks = preproc_medium_mask(seg, tc.params); + tc.verifyEqual(size(masks), [12, 14, 16]); + end + + end + + % ------------------------------------------------------------------ % + %% skull_fill_holes β€” synthetic hollow skull + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'skull_fill'}) + + function test_skull_fill_closes_small_hole(tc) + tc.assumeTrue(license('test', 'Image_Toolbox'), ... + 'Image Processing Toolbox not available β€” skipping skull_fill_holes test'); + + % Build a synthetic segmentation with a skull shell and a punched hole + dims = [31, 31, 31]; + seg = zeros(dims, 'uint8'); + [X, Y, Z] = ndgrid(1:dims(1), 1:dims(2), 1:dims(3)); + c = (dims+1)/2; + R = sqrt((X-c(1)).^2 + (Y-c(2)).^2 + (Z-c(3)).^2); + seg(R >= 8 & R <= 10) = 4; % skull shell (charm label 4) + seg(R < 8) = 1; % brain inside + + % Build medium_masks matching medium_properties field order + skull_id = find(strcmp(fieldnames(tc.params.medium_properties), 'skull')); + medium_masks = zeros(dims); + medium_masks(seg == 4) = skull_id; + + % Punch a hole and count skull voxels before fill + medium_masks(1:5, 1:5, 1:5) = 0; + n_before = sum(medium_masks(:) > 0); + + p = tc.params; + p.headmodel.skull_fill_method = 'imclose'; + [filled, ~] = skull_fill_holes(p, medium_masks, round(c), seg); + tc.verifyGreaterThanOrEqual(sum(filled(:) > 0), n_before); + end + + end + +end diff --git a/tests/test_helper.m b/tests/test_helper.m new file mode 100644 index 00000000..659f494b --- /dev/null +++ b/tests/test_helper.m @@ -0,0 +1,246 @@ +classdef test_helper < matlab.unittest.TestCase +% TEST_HELPER Unit tests for functions/helper/ +% +% Run with: results = runtests('tests/test_helper.m'); + + % ------------------------------------------------------------------ % + %% round_if_integer + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'round_if_integer'}) + + function test_round_if_integer_exact(tc) + tc.verifyEqual(round_if_integer(3.0, 'err'), 3); + end + + function test_round_if_integer_within_tolerance(tc) + tc.verifyEqual(round_if_integer(3 + 1e-7, 'err'), 3); + end + + function test_round_if_integer_array(tc) + % round_if_integer asserts element-wise; call per element + tc.verifyEqual(round_if_integer(1.0, 'err'), 1); + tc.verifyEqual(round_if_integer(2.0, 'err'), 2); + end + + function test_round_if_integer_non_integer_errors(tc) + % Any error should be thrown for a non-integer input + tc.verifyError(@() round_if_integer(3.5, 'not an integer'), ?MException); + end + + end + + % ------------------------------------------------------------------ % + %% find_min_factor + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'find_min_factor'}) + + function test_find_min_factor_power_of_two(tc) + % 128 = 2^7; should win over nearby primes in range [120,130] + result = find_min_factor(120, 130); + tc.verifyEqual(result, 128); + end + + function test_find_min_factor_single_element_range(tc) + result = find_min_factor(16, 16); + tc.verifyEqual(result, 16); + end + + function test_find_min_factor_returns_value_in_range(tc) + lo = 50; hi = 60; + result = find_min_factor(lo, hi); + tc.verifyGreaterThanOrEqual(result, lo); + tc.verifyLessThanOrEqual(result, hi); + end + + end + + % ------------------------------------------------------------------ % + %% get_crop_dims + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'get_crop_dims'}) + + function test_get_crop_dims_single_voxel(tc) + img = zeros(10, 10, 10); + img(5, 5, 5) = 1; + margin = 2; + [mn, mx, sz] = get_crop_dims(img, margin); + tc.verifyEqual(mn, [3, 3, 3]); + tc.verifyEqual(mx, [7, 7, 7]); + tc.verifyEqual(sz, [5, 5, 5]); + end + + function test_get_crop_dims_cuboid_object(tc) + img = zeros(20, 20, 20); + img(3:7, 4:8, 5:9) = 1; + margin = 1; + [mn, mx, ~] = get_crop_dims(img, margin); + tc.verifyEqual(mn, [2, 3, 4]); + tc.verifyEqual(mx, [8, 9, 10]); + end + + function test_get_crop_dims_grid_size_consistent(tc) + img = zeros(15, 15, 15); + img(6:10, 6:10, 6:10) = 1; + [mn, mx, sz] = get_crop_dims(img, 0); + tc.verifyEqual(sz, mx - mn + 1); + end + + end + + % ------------------------------------------------------------------ % + %% masked_max_3d + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'masked_max_3d'}) + + function test_masked_max_3d_known_location(tc) + vol = zeros(5, 5, 5); + vol(2, 3, 4) = 99; + mask = ones(5, 5, 5); + [val, Ix, Iy, Iz] = masked_max_3d(vol, mask); + tc.verifyEqual(val, 99); + tc.verifyEqual([Ix, Iy, Iz], [2, 3, 4]); + end + + function test_masked_max_3d_mask_excludes_true_max(tc) + vol = zeros(5, 5, 5); + vol(1, 1, 1) = 100; % true max, but outside mask + vol(3, 3, 3) = 50; % max within mask + mask = zeros(5, 5, 5); + mask(3, 3, 3) = 1; + [val, ~, ~, ~] = masked_max_3d(vol, mask); + tc.verifyEqual(val, 50); + end + + end + + % ------------------------------------------------------------------ % + %% charm_seg_labels + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'charm_seg_labels'}) + + function test_charm_seg_labels_returns_struct(tc) + labels = charm_seg_labels(); + tc.verifyClass(labels, 'struct'); + end + + function test_charm_seg_labels_expected_fields(tc) + labels = charm_seg_labels(); + expected = {'wm','gm','csf','skull','skin','skull_cortical','skull_trabecular'}; + for i = 1:numel(expected) + tc.verifyTrue(isfield(labels, expected{i}), ... + sprintf('Missing field: %s', expected{i})); + end + end + + function test_charm_seg_labels_values(tc) + labels = charm_seg_labels(); + tc.verifyEqual(labels.wm, 1); + tc.verifyEqual(labels.gm, 2); + tc.verifyEqual(labels.csf, 3); + tc.verifyEqual(labels.skull, 4); + tc.verifyEqual(labels.skin, 5); + tc.verifyEqual(labels.skull_cortical, 7); + tc.verifyEqual(labels.skull_trabecular, 8); + end + + end + + % ------------------------------------------------------------------ % + %% get_flhm_center_position + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'get_flhm_center_position'}) + + function test_flhm_symmetric_gaussian(tc) + x = -10:0.1:10; + y = exp(-x.^2); % Gaussian centred at 0 + [centre, ~] = get_flhm_center_position(x, y); + tc.verifyEqual(centre, 0, 'AbsTol', 0.1); + end + + function test_flhm_shifted_gaussian(tc) + x = 0:0.1:20; + y = exp(-(x - 10).^2); % Gaussian centred at 10 + [centre, ~] = get_flhm_center_position(x, y); + tc.verifyEqual(centre, 10, 'AbsTol', 0.1); + end + + end + + % ------------------------------------------------------------------ % + %% cast_struct + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'cast_struct'}) + + function test_cast_struct_single_level(tc) + s.a = double(1.0); + s.b = double(2.0); + s = cast_struct(s, 'single'); + tc.verifyClass(s.a, 'single'); + tc.verifyClass(s.b, 'single'); + end + + function test_cast_struct_nested(tc) + s.x = double(1.0); + s.sub.y = double(2.0); + s = cast_struct(s, 'single'); + tc.verifyClass(s.x, 'single'); + tc.verifyClass(s.sub.y, 'single'); + end + + function test_cast_struct_preserves_non_numeric(tc) + s.label = 'hello'; + s.val = double(3.0); + s = cast_struct(s, 'single'); + tc.verifyClass(s.label, 'char'); % untouched + tc.verifyClass(s.val, 'single'); % cast + end + + end + + % ------------------------------------------------------------------ % + %% get_xyz_mesh + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'get_xyz_mesh'}) + + function test_get_xyz_mesh_size(tc) + img = zeros(3, 4, 5); + mesh = get_xyz_mesh(img); + tc.verifyEqual(size(mesh), [3*4*5, 3]); + end + + function test_get_xyz_mesh_range(tc) + img = zeros(3, 4, 5); + mesh = get_xyz_mesh(img); + tc.verifyEqual(min(mesh(:,1)), 1); + tc.verifyEqual(max(mesh(:,1)), 3); + tc.verifyEqual(max(mesh(:,3)), 5); + end + + end + + % ------------------------------------------------------------------ % + %% zip_fields / subset_fields + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'struct_utils'}) + + function test_zip_fields_order(tc) + s.alpha = 1; + s.beta = 2; + c = zip_fields(s); + tc.verifyEqual(c{1}, 'alpha'); + tc.verifyEqual(c{2}, 1); + tc.verifyEqual(c{3}, 'beta'); + tc.verifyEqual(c{4}, 2); + end + + function test_subset_fields_copies_requested(tc) + s.a = 1; s.b = 2; s.c = 3; + r = subset_fields(s, {'a', 'c'}); + tc.verifyTrue(isfield(r, 'a')); + tc.verifyTrue(isfield(r, 'c')); + tc.verifyFalse(isfield(r, 'b')); + end + + end + +end diff --git a/tests/test_integration_headpreproc.m b/tests/test_integration_headpreproc.m new file mode 100644 index 00000000..1dfdb6a2 --- /dev/null +++ b/tests/test_integration_headpreproc.m @@ -0,0 +1,105 @@ +classdef test_integration_headpreproc < matlab.unittest.TestCase +% TEST_INTEGRATION_HEADPREPROC Integration tests for head preprocessing functions. +% +% Tests preproc_medium_mask and skull processing using a synthetic +% concentric-shell segmentation. No MRI files needed. +% +% Run with: +% results = runtests('tests/test_integration_headpreproc.m'); + + properties + Parameters + Segmentation + end + + methods (TestMethodSetup) + function setup(tc) + tc.Parameters = make_minimal_parameters(); + tc.Segmentation = make_synthetic_segmentation([64, 64, 80]); + + % Switch to layered for head preprocessing tests + tc.Parameters.simulation.medium = 'layered'; + + % Minimal headmodel settings + tc.Parameters.headmodel.smooth_method = 'gaussian'; + tc.Parameters.headmodel.smooth_fwhm_mm = 1; + tc.Parameters.headmodel.smooth_threshold_skull = 0.5; + tc.Parameters.headmodel.smooth_threshold_other = 0.5; + tc.Parameters.headmodel.smooth_properties = false; + tc.Parameters.headmodel.skull_fill_method = 'imclose'; % faster than rubberwrap for tests + + % Single-skull layer setup (no cortical/trabecular split) + tc.Parameters.layers.water = [0, 3, 6, 9, 10]; + tc.Parameters.layers.brain = [1, 2]; + tc.Parameters.layers.skin = [5]; + tc.Parameters.layers.skull = [4]; + end + end + + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'headpreproc', 'medium_mask'}) + + function test_medium_mask_returns_correct_size(tc) + masks = preproc_medium_mask(tc.Segmentation, tc.Parameters); + tc.verifyEqual(size(masks), size(tc.Segmentation)); + end + + function test_medium_mask_has_expected_labels(tc) + masks = preproc_medium_mask(tc.Segmentation, tc.Parameters); + unique_labels = unique(masks); + % Should contain at least water (0 or 1) and other tissues + tc.verifyGreaterThanOrEqual(numel(unique_labels), 2); + end + + function test_medium_mask_no_unlabelled_interior(tc) + % All voxels inside the skin shell should be assigned a non-water label + masks = preproc_medium_mask(tc.Segmentation, tc.Parameters); + medium_labels = fieldnames(tc.Parameters.medium_properties); + i_water = find(strcmp(medium_labels, 'water')); + interior = tc.Segmentation > 0; % inside skin sphere + interior_masks = masks(interior); + frac_water = mean(interior_masks == i_water); + % Allow some water voxels at boundaries (smoothing), but not majority + tc.verifyLessThan(frac_water, 0.5, ... + 'More than 50%% of interior voxels assigned as water β€” check layer mapping'); + end + + end + + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'headpreproc', 'skull'}) + + function test_skull_fill_holes_runs(tc) + % skull_fill_holes should not error on synthetic skull + skull_mask = ismember(tc.Segmentation, tc.Parameters.layers.skull); + tc.verifyWarningFree(@() skull_fill_holes(skull_mask, tc.Parameters)); + end + + function test_skull_fill_holes_output_size(tc) + skull_mask = ismember(tc.Segmentation, tc.Parameters.layers.skull); + filled = skull_fill_holes(skull_mask, tc.Parameters); + tc.verifyEqual(size(filled), size(skull_mask)); + end + + function test_skull_fill_holes_non_empty(tc) + skull_mask = ismember(tc.Segmentation, tc.Parameters.layers.skull); + filled = skull_fill_holes(skull_mask, tc.Parameters); + tc.verifyGreaterThan(sum(filled(:)), 0, 'Filled skull should contain non-zero voxels'); + end + + end + + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'headpreproc', 'crop'}) + + function test_get_crop_dims_on_segmentation(tc) + brain_mask = tc.Segmentation > 0; + margin = 5; + [mn, mx, sz] = get_crop_dims(brain_mask, margin); + tc.verifyEqual(sz, mx - mn + 1); + tc.verifyGreaterThan(min(sz), 0); + end + + end + +end diff --git a/tests/test_integration_pipeline.m b/tests/test_integration_pipeline.m new file mode 100644 index 00000000..322bbc48 --- /dev/null +++ b/tests/test_integration_pipeline.m @@ -0,0 +1,193 @@ +classdef test_integration_pipeline < matlab.unittest.TestCase +% TEST_INTEGRATION_PIPELINE Smoke/integration tests for full and partial pipeline runs. +% +% These tests require demo input data (Ernie template or equivalent). +% They are intentionally coarse: they verify that the pipeline reaches +% expected output checkpoints without crashing, rather than checking +% numerical accuracy. +% +% Test levels (controlled by TestTags): +% 'smoke_config' β€” config loading only (seconds; no I/O) +% 'smoke_head' β€” head preprocessing up to medium masks (~1–5 min) +% 'smoke_acoustic' β€” full acoustic simulation (minutes–hours; requires GPU/CPU) +% 'smoke_thermal' β€” thermal simulation (minutes–hours; requires acoustic outputs) +% +% Run a specific level: +% runtests('tests/test_integration_pipeline.m', 'Tag', 'smoke_config') +% +% Run all integration tests: +% runtests('tests/test_integration_pipeline.m') +% +% Prerequisites: +% - Set environment variable PRESTUS_TEST_DATA to the folder containing +% the demo subject's T1/T2 and SimNIBS m2m_* output. +% - Set PRESTUS_DEMO_CONFIG to the path of the demo study config YAML. +% (Defaults to configs/tutorial_config.yaml if not set.) + + properties (Constant) + DEMO_SUBJECT_ID = 1 + end + + properties + params % loaded parameter struct for this subject + repo_root % absolute path to repo root + data_root % path to test data (from env var or skipped) + end + + methods (TestClassSetup) + function resolve_paths(tc) + test_dir = fileparts(mfilename('fullpath')); + tc.repo_root = fileparts(test_dir); + addpath(genpath(fullfile(tc.repo_root, 'functions'))); + addpath(fullfile(tc.repo_root, 'configs')); + end + end + + methods (TestMethodSetup) + function load_demo_params(tc) + % Resolve demo data root β€” skip gracefully if not available + data_env = getenv('PRESTUS_TEST_DATA'); + if isempty(data_env) || ~isfolder(data_env) + tc.assumeFail('PRESTUS_TEST_DATA not set or not a valid folder. Skipping integration tests.'); + end + tc.data_root = data_env; + + cfg_env = getenv('PRESTUS_DEMO_CONFIG'); + if isempty(cfg_env) + cfg_env = fullfile(tc.repo_root, 'configs', 'tutorial_config.yaml'); + end + tc.assumeTrue(isfile(cfg_env), ... + sprintf('Demo config not found: %s', cfg_env)); + + tc.params = load_parameters(cfg_env); + tc.params.subject_id = tc.DEMO_SUBJECT_ID; + tc.params.simulation.interactive = 0; + tc.params.io.overwrite_files = 'always'; + tc.params.platform = 'matlab'; + end + end + + % ------------------------------------------------------------------ % + %% Level 1: config loading (no data needed β€” prerequisite always runs) + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'smoke_config'}) + + function test_tutorial_config_loads(tc) + cfg = fullfile(tc.repo_root, 'configs', 'tutorial_config.yaml'); + tc.assumeTrue(isfile(cfg)); + p = load_parameters(cfg); + tc.verifyClass(p, 'struct'); + tc.verifyTrue(isfield(p, 'transducer')); + end + + end + + % ------------------------------------------------------------------ % + %% Level 2: head preprocessing (segmentation + medium masks) + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'smoke_head'}) + + function test_segmentation_file_exists(tc) + % Verify the expected charm output is present for this subject + m2m = fullfile(tc.data_root, ... + sprintf('m2m_sub-%03d', tc.DEMO_SUBJECT_ID), 'final_tissues.nii.gz'); + tc.verifyTrue(isfile(m2m), ... + 'SimNIBS charm output not found β€” run segmentation first.'); + end + + function test_preproc_head_runs(tc) + % Run up to and including head preprocessing; check output struct + p = tc.params; + p.modules.run_medium_setup = 0; + p.modules.run_source_setup = 0; + p.modules.run_acoustic_sims = 0; + p.modules.run_acoustic_analysis = 0; + p.modules.run_heating_sims = 0; + p.modules.run_nifti_creation = 0; + p.modules.generate_report = 0; + p.modules.run_posthoc_water_sims = 0; + + % Call preproc directly to avoid full pipeline path setup + [p_out, medium_masks, segmentation, ~, ~] = preproc_head(p); + tc.verifyNotEmpty(medium_masks); + tc.verifyNotEmpty(segmentation); + tc.verifyTrue(isfield(p_out, 'grid')); + tc.verifyTrue(isfield(p_out.grid, 'dims')); + end + + function test_medium_masks_label_range(tc) + p = tc.params; + [~, medium_masks, ~, ~, ~] = preproc_head(p); + n_layers = numel(fieldnames(p.medium_properties)); + tc.verifyGreaterThanOrEqual(min(medium_masks(:)), 0); + tc.verifyLessThanOrEqual(max(medium_masks(:)), n_layers); + end + + end + + % ------------------------------------------------------------------ % + %% Level 3: full acoustic pipeline + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'smoke_acoustic'}) + + function test_acoustic_output_file_created(tc) + p = tc.params; + p.modules.run_heating_sims = 0; + p.modules.run_thermal_analysis = 0; + p.modules.run_posthoc_water_sims = 0; + p.modules.generate_report = 0; + + prestus_pipeline(p); + + expected_file = fullfile(p.path.sim, ... + sprintf('sub-%03d', tc.DEMO_SUBJECT_ID), ... + sprintf('sub-%03d_%s%s_intensity_orig_coord.nii.gz', ... + tc.DEMO_SUBJECT_ID, p.simulation.medium, p.io.output_affix)); + tc.verifyTrue(isfile(expected_file), ... + 'Acoustic output NIfTI not created.'); + end + + function test_output_csv_created(tc) + p = tc.params; + p.modules.run_heating_sims = 0; + p.modules.run_posthoc_water_sims = 0; + p.modules.generate_report = 0; + + prestus_pipeline(p); + + csv_pattern = fullfile(p.path.sim, ... + sprintf('sub-%03d', tc.DEMO_SUBJECT_ID), ... + sprintf('sub-%03d_%s_output_table*.csv', ... + tc.DEMO_SUBJECT_ID, p.simulation.medium)); + hits = dir(csv_pattern); + tc.verifyGreaterThan(numel(hits), 0, 'Output CSV not found.'); + end + + end + + % ------------------------------------------------------------------ % + %% Level 4: thermal pipeline (requires completed acoustic run) + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'smoke_thermal'}) + + function test_thermal_output_file_created(tc) + p = tc.params; + p.modules.run_acoustic_sims = 0; % reuse cached acoustic output + p.modules.run_heating_sims = 1; + p.modules.run_posthoc_water_sims = 0; + p.modules.generate_report = 0; + p.io.overwrite_files = 'never'; % preserve acoustics + + prestus_pipeline(p); + + expected_file = fullfile(p.path.sim, ... + sprintf('sub-%03d', tc.DEMO_SUBJECT_ID), ... + sprintf('sub-%03d_%s%s_cem43_orig_coord.nii.gz', ... + tc.DEMO_SUBJECT_ID, p.simulation.medium, p.io.output_affix)); + tc.verifyTrue(isfile(expected_file), ... + 'Thermal (CEM43) output NIfTI not created.'); + end + + end + +end diff --git a/tests/test_integration_water.m b/tests/test_integration_water.m new file mode 100644 index 00000000..a245847e --- /dev/null +++ b/tests/test_integration_water.m @@ -0,0 +1,74 @@ +classdef test_integration_water < matlab.unittest.TestCase +% TEST_INTEGRATION_WATER End-to-end water pipeline integration test. +% +% Runs the full prestus_pipeline with simulation.medium = 'water' using a +% single-element bowl transducer on a small grid. No MRI, no SimNIBS, no GPU. +% Expected runtime: ~2–5 min on CPU. +% +% Run with: +% results = runtests('tests/test_integration_water.m'); + + properties + SimPath % temporary output directory + Parameters + end + + methods (TestMethodSetup) + function setup(tc) + tc.SimPath = fullfile(tempdir, sprintf('prestus_water_test_%s', ... + datestr(now, 'yyyymmdd_HHMMSS'))); + mkdir(tc.SimPath); + tc.Parameters = make_minimal_parameters(tc.SimPath); + end + end + + methods (TestMethodTeardown) + function teardown(tc) + if exist(tc.SimPath, 'dir') + rmdir(tc.SimPath, 's'); + end + end + end + + % ------------------------------------------------------------------ % + methods (Test, TestTags = {'water', 'pipeline'}) + + function test_pipeline_runs_without_error(tc) + % Pipeline should complete without throwing + tc.verifyWarningFree(@() prestus_pipeline(tc.Parameters)); + end + + function test_output_csv_created(tc) + prestus_pipeline(tc.Parameters); + csv_files = dir(fullfile(tc.SimPath, '**', '*.csv')); + tc.verifyNotEmpty(csv_files, 'Expected at least one CSV output table'); + end + + function test_nifti_output_created(tc) + prestus_pipeline(tc.Parameters); + nii_files = dir(fullfile(tc.SimPath, '**', '*.nii*')); + tc.verifyNotEmpty(nii_files, 'Expected at least one NIfTI output file'); + end + + function test_intensity_positive(tc) + % Read the output CSV and check Isppa > 0 + prestus_pipeline(tc.Parameters); + csv_files = dir(fullfile(tc.SimPath, '**', '*output_table*.csv')); + tc.verifyNotEmpty(csv_files); + tbl = readtable(fullfile(csv_files(1).folder, csv_files(1).name)); + intensity_col = tbl{:, contains(tbl.Properties.VariableNames, 'Isppa', 'IgnoreCase', true)}; + tc.verifyGreaterThan(intensity_col(1), 0, 'Isppa (I_sppa) should be positive'); + end + + function test_axisymmetric_water_runs(tc) + % 2D axisymmetric should also complete without error + p = tc.Parameters; + p.grid.axisymmetric = 1; + p.grid.default_dims = [1, 72, 128]; % 2D: first dim = 1 + p.io.output_affix = '_test_2d'; + tc.verifyWarningFree(@() prestus_pipeline(p)); + end + + end + +end diff --git a/tests/test_load_parameters.m b/tests/test_load_parameters.m new file mode 100644 index 00000000..02cea304 --- /dev/null +++ b/tests/test_load_parameters.m @@ -0,0 +1,112 @@ +classdef test_load_parameters < matlab.unittest.TestCase +% TEST_LOAD_PARAMETERS Unit tests for load_parameters() config merging. +% +% These tests operate on default_config.yaml only (no SimNIBS/k-Wave needed). +% Run with: results = runtests('tests/test_load_parameters.m'); + + methods (TestClassSetup) + function add_prestus_path(tc) + % Resolve repo root relative to this test file and add functions/ + test_dir = fileparts(mfilename('fullpath')); + repo_root = fileparts(test_dir); + addpath(genpath(fullfile(repo_root, 'functions'))); + % load_parameters expects to find default_config.yaml on the path + addpath(fullfile(repo_root, 'configs')); + end + end + + methods (Test, TestTags = {'defaults'}) + + function test_default_config_loads(tc) + % Must not error; interactive guard bypassed by non-desktop mode + p = load_parameters(); + tc.verifyClass(p, 'struct'); + end + + function test_top_level_keys_present(tc) + p = load_parameters(); + expected = {'simulation','path','io','modules','hpc','grid', ... + 'headmodel','layers','medium_properties','timing','thermal','analysis'}; + for i = 1:numel(expected) + tc.verifyTrue(isfield(p, expected{i}), ... + sprintf('Missing top-level key: %s', expected{i})); + end + end + + function test_simulation_defaults(tc) + p = load_parameters(); + tc.verifyTrue(ischar(p.simulation.medium) || isstring(p.simulation.medium), ... + 'simulation.medium should be a string'); + tc.verifyTrue(contains(string(p.simulation.medium), 'layered'), ... + sprintf('Expected medium to contain ''layered'', got: %s', p.simulation.medium)); + tc.verifyTrue(contains(string(p.simulation.code_type), 'matlab_gpu'), ... + sprintf('Expected code_type to contain ''matlab_gpu'', got: %s', p.simulation.code_type)); + % precision may be 'single' or "'single'" depending on YAML parser + tc.verifyTrue(contains(string(p.simulation.precision), 'single'), ... + sprintf('Expected precision to contain ''single'', got: %s', p.simulation.precision)); + % interactive may be logical false or numeric 0 + tc.verifyTrue(~p.simulation.interactive); + end + + function test_modules_defaults(tc) + p = load_parameters(); + tc.verifyEqual(p.modules.run_grid_setup, 1); + tc.verifyEqual(p.modules.run_heating_sims, 0); + tc.verifyEqual(p.modules.segmentation_only, 0); + tc.verifyEqual(p.modules.generate_report, 1); + end + + function test_grid_defaults(tc) + p = load_parameters(); + tc.verifyEqual(p.grid.resolution_mm, 0.5); + tc.verifyEqual(p.grid.pml_size, 10); + tc.verifyEqual(p.grid.use_kWaveArray, 1); + end + + function test_hpc_defaults(tc) + p = load_parameters(); + tc.verifyEqual(p.hpc.max_wait_checks, 540); + tc.verifyFalse(p.hpc.wait_for_job); + end + + end + + methods (Test, TestTags = {'merging'}) + + function test_study_config_overrides_default(tc) + % Supply a minimal struct override + override.simulation.medium = 'water'; + override.simulation.interactive = 0; % keep non-desktop safe + p = load_parameters(override); + tc.verifyEqual(p.simulation.medium, 'water'); + end + + function test_override_does_not_clobber_unrelated_fields(tc) + override.simulation.medium = 'water'; + override.simulation.interactive = 0; + p = load_parameters(override); + % grid fields untouched + tc.verifyEqual(p.grid.resolution_mm, 0.5); + end + + end + + methods (Test, TestTags = {'sanitization'}) + + function test_output_affix_sanitized(tc) + override.io.output_affix = 'bad/chars!here'; + override.simulation.interactive = 0; + p = load_parameters(override); + tc.verifyMatches(p.io.output_affix, '^[a-zA-Z0-9_]+$'); + end + + function test_output_affix_valid_passthrough(tc) + override.io.output_affix = 'run_01'; + override.simulation.interactive = 0; + p = load_parameters(override); + tc.verifyEqual(p.io.output_affix, 'run_01'); + end + + end + +end diff --git a/tests/test_thermal_parameters.m b/tests/test_thermal_parameters.m new file mode 100644 index 00000000..ba3ef6fb --- /dev/null +++ b/tests/test_thermal_parameters.m @@ -0,0 +1,80 @@ +classdef test_thermal_parameters < matlab.unittest.TestCase +% TEST_THERMAL_PARAMETERS Unit tests for thermal_parameters() +% +% Run with: results = runtests('tests/test_thermal_parameters.m'); + + methods (TestMethodSetup) + function setup(tc) + % Minimal valid timing struct used as base for all tests + tc.base = struct(); + tc.base.timing.pd = 0.02; % 20 ms pulse + tc.base.timing.pri = 0.1; % 100 ms PRI β†’ DC = 0.2 + tc.base.timing.ptd = 1.0; % 1 s pulse train + tc.base.timing.pt_timestep = 0.02; + tc.base.timing.ptri = 2.0; % 2 s PTRI + tc.base.timing.ptrd = 10.0; % 10 s total + tc.base.timing.post_pt_timestep = 1.0; + tc.base.timing.post_ptri_dur = 0.0; + tc.base.timing.equal_step_duration = 0; + tc.base.modules.run_heating_sims = 1; + tc.base.thermal = struct(); % thermal_parameters also reads parameters.thermal + end + end + + properties + base % base parameter struct + end + + methods (Test, TestTags = {'duty_cycle'}) + + function test_duty_cycle(tc) + p = thermal_parameters(tc.base, true); + tc.verifyEqual(p.dc, tc.base.timing.pd / tc.base.timing.pri, 'AbsTol', 1e-10); + end + + function test_prf(tc) + p = thermal_parameters(tc.base, true); + tc.verifyEqual(p.prf, 1 / tc.base.timing.pri, 'AbsTol', 1e-10); + end + + end + + methods (Test, TestTags = {'pulse_counts'}) + + function test_pulses_per_train(tc) + p = thermal_parameters(tc.base, true); + expected = round(tc.base.timing.ptd / tc.base.timing.pri); + tc.verifyEqual(p.n_pulses_per_pt, expected); + end + + function test_ptri_repetitions(tc) + p = thermal_parameters(tc.base, true); + expected_reps = round(tc.base.timing.ptrd / tc.base.timing.ptri); + tc.verifyEqual(p.n_ptri_reps, expected_reps); + end + + end + + methods (Test, TestTags = {'step_counts'}) + + function test_on_off_steps_sum_to_pri(tc) + % ON + OFF step durations should sum to PRI within one pulse + p = thermal_parameters(tc.base, true); + on_dur = p.pt_on_steps_n * p.pt_on_steps_dur; + off_dur = p.pt_off_steps_n * p.pt_off_steps_dur; + tc.verifyEqual(on_dur + off_dur, tc.base.timing.pri, 'AbsTol', 1e-9); + end + + end + + methods (Test, TestTags = {'validation'}) + + function test_non_integer_pulses_per_train_errors(tc) + p = tc.base; + p.timing.ptd = 0.15; % 0.15 / 0.1 = 1.5 β†’ not integer + tc.verifyError(@() thermal_parameters(p, true), ''); + end + + end + +end diff --git a/tests/test_transform.m b/tests/test_transform.m new file mode 100644 index 00000000..c923e2c3 --- /dev/null +++ b/tests/test_transform.m @@ -0,0 +1,53 @@ +classdef test_transform < matlab.unittest.TestCase +% TEST_TRANSFORM Unit tests for functions/transform/ +% +% Run with: results = runtests('tests/test_transform.m'); + + methods (Test, TestTags = {'ras_to_grid'}) + + function test_ras_to_grid_identity_affine(tc) + % With identity affine, RAS == grid (homogeneous coords) + hdr.Transform.T = eye(4); + grid = ras_to_grid([5, 6, 7], hdr); + tc.verifyEqual(grid, [5, 6, 7]); + end + + function test_ras_to_grid_translation(tc) + % Affine = shift by [10,20,30] + T = eye(4); + T(1:3, 4) = [10; 20; 30]; + hdr.Transform.T = T'; % niftiinfo stores T transposed + grid = ras_to_grid([15, 26, 37], hdr); + tc.verifyEqual(grid, [5, 6, 7]); + end + + function test_ras_to_grid_column_input(tc) + hdr.Transform.T = eye(4); + grid = ras_to_grid([1; 2; 3], hdr); + tc.verifyEqual(grid, [1, 2, 3]); + end + + end + + methods (Test, TestTags = {'axisymmetric'}) + + function test_radial_expand_output_size(tc) + % radialExpand2DTo3D(data2D) with [C x Nr] input + % returns [2*Nr x 2*Nr x C] + C = 10; Nr = 20; + data2d = rand(C, Nr); + vol3d = radialExpand2DTo3D(data2d); + tc.verifyEqual(size(vol3d), [2*Nr, 2*Nr, C]); + end + + function test_radial_expand_symmetry(tc) + % Output should be symmetric: vol3d(i,j,c) == vol3d(j,i,c) + C = 2; Nr = 10; + data2d = rand(C, Nr); + vol3d = radialExpand2DTo3D(data2d); + tc.verifyEqual(vol3d(:,:,1), vol3d(:,:,1)', 'AbsTol', 1e-10); + end + + end + +end