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
[](https://github.com/Donders-Institute/PRESTUS/graphs/contributors)
-[](#contributors)
+
+### Plot heuristic transducer position
+
+PRESTUS generates an overview of the selected transducer placement:
+
+
+
+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.
+
+
+
+### 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
+
+
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
+
+
+
+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
sub-%03d
', 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('
Medium
%s Layered (Realistic Head)
', html_escape(medium))];
else
html = [html sprintf('
Medium
%s Water / Free-field
', html_escape(medium))];
@@ -355,7 +364,7 @@
end
html = [html sprintf('
Generated
%s
', datestr(now, 'yyyy-mm-dd HH:MM:SS'))];
if isfield(parameters, 'output_dir')
- html = [html sprintf('
Output dir
%s
', html_escape(parameters.output_dir))];
+ html = [html sprintf('
Output dir
%s
', html_escape(parameters.io.output_dir))];
end
html = [html '
'];
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('', 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('', 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('', 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