From cbb19875e1791c6d9d6286ebef17c42bf56ec2e2 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 3 Mar 2026 16:17:44 +0100 Subject: [PATCH 001/144] [param, calibration] separate focal distance from exit and bowl additional adjustments: --- configs/calibration_config.yaml | 3 +- documentation/PRESTUS_intro_tutorial.md | 12 ++-- documentation/README.md | 2 +- documentation/doc_config.md | 8 +-- examples/calibration_standalone.m | 59 +++++++++---------- .../calibration/calibration_transducer.m | 2 +- .../calibration/compute_oneil_solution.m | 22 ++++--- .../extract_real_intensity_profile.m | 28 ++++----- functions/calibration/perform_global_search.m | 11 +++- .../phase_optimization_annulus_full_curve.m | 19 ++++-- .../scale_real_intensity_profile.m | 2 +- functions/core/generate_simulation_report.m | 2 +- functions/core/print_parameter_summary.m | 3 +- .../neuronav_convert_trigger_to_voxels.m | 2 +- .../neuronav/position_transducer_localite.m | 6 +- functions/source/grid_axisymmetry.m | 2 - functions/source/grid_transducer_location.m | 2 +- .../transducer/focal_distance_calculation.m | 51 +++++++++++----- functions/transducer/transducer_positioning.m | 2 +- .../transform/convert_axisymmetric_to_2d.m | 2 - .../transform/convert_axisymmetric_to_3d.m | 6 -- 21 files changed, 135 insertions(+), 111 deletions(-) diff --git a/configs/calibration_config.yaml b/configs/calibration_config.yaml index bf9d445c..cf0b08ad 100644 --- a/configs/calibration_config.yaml +++ b/configs/calibration_config.yaml @@ -13,10 +13,9 @@ save_in_calibration_folder: true # Boolean flag to save outputs in general output folder or simulation-specific folder. If TRUE, simulations are run in calibration output directory; results are appended to existing calibration for this equipment instead of overwritten. skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts optmethod: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) - weights: 1 # Weighting of the original profile during fitting (towards 0 = narrower Gaussian; 1 = equal weighting) + weights: 3 # Weighting of the original profile during fitting (0 = equal weighting, >1 increasingly narrow Gaussian FWHM) seed: 251024 # Random seed for optimization addEPdistance: 1 # Append distance from transducer bowl to exit plane (if zero point in provided profiles = exit plane, != transducer bowl) - interpolateToEP: 1 # Interpolate values between bowl and exit plane [default: add initial value in empirical measurements] force_kwavearray: 1 # Force to run free-water simulations with kwavearray? axisymmetric2D: 0 # [EXPERIMENTAL; default: 0] Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency diff --git a/documentation/PRESTUS_intro_tutorial.md b/documentation/PRESTUS_intro_tutorial.md index 932d187b..6d89f68d 100644 --- a/documentation/PRESTUS_intro_tutorial.md +++ b/documentation/PRESTUS_intro_tutorial.md @@ -204,7 +204,7 @@ hold on plot(axial_position-(parameters.transducer.trans_pos(3)-1)*0.5, pred_axial_pressure.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4,'--'); plot(real_profile(:,1),real_profile(:,2)) hold off -xline(parameters.expected_focal_distance_mm, '--'); +xline(parameters.expected_focal_distance_bowl, '--'); legend('Analytic solution','Simulated results','Real profile') title('Pressure along the beam axis') ``` @@ -235,7 +235,7 @@ So how to find the settings for the simulations that match the desired pressure gs = GlobalSearch; %opt_velocity = desired_pressure/max_pressure*velocity; -%optimize_phases = @(phases) phase_optimization_annulus(phases, parameters, velocity, axial_position, parameters.expected_focal_distance_mm); +%optimize_phases = @(phases) phase_optimization_annulus(phases, parameters, velocity, axial_position, parameters.expected_focal_distance_bowl); optimize_phases = @(phases_and_velocity) phase_optimization_annulus_full_curve(phases_and_velocity(1:(parameters.transducer.n_elements - 1)), parameters, phases_and_velocity(parameters.transducer.n_elements),... real_profile(:,1), real_profile(:,2)); @@ -297,7 +297,7 @@ hold on plot(axial_position, p_axial_oneil_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); plot(real_profile(:,1),real_profile(:,2)) hold off -xline(parameters.expected_focal_distance_mm, '--'); +xline(parameters.expected_focal_distance_bowl, '--'); yline(30, '--'); legend('Original simulation', sprintf('Optimized to match the real profile'),'Real profile') title('Pressure along the beam axis') @@ -395,10 +395,10 @@ plot(sim_res_axial_position, ... pred_axial_pressure_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); plot(real_profile(:,1),real_profile(:,2)) hold off -xline(opt_res.parameters.expected_focal_distance_mm, '--'); +xline(opt_res.parameters.expected_focal_distance_bowl, '--'); yline(desired_intensity, '--'); -legend('Original simulation', sprintf('Optimized for %2.f mm distance, analytical', opt_res.parameters.expected_focal_distance_mm), ... - sprintf('Optimized for %2.f mm distance, simulated', opt_res.parameters.expected_focal_distance_mm),'Real profile','Location', 'best') +legend('Original simulation', sprintf('Optimized for %2.f mm distance, analytical', opt_res.parameters.expected_focal_distance_bowl), ... + sprintf('Optimized for %2.f mm distance, simulated', opt_res.parameters.expected_focal_distance_bowl),'Real profile','Location', 'best') ``` ![image](img/figure_5.png) diff --git a/documentation/README.md b/documentation/README.md index d6ace34a..c687c4af 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -23,7 +23,7 @@ Steps 2&3 are done simultaneously to avoid the need to interpolate the image twi The simulation grid is then created based on the segmented image size (with padding), and the transducer and the focus positions are computed based on the transformed coordinate system. For water-only simulations: -The simulation grid dimensions are taken from the parameter structure. The transducer and the focus positions are either taken from the same structure or, if they are missing there, computed so that transducer is at the z-axis and the focus is on the same axis with the distance based on the expected_focal_distance_mm field in the parameters. +The simulation grid dimensions are taken from the parameter structure. The transducer and the focus positions are either taken from the same structure or, if they are missing there, computed so that transducer is at the z-axis and the focus is on the same axis with the distance based on the `expected_focal_distance_bowl` field in the parameters. After that things are straightforward: k-Wave medium, source, grid, and sensor are set up and the simulations are started. When they are completed, the maximum ISPPA map is computed and the maximum pressure and intensity points are estimated for different masks. The heating simulations can be enabled by setting `thermal_simulation` to 1. If `run_posthoc_water_simulations` is set in parameters, then after the layered simulations, the same simulation parameters are used to run the simulations for water only. diff --git a/documentation/doc_config.md b/documentation/doc_config.md index ced3309f..7054e2e7 100644 --- a/documentation/doc_config.md +++ b/documentation/doc_config.md @@ -64,7 +64,8 @@ To set up a specific application, an additional `config_.yaml` should be | `transducer.source_phase_deg` | Phase of the acoustic source (in degrees). | Must be calibrated. | | `transducer.trans_pos` | Position of transducer bowl (XYZ, T1 grid voxel space). | | | `transducer.focus_pos` | Position of stimulation target (XYZ, T1 grid voxel space). | | -| `expected_focal_distance_mm` | Expected distance to the stimulation focus (in mm). | Transducer depth setting | +| `expected_focal_distance_ep` | Expected distance from the transducer exit plane to the stimulation focus (in mm). | Transducer depth setting [Either `expected_focal_distance_ep`, `expected_focal_distance_bowl`, or [`transducer.focus_pos` and `transducer.trans_pos`] have to be specified.] | +| `expected_focal_distance_bowl` | Expected distance from the transducer bowl to the stimulation focus (in mm). | | | `transducer_from_localite` | Load transducer position from Localite files?. | (`1 = yes, 0 = no` [default]) | | `reference_transducer_distance_mm` | Distance from tracker to transducer exit plane (in mm). | Allows to correct for varying distances between the infrared trackers attached to the transducer and the exit plane. Only applies when `transducer_from_localite=1`. | | `use_kWaveArray` | Use the kWaveArray class for modeling transducers. | (`1 = yes, 0 = no`) see k-Wave documentation. | @@ -205,10 +206,9 @@ For transducer calibration, a separate `calibration_config.yaml` applies that sh | `save_in_calibration_folder` | `TRUE` (default): save in `path_output` ; `FALSE`: save outputs in `sim_path` (see regular config). | Note: If TRUE calibration results are also appended to existing calibration for this equipment instead of overwritten. | | `skip_front_peak_mm` | Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts. | Used only for calculating the peak distance and FWHM. | | `optmethod` | `FEXminimize` (open source subtoolbox) or `GlobalSearch` (MATLAB's Global Optimization Toolbox) | | -| `weights` | Weighting of the original profile during fitting (towards 0 = narrower Gaussian; 1 = equal weighting) | | +| `weights` | Weighting of the original profile during fitting (1 = equal weighting, > 1 Gaussian weighting, increasingly narrow with laregr weights) | | | `seed` | Random seed for optimization | | -| `addEPdistance` | Append distance from transducer bowl to exit plane (set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl) | | -| `interpolateToEP` | Interpolate values between bowl and exit plane | If active, the profile is padded (between bowl and exit plane) with the initial value available in the profile. This can stabilize the fitting procedure in the near field. | +| `addEPdistance` | Append distance from transducer bowl to exit plane (set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl). The profile is padded (between bowl and exit plane) with the initial value available in the profile. This can stabilize the fitting procedure in the near field. | | | `force_kwavearray` | Force to run free-water simulations with kwavearray? | If active, free-water simulations always use kwavearray. If set to `0`, simulations use the setting in the default or study-specific config. | | `combinations` | Equipment combinations (must refer to equipment in `equipment_config.yaml`) | Multiple combinations can be specified. Every [] specification will be performed for the corresponding (ordered) index equipment combination. Example: all foci within the first [] will be executed for the first equipment combination. If array is empty ([]), all focal depths of available characterization data will be used. | | `focal_depths_wrt_exit_plane` | List of focal depths (in mm) to be characterized. | Multiple combinations can be specified. | diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index 7ac88fb8..6ba5499f 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -93,17 +93,6 @@ 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}) @@ -128,24 +117,18 @@ 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); 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, ... intens_data, ... equipment_name, ... - dist_from_tran); + dist_from_exit_plane); % Iterate across intensities N_k = length(parameters.calibration.desired_intensities{i}); @@ -155,31 +138,43 @@ % 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, 'addEPdistance') && parameters.calibration.addEPdistance == 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 = focus_wrt_exit_plane; + parameters.expected_focal_distance_bowl = focus_wrt_exit_plane + 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); + if isfield(parameters.calibration, 'addEPdistance') && parameters.calibration.addEPdistance == 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 initial value in amplitude profile - profile_focus_tmp = cat(1, repmat(profile_focus(1),Nvals,1), profile_focus'); + profile_focus = cat(1, repmat(profile_focus_ep(1),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.profile_focus = profile_focus; + profile_empirical.dist_bowl_focus = dist_bowl_focus; profile_empirical.focus_wrt_exit_plane = focus_wrt_exit_plane; % convert from default 3D to 2D axisymmetric simulation (if requested) diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index ad339151..3979902c 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -132,7 +132,7 @@ end % Scale the profile to the desired intensity - profile_opt.adjusted_profile_focus = scale_real_intensity_profile(... + [profile_opt.adjusted_profile_focus, ~] = scale_real_intensity_profile(... initial_params, ... desired_intensity, ... profile_empirical.profile_focus); diff --git a/functions/calibration/compute_oneil_solution.m b/functions/calibration/compute_oneil_solution.m index 0be97070..7c4aed88 100644 --- a/functions/calibration/compute_oneil_solution.m +++ b/functions/calibration/compute_oneil_solution.m @@ -1,5 +1,5 @@ function [p_axial_oneil, simulated_grid_adj_factor, velocity, axial_position] = ... - compute_oneil_solution(parameters, pred_axial_pressure, dist_exit_plane, ... + compute_oneil_solution(parameters, pred_axial_pressure, dist_transducer, ... adjusted_profile_focus, focus_wrt_exit_plane, desired_intensity, equipment_name) % Compute O'Neil solution and plot it along with comparisons % @@ -7,9 +7,10 @@ % - 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]. + % - dist_transducer: 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]. + % - focus_wrt_exit_plane: Focal distance relative to the exit plane + % [mm]. Will be used only for labelling. % - desired_intensity: Desired intensity at the focal point [W/cm^2]. % - equipment_name: Name of the equipment for labeling results. % @@ -24,6 +25,7 @@ (parameters.medium.water.density * parameters.medium.water.sound_speed); % Define the axial position vector [mm] + % Note: transducer will be placed at initial location in grid axial_position = (1:parameters.default_grid_dims(end)) * parameters.grid_step_mm; % Compute O'Neil analytical solution for pressure along the beam axis [Pa] @@ -48,15 +50,19 @@ hold on; plot(axial_position - (parameters.transducer.trans_pos(end) - 1) * parameters.grid_step_mm, pred_axial_intensity, ... '--', 'LineWidth', 1.5, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Inital Simulated Intensity'); - plot(dist_exit_plane, adjusted_profile_focus, ... + plot(dist_transducer, adjusted_profile_focus, ... '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)'); + end + if isfield(parameters, 'expected_focal_distance_ep') && isfield(parameters, 'expected_focal_distance_bowl') + yline(parameters.expected_focal_distance_ep-parameters.expected_focal_distance_bowl, '--', ... + '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'); diff --git a/functions/calibration/extract_real_intensity_profile.m b/functions/calibration/extract_real_intensity_profile.m index 4d159aa0..ccd8482e 100644 --- a/functions/calibration/extract_real_intensity_profile.m +++ b/functions/calibration/extract_real_intensity_profile.m @@ -4,7 +4,7 @@ focus_wrt_exit_plane, ... intens_data, ... equipment_name, ... - dist_from_tran) + dist_from_exit_plane) % Extracts or interpolates the intensity profile at a specific focal depth. % @@ -16,7 +16,7 @@ % - focus_wrt_exit_plane: 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. @@ -54,12 +54,12 @@ [~, 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'); @@ -72,25 +72,25 @@ % 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', ... + plot(dist_from_exit_plane, profile_focus, '-x', 'DisplayName', ... ['Interpolated, focus at ' num2str(focus_wrt_exit_plane)]); legend; xlabel('Distance wrt exit plane [mm]'); @@ -103,7 +103,7 @@ % 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]']); @@ -118,7 +118,7 @@ % 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/perform_global_search.m b/functions/calibration/perform_global_search.m index a2f197f7..5e6702fa 100644 --- a/functions/calibration/perform_global_search.m +++ b/functions/calibration/perform_global_search.m @@ -19,7 +19,7 @@ end if ~isfield(parameters.calibration, 'weights') - weights = 1; + weights = 0; % uniform weighting else weights = parameters.calibration.weights; end @@ -43,8 +43,8 @@ % Define initial guess, bounds, and options for the optimization problem. 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), 0.05]; % Upper bounds: [2pi rad, 50 mm/s] if ~isfield(parameters.calibration, 'optmethod') || strcmp(parameters.calibration.optmethod, 'FEXminimize') % by default use FEXminimize @@ -93,6 +93,11 @@ 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_full_curve.m b/functions/calibration/phase_optimization_annulus_full_curve.m index f14d1d44..a4cbadea 100644 --- a/functions/calibration/phase_optimization_annulus_full_curve.m +++ b/functions/calibration/phase_optimization_annulus_full_curve.m @@ -51,14 +51,22 @@ i_axial_oneil = p_axial_oneil.^2 / (2 * parameters.medium.water.sound_speed * parameters.medium.water.density) * 1e-4; %% Generate weights if not provided + + % The weights always scale to one. 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)) / length(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); + weights = weights / sum(weights); end - % Normalize weights to sum to 1 - weights = weights / sum(weights); %% Calculate error metric % Compute weighted squared error between computed and desired profiles @@ -80,7 +88,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/scale_real_intensity_profile.m b/functions/calibration/scale_real_intensity_profile.m index 600858bb..be169cc0 100644 --- a/functions/calibration/scale_real_intensity_profile.m +++ b/functions/calibration/scale_real_intensity_profile.m @@ -1,4 +1,4 @@ -function adjusted_profile_focus = scale_real_intensity_profile(parameters, desired_intensity, profile_focus) +function [adjusted_profile_focus, parameters] = scale_real_intensity_profile(parameters, desired_intensity, profile_focus) % Scale the real intensity profile to match the desired maximum intensity. % % Arguments: diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index 2fc0db10..ab0e48e1 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -514,7 +514,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 diff --git a/functions/core/print_parameter_summary.m b/functions/core/print_parameter_summary.m index 11de4f5e..b5f78d12 100644 --- a/functions/core/print_parameter_summary.m +++ b/functions/core/print_parameter_summary.m @@ -55,7 +55,8 @@ function print_parameter_summary(parameters) 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(parameters, 'expected_focal_distance_ep', '%.1f'); +print_if_field(parameters, 'expected_focal_distance_bowl', '%.1f'); fprintf('\n'); %% 5. Medium Properties (requested layers only) diff --git a/functions/neuronav/neuronav_convert_trigger_to_voxels.m b/functions/neuronav/neuronav_convert_trigger_to_voxels.m index af8a3b5b..944b00be 100644 --- a/functions/neuronav/neuronav_convert_trigger_to_voxels.m +++ b/functions/neuronav/neuronav_convert_trigger_to_voxels.m @@ -56,7 +56,7 @@ % -- 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; + target_ras(i,:) = ref_pos + parameters.expected_focal_distance_bowl * ref_vec; % -- Convert these world (RAS mm) positions into MRI voxel index space transducer_pos(i,:) = ras_to_grid(transducer_ras(i,1:3)', t1_header); diff --git a/functions/neuronav/position_transducer_localite.m b/functions/neuronav/position_transducer_localite.m index 7e757b0f..04bbddfc 100644 --- a/functions/neuronav/position_transducer_localite.m +++ b/functions/neuronav/position_transducer_localite.m @@ -11,7 +11,7 @@ % 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). +% * 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). @@ -23,7 +23,7 @@ % - '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). + % - '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/. @@ -53,7 +53,7 @@ trans_pos_ras = reference_pos + parameters.reference_transducer_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/source/grid_axisymmetry.m b/functions/source/grid_axisymmetry.m index 7755f87d..a6b53410 100644 --- a/functions/source/grid_axisymmetry.m +++ b/functions/source/grid_axisymmetry.m @@ -12,7 +12,6 @@ % 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); trans_pos = fliplr(trans_pos); focus_pos = fliplr(focus_pos); segmentation = segmentation'; @@ -23,7 +22,6 @@ % 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; 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_transducer_location.m b/functions/source/grid_transducer_location.m index 20a9b4d9..349d367d 100644 --- a/functions/source/grid_transducer_location.m +++ b/functions/source/grid_transducer_location.m @@ -64,7 +64,7 @@ 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); + parameters.expected_focal_distance_bowl/parameters.grid_step_mm); else focus_pos = parameters.transducer.focus_pos; % Adjust if the positions are transposed (2D only) diff --git a/functions/transducer/focal_distance_calculation.m b/functions/transducer/focal_distance_calculation.m index defa3343..bc8098dd 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,18 +10,18 @@ % 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 @@ -33,18 +33,33 @@ % - 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) +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_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_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 +elseif 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_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 @@ -53,7 +68,7 @@ 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) + if ~isfield(tr, 'expected_focal_distance_bowl') || isempty(tr.expected_focal_distance_bowl) needs_t1 = true; break end @@ -72,17 +87,23 @@ t1_info = niftiinfo(filename_t1); t1_grid_step_mm = t1_info.PixelDimensions(1); - % Fill missing expected_focal_distance_mm per transducer + % Fill missing expected_focal_distance_bowl 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, '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) error('Transducer %d: trans_pos or focus_pos missing; cannot compute expected focal distance.', ti); end - + % calculate grid distance between transducer bowl and focus focal_distance_t1 = norm(tr.focus_pos - tr.trans_pos); - parameters.transducer(ti).expected_focal_distance_mm = focal_distance_t1 * t1_grid_step_mm; + % scale grid distance by grid step + parameters.transducer(ti).expected_focal_distance_bowl = focal_distance_t1 * t1_grid_step_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; + end end diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index a1b2213c..171d9d8b 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -100,7 +100,7 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar % 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; + parameters.min_focal_distance_mm = parameters.expected_focal_distance_bowl; end outer_sphere = []; while numel(find(outer_sphere)) < 1 diff --git a/functions/transform/convert_axisymmetric_to_2d.m b/functions/transform/convert_axisymmetric_to_2d.m index 8388373a..385180e5 100644 --- a/functions/transform/convert_axisymmetric_to_2d.m +++ b/functions/transform/convert_axisymmetric_to_2d.m @@ -24,11 +24,9 @@ % 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; % shift radial to x dim parameters.grid_dims = fliplr(parameters.grid_dims); -parameters.default_grid_dims = fliplr(parameters.default_grid_dims); trans_pos_final = fliplr(trans_pos_final); focus_pos_final = fliplr(focus_pos_final); segmentation = segmentation'; diff --git a/functions/transform/convert_axisymmetric_to_3d.m b/functions/transform/convert_axisymmetric_to_3d.m index cfc22684..b8e9a8d0 100644 --- a/functions/transform/convert_axisymmetric_to_3d.m +++ b/functions/transform/convert_axisymmetric_to_3d.m @@ -34,12 +34,6 @@ 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)]; % convert: transducer and focus positions trans_pos_final = fliplr(trans_pos_final); From 63d0a7397cedd9b26f4310bac7c0aedb0595f1e0 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 3 Mar 2026 16:45:01 +0100 Subject: [PATCH 002/144] [distance] fix geometric focal distance estimation does not rely on T1, needs to be informed by grid size, not dimensions of planning image --- .../transducer/focal_distance_calculation.m | 57 ++++++------------- 1 file changed, 18 insertions(+), 39 deletions(-) diff --git a/functions/transducer/focal_distance_calculation.m b/functions/transducer/focal_distance_calculation.m index bc8098dd..5470cf83 100644 --- a/functions/transducer/focal_distance_calculation.m +++ b/functions/transducer/focal_distance_calculation.m @@ -64,49 +64,28 @@ end end -% 2) T1-based geometric derivation for unspecified distances -needs_t1 = false; +% 2) Rely on specification of transducer and target position + +warning('Expected focal distance not specified for all transducers, trying to get it from transducer and target positions ...') + +% 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) - needs_t1 = true; - break - 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))); - 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); - - % 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) - error('Transducer %d: trans_pos or focus_pos missing; cannot compute expected focal distance.', ti); - end - % calculate grid distance between transducer bowl and focus - focal_distance_t1 = norm(tr.focus_pos - tr.trans_pos); - % scale grid distance by grid step - parameters.transducer(ti).expected_focal_distance_bowl = focal_distance_t1 * t1_grid_step_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; - + 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 - 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_step_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 From 0528053ba2115a86ddcc90c7a5bd7f3e75660d8c Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 3 Mar 2026 17:04:55 +0100 Subject: [PATCH 003/144] [distance] attempt to calculate transducer distance during load --- functions/core/load_parameters.m | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/functions/core/load_parameters.m b/functions/core/load_parameters.m index 382b5b84..ef9899e8 100644 --- a/functions/core/load_parameters.m +++ b/functions/core/load_parameters.m @@ -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]); From 92e2c67cccbfd31a740587cff1090a5230aa135e Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 3 Mar 2026 18:06:14 +0100 Subject: [PATCH 004/144] [calibration] update settings and naming --- configs/calibration_config.yaml | 2 +- configs/default_config.yaml | 2 +- examples/calibration_standalone.m | 13 ++++++++----- functions/calibration/perform_global_search.m | 2 +- .../phase_optimization_annulus_full_curve.m | 9 +++------ functions/source/grid_transducer_location.m | 3 +++ 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/configs/calibration_config.yaml b/configs/calibration_config.yaml index cf0b08ad..25bc5326 100644 --- a/configs/calibration_config.yaml +++ b/configs/calibration_config.yaml @@ -13,7 +13,7 @@ save_in_calibration_folder: true # Boolean flag to save outputs in general output folder or simulation-specific folder. If TRUE, simulations are run in calibration output directory; results are appended to existing calibration for this equipment instead of overwritten. skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts optmethod: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) - weights: 3 # Weighting of the original profile during fitting (0 = equal weighting, >1 increasingly narrow Gaussian FWHM) + weights: 0 # Weighting of the original profile during fitting (0 = equal weighting, >1 increasingly narrow Gaussian FWHM) seed: 251024 # Random seed for optimization addEPdistance: 1 # Append distance from transducer bowl to exit plane (if zero point in provided profiles = exit plane, != transducer bowl) force_kwavearray: 1 # Force to run free-water simulations with kwavearray? diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 5a77f41e..a1050b2e 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -47,7 +47,7 @@ pseudoCT_variant: kosciessa # pCT mapping algorithms (yaakub/carpino/k-plan/marq # Only run specific components of the pipeline? run_source_setup: 1 # Set up acoustic source? (switch off to look at initial segmentation results only) run_acoustic_sims: 1 # Run the acoustic simulations? -run_heating_sims: 1 # Run the heating simulations? Run once acoustic simulation provides sensible results. +run_heating_sims: 0 # Run the heating simulations? Run once acoustic simulation provides sensible results. run_posthoc_water_sims: 1 # Run the simulations in free water with exactly the same parameter? Verify that free-water intensity matches target. generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index 6ba5499f..cf10ab4c 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 @@ -99,17 +99,20 @@ 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) + parameters.expected_focal_distance_ep = focus_wrt_exit_plane; + + % 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 warning('Focus %.2f mm is outside the range [%.2f, %.2f] mm. Skipping.\n', ... @@ -174,7 +177,7 @@ % collect data on empirical profile profile_empirical.profile_focus = profile_focus; - profile_empirical.dist_bowl_focus = dist_bowl_focus; + profile_empirical.dist_from_tran = dist_bowl_focus; profile_empirical.focus_wrt_exit_plane = focus_wrt_exit_plane; % convert from default 3D to 2D axisymmetric simulation (if requested) diff --git a/functions/calibration/perform_global_search.m b/functions/calibration/perform_global_search.m index 5e6702fa..3852f7c6 100644 --- a/functions/calibration/perform_global_search.m +++ b/functions/calibration/perform_global_search.m @@ -44,7 +44,7 @@ % Define initial guess, bounds, and options for the optimization problem. initial_guess = [randi(360, [1, parameters.transducer.n_elements]) / 180 * pi, velocity]; 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), 0.05]; % Upper bounds: [2pi rad, 50 mm/s] + upper_bounds = [2 * pi * ones(1, parameters.transducer.n_elements), 0.25]; % Upper bounds: [2pi rad, 250 mm/s] if ~isfield(parameters.calibration, 'optmethod') || strcmp(parameters.calibration.optmethod, 'FEXminimize') % by default use FEXminimize diff --git a/functions/calibration/phase_optimization_annulus_full_curve.m b/functions/calibration/phase_optimization_annulus_full_curve.m index a4cbadea..967a6fac 100644 --- a/functions/calibration/phase_optimization_annulus_full_curve.m +++ b/functions/calibration/phase_optimization_annulus_full_curve.m @@ -52,21 +52,18 @@ %% Generate weights if not provided - % The weights always scale to one. if weights == 0 % UNIFORM: equal weight everywhere (optimize entire profile) - weights = ones(size(axial_position)) / length(axial_position); - + 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); - weights = weights / sum(weights); end - + % Weights always sum to one. + weights = weights / sum(weights); %% Calculate error metric % Compute weighted squared error between computed and desired profiles diff --git a/functions/source/grid_transducer_location.m b/functions/source/grid_transducer_location.m index 349d367d..eccb5f4c 100644 --- a/functions/source/grid_transducer_location.m +++ b/functions/source/grid_transducer_location.m @@ -61,6 +61,9 @@ % 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) + ... From 1224bff391663f75f0af796eaf0072c1b848d606 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 3 Mar 2026 18:37:57 +0100 Subject: [PATCH 005/144] [calibration] introduce optional parameter for upper verlocity --- configs/calibration_config.yaml | 7 ++++--- functions/calibration/perform_global_search.m | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/configs/calibration_config.yaml b/configs/calibration_config.yaml index 25bc5326..2c63ab49 100644 --- a/configs/calibration_config.yaml +++ b/configs/calibration_config.yaml @@ -11,13 +11,14 @@ submit_medium: 'slurm' # Simulation submit mode: matlab (debug; doesn't overwrite; force cpu mode), slurm (recommended), or qsub save_in_calibration_folder: true # Boolean flag to save outputs in general output folder or simulation-specific folder. If TRUE, simulations are run in calibration output directory; results are appended to existing calibration for this equipment instead of overwritten. + force_kwavearray: 1 # Force to run free-water simulations with kwavearray? + axisymmetric2D: 0 # [EXPERIMENTAL; default: 0] Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency + addEPdistance: 1 # Append distance from transducer bowl to exit plane (if zero point in provided profiles = exit plane, != transducer bowl) skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts optmethod: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) weights: 0 # Weighting of the original profile during fitting (0 = equal weighting, >1 increasingly narrow Gaussian FWHM) seed: 251024 # Random seed for optimization - addEPdistance: 1 # Append distance from transducer bowl to exit plane (if zero point in provided profiles = exit plane, != transducer bowl) - force_kwavearray: 1 # Force to run free-water simulations with kwavearray? - axisymmetric2D: 0 # [EXPERIMENTAL; default: 0] Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency + gs_upper_velocity: 0.2 # Upper velocity in global search # List combinations of setups (e.g., transducer-TPO) and calibration targets. # Every [] specification will be performed for the corresponding (ordered) index equipment combination. diff --git a/functions/calibration/perform_global_search.m b/functions/calibration/perform_global_search.m index 3852f7c6..77615d55 100644 --- a/functions/calibration/perform_global_search.m +++ b/functions/calibration/perform_global_search.m @@ -42,9 +42,12 @@ end % Define initial guess, bounds, and options for the optimization problem. + if ~isfield(parameters.calibration, 'gs_upper_velocity') || isempty(parameters.calibration.gs_upper_velocity) + parameters.calibration.gs_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), 0.001]; % Lower bounds: [0 rad, 1 mm/s] - upper_bounds = [2 * pi * ones(1, parameters.transducer.n_elements), 0.25]; % Upper bounds: [2pi rad, 250 mm/s] + upper_bounds = [2 * pi * ones(1, parameters.transducer.n_elements), parameters.calibration.gs_upper_velocity]; % Upper bounds: [2pi rad, 200 mm/s] if ~isfield(parameters.calibration, 'optmethod') || strcmp(parameters.calibration.optmethod, 'FEXminimize') % by default use FEXminimize From c855b696204014081bab72f4a644b7eaaa0358fb Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 10:45:23 +0100 Subject: [PATCH 006/144] [calibration] optimize functions --- documentation/doc_functions.md | 3 +- .../calibration/calibration_transducer.m | 45 +--------- .../calibration/compute_oneil_solution.m | 7 +- .../calibration/extract_simulated_profile.m | 88 +++++++++++++++++++ 4 files changed, 98 insertions(+), 45 deletions(-) create mode 100644 functions/calibration/extract_simulated_profile.m diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index 4dd59df5..16d8f46b 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -12,7 +12,8 @@ The following documents the functions provided in PRESTUS. | `calibration_transducer` | CALIBRATION | Run water simulations to optimize transducer source amplitude and element phases matching a target intensity profile via O’Neil analytical solution, global search, and re-simulation. | | `compute_oneil_solution` | CALIBRATION | Compute analytical O’Neil pressure along the beam axis for focused annular transducers in water, derives particle velocity and grid adjustment factor, and plots comparisons with simulated/desired intensities. | | `compute_phases` | CALIBRATION | Calculate per-element transducer phases (degrees) for electronic focusing by computing fractional wavelength delays to steer waves constructively to a target point relative to the exit plane. | -| `extract_real_intensity_profile` | CALIBRATION | Extract or spline-interpolate axial intensity profiles between available measured focal depths (wrt exit plane), aligns peaks, skips near-field for max, and plots/saves results. | +| `extract_real_intensity_profile` | CALIBRATION | Extract or spline-interpolate axial intensity profiles between available measured focal depths (wrt exit plane), aligns peaks, skips near-field for max, and plots/saves results. | +| `extract_simulated_profile` | CALIBRATION | Extract and visualize simulated acoustic pressure data. | | `perform_global_search` | CALIBRATION | Globally optimize transducer element phases (rad) and particle velocity via FEXminimize or GlobalSearch to minimize error fitting O’Neil analytical intensity to measured axial profiles. | | `phase_optimization_annulus_full_curve` | CALIBRATION | Optimizes the phase profile for an annular transducer by matching intensity curves. | | `phase_optimization_annulus` | CALIBRATION | Optimizes the phase profile by calculating focal distance error for an annular transducer. | diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index 3979902c..11f97f37 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -86,51 +86,12 @@ initial_params = initial_res.acoustic_info.parameters; - %% Get maximum pressure - - p_max = gather(initial_res.sensor_data.p_max_all); % transform from GPU array to normal array - - %% Plot 2D intensity map - - 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') + %% Extract simulated pressure along the focal axis + [pred_axial_pressure] = extract_simulated_profile... + (initial_res, initial_params, profile_empirical, desired_intensity, equipment_name); - % 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, ... diff --git a/functions/calibration/compute_oneil_solution.m b/functions/calibration/compute_oneil_solution.m index 7c4aed88..c2c50f6b 100644 --- a/functions/calibration/compute_oneil_solution.m +++ b/functions/calibration/compute_oneil_solution.m @@ -1,12 +1,13 @@ function [p_axial_oneil, simulated_grid_adj_factor, velocity, axial_position] = ... compute_oneil_solution(parameters, pred_axial_pressure, dist_transducer, ... adjusted_profile_focus, focus_wrt_exit_plane, desired_intensity, equipment_name) + % 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]. + % - p_max: Predicted pressure [Pa]. % - dist_transducer: 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 @@ -48,7 +49,9 @@ 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, ... + % get the distance from the transducer bowl (i.e., trans_pos in PRESTUS) + axial_position_sim = axial_position - (parameters.transducer.trans_pos(end) - 1) * parameters.grid_step_mm; + plot(axial_position_sim, pred_axial_intensity, ... '--', 'LineWidth', 1.5, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Inital Simulated Intensity'); plot(dist_transducer, adjusted_profile_focus, ... 'LineWidth', 2, 'Color', [1 0 0], 'DisplayName', 'Desired Profile'); diff --git a/functions/calibration/extract_simulated_profile.m b/functions/calibration/extract_simulated_profile.m new file mode 100644 index 00000000..900e687d --- /dev/null +++ b/functions/calibration/extract_simulated_profile.m @@ -0,0 +1,88 @@ +function [pred_axial_pressure] = extract_simulated_profile(initial_res, initial_params, profile_empirical, desired_intensity, equipment_name) +%-------------------------------------------------------------------------- +% +% 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) +% initial_params - structure containing simulation and grid parameters +% profile_empirical - structure with empirical profile info (for labeling) +% desired_intensity - numeric target intensity (for output naming) +% equipment_name - string identifying the experimental setup +% +% OUTPUT: +% pred_axial_pressure - simulated pressure values along the focal axis +% +% NOTE: +% The code works for both 2D and 3D simulations, depending on +% initial_params.n_sim_dims (2 or 3). +%-------------------------------------------------------------------------- + + %% 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)) * initial_params.grid_step_mm; + p_width = (1:size(p_max, initial_params.n_sim_dims)) * initial_params.grid_step_mm; + + % Depending on the dimensionality, select an appropriate plane: + if initial_params.n_sim_dims == 2 + % 2D simulation -> directly available pressure field. + p_axialprofile = squeeze(p_max(:, :))'; + elseif initial_params.n_sim_dims == 3 + % 3D simulation -> take the central slice at the transducer’s lateral position. + p_axialprofile = squeeze(p_max(:, initial_params.transducer.trans_pos(2), :))'; + else + error('Unsupported simulation dimensionality: expected 2 or 3.'); + end + + % Plot the pressure map. + imagesc(p_distance, p_width, p_axialprofile); + axis image; + colormap(getColorMap); + xlabel('Lateral Position [mm]'); + ylabel('Axial Position [mm]'); + cb = colorbar; %#ok % keep reference in case of future annotations + title('Pressure for the focal plane'); + + clear p_distance p_width p_axialprofile; + + fig_path = fullfile(initial_params.outputs_folder, ... + strcat('Initial_Intensity_map_2D_at_F_', ... + num2str(profile_empirical.focus_wrt_exit_plane), ... + '_at_I_', num2str(desired_intensity), ... + '_', 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 initial_params.n_sim_dims == 2 + % For 2D: extract the axial profile along the transducer's horizontal position. + i_x = initial_params.transducer.trans_pos(1); + pred_axial_pressure = squeeze(p_max(i_x, :)); + clear i_x; + + elseif initial_params.n_sim_dims == 3 + % For 3D: extract along both lateral center coordinates. + 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 + +end From 78ab26d938afb013dccf1d495416e3bf52a7476e Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 11:42:30 +0100 Subject: [PATCH 007/144] [doc] create mkdocs file --- README.md | 5 +- documentation/CODE_OF_CONDUCT.md | 5 ++ documentation/CONTRIBUTING.md | 5 ++ documentation/README.md | 42 ++-------- documentation/doc_simulations-acoustic.md | 1 + .../doc_simulations-getting-started.md | 4 +- mkdocs.yaml | 76 +++++++++++++++++++ 7 files changed, 97 insertions(+), 41 deletions(-) create mode 100644 documentation/CODE_OF_CONDUCT.md create mode 100644 documentation/CONTRIBUTING.md create mode 100644 mkdocs.yaml diff --git a/README.md b/README.md index 2430e034..ca504955 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). @@ -31,7 +32,7 @@ When working on the Donders High-Performance-Computing cluster, PRESTUS and its 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. -For more information on HPC usage, see [doc_hpc](documentation/doc_hpc.md) +For more information on HPC usage, see [doc_hpc](doc_hpc.md "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: @@ -41,7 +42,7 @@ cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} ## Outside the Donders HPC -Follow the [installation guide](documentation/doc_installation.md). +Follow the [installation guide](doc_installation.md "documentation/doc_installation.md"). # Reference diff --git a/documentation/CODE_OF_CONDUCT.md b/documentation/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..ccfefe4d --- /dev/null +++ b/documentation/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +XSym +0021 +a651e1a432e4ccffef6e6f65e4602347 +../CODE_OF_CONDUCT.md + \ No newline at end of file diff --git a/documentation/CONTRIBUTING.md b/documentation/CONTRIBUTING.md new file mode 100644 index 00000000..9a22115e --- /dev/null +++ b/documentation/CONTRIBUTING.md @@ -0,0 +1,5 @@ +XSym +0018 +f45c180d73747f52fbe1c9fba7691053 +../CONTRIBUTING.md + \ No newline at end of file diff --git a/documentation/README.md b/documentation/README.md index c687c4af..f9fd0649 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -1,37 +1,5 @@ -# How to run a simulation - -The main function is single_subject_pipeline, which takes the subject ID and parameter structure as an input. The parameters are taken from the config files in the configs directory, see the configs in this folder for the information on configurable fields. Parameters include `results_filename_affix` field that can define a condition (e.g., stimulation site, protocol, or something else) in case several simulations are to be started for a given subject. - -For each subject, two files are needed to start, T1 and T2 scans. These files should allow identifying a subject based on the T1 & T2 filename templates set in the config files. These can include wildcards (\* - stands for any symbols repeated 0 or more times, like in bash or MATLAB dir command to be precise) and string substitution patterns (only `subject_id` is used for them for now). Put these files in the folder defined in data_path field in the configuration. If you want to use Localite data, you can either use an instrument marker file (put it in the same folder, add the subject id to it so that the `localite_instr_file_template` from the config file could be parsed) or trigger markers file, which you would need to preprocess for each subject (see example in `example_pipeline_sjoerd.m`). - -On the first run, it is recommended to start the scripts on a local computing node (i.e., without qsub) and without a GPU. Then (if things are good), the segmentation will be started for each subject. They take ~4 hours. After they are completed, start the jobs again, now remotely and with the GPU. This should ideally result in multiple files for each subject/condition in your `sim_outputs` folder. Intermediate plots and files are also created there. You can then run extra post-processing steps if needed. - -# Processing steps - -The main pipeline depends on whether you set `water` or `layered` as the simulations medium. - -The first big part is to get the transducer and the focus positions and a segmentated head image if needed. - -For `layered`: -1) the brain is segmented with SimNIBS -2) the segmented image is rotated so that the focal axis is aligned with the z-axis -3) the segmented image is upsampled to match the simulations grid voxel size -4) the layers in the segmented image are smoothed, the gaps between skin and skull filled, the holes in the skull closed -5) the segmented image is cropped (based on the expanded cerebro-spinal fluid mask to get rid of part of the neck bones) - -Steps 2&3 are done simultaneously to avoid the need to interpolate the image twice. -The simulation grid is then created based on the segmented image size (with padding), and the transducer and the focus positions are computed based on the transformed coordinate system. - -For water-only simulations: -The simulation grid dimensions are taken from the parameter structure. The transducer and the focus positions are either taken from the same structure or, if they are missing there, computed so that transducer is at the z-axis and the focus is on the same axis with the distance based on the `expected_focal_distance_bowl` field in the parameters. - -After that things are straightforward: k-Wave medium, source, grid, and sensor are set up and the simulations are started. When they are completed, the maximum ISPPA map is computed and the maximum pressure and intensity points are estimated for different masks. The heating simulations can be enabled by setting `thermal_simulation` to 1. If `run_posthoc_water_simulations` is set in parameters, then after the layered simulations, the same simulation parameters are used to run the simulations for water only. - -## Supported simulation setups - -- `layered`: the simulation medium varies in terms of water, brain, skin and skull, with potential subdivision into cortical bone (`skull_cortical`) and trabecular bone (`skull_trabecular`) -- `layered`+`pseudoCT`: acoustic properties of bone are based on pseudo-Hounsfield units -- `water`: homogeneous water medium -- `brain`: homogeneous brain medium -- `water_and_skull`: homogeneous water medium + skull -- `brain_and_skull`: homogeneous water medium + brain; note that brain medium is also assumed outside the skull \ No newline at end of file +XSym +0012 +3af88e2d5f99b5321c2ed95978e7bab3 +../README.md + \ No newline at end of file diff --git a/documentation/doc_simulations-acoustic.md b/documentation/doc_simulations-acoustic.md index be716af7..4fed4ec9 100644 --- a/documentation/doc_simulations-acoustic.md +++ b/documentation/doc_simulations-acoustic.md @@ -7,6 +7,7 @@ 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. Multiple adjustments are made if axisymmetry is requested: + - 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. diff --git a/documentation/doc_simulations-getting-started.md b/documentation/doc_simulations-getting-started.md index 2273ce75..c0da53d6 100644 --- a/documentation/doc_simulations-getting-started.md +++ b/documentation/doc_simulations-getting-started.md @@ -24,11 +24,11 @@ ### choose a simulation medium -- see `doc_medium.md` +- see [Medium Setup](doc_medium.md) ### choose a simulation type -- see `doc_backend.md` +- see [Backend](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). diff --git a/mkdocs.yaml b/mkdocs.yaml new file mode 100644 index 00000000..b28c8a41 --- /dev/null +++ b/mkdocs.yaml @@ -0,0 +1,76 @@ +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_simulations-getting-started.md + - 📖 Documentation: + - Transducer Calibration: doc_calibration.md + - Medium Setup: doc_medium.md + - Transducer Placement: doc_heuristic-placement.md + - Neuronavigation: doc_neuronav.md + - Outputs: doc_outputs.md + - 🔬 Simulations: + - Acoustic: doc_simulations-acoustic.md + - Thermal: doc_simulations-thermal.md + - PseudoCT: doc_pseudoCT.md + - 📚 References: + - Parameter Configuration: doc_config.md + - Functions: doc_functions.md + - 🖥️ Advanced: + - Advanced Workflows: doc_advanced.md + - Backend: doc_backend.md + - HPC: doc_hpc.md + - Group Plots: doc_group-plots.md + - 🆘 Troubleshooting: doc_troubleshooting.md + - 📋 Community: + - Code of Conduct: CODE_OF_CONDUCT.md + - Contributing: CONTRIBUTING.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 \ No newline at end of file From 22dd02a0a7b1ac48953f1ac071fcaa8aa98ba8a9 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 11:49:13 +0100 Subject: [PATCH 008/144] [docs] automate pages --- .github/workflows/mkdocs.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/workflows/mkdocs.yml diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml new file mode 100644 index 00000000..e71d8854 --- /dev/null +++ b/.github/workflows/mkdocs.yml @@ -0,0 +1,11 @@ +name: Deploy MkDocs +on: push +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: python-version: 3.x + - run: pip install mkdocs-material + - run: mkdocs gh-deploy \ No newline at end of file From 5193cf46d20a94ef91846128bf151cc0643d3a95 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 11:53:42 +0100 Subject: [PATCH 009/144] [docs] automate pages --- .github/workflows/mkdocs.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index e71d8854..10d241da 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -1,5 +1,7 @@ name: Deploy MkDocs -on: push +on: + push: + branches: [ main development ] jobs: deploy: runs-on: ubuntu-latest From faa1d4e6a8a01f325deb22d20612b175a33392d8 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 11:56:40 +0100 Subject: [PATCH 010/144] [doc] minor --- .github/workflows/mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 10d241da..8dba701a 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -2,6 +2,8 @@ name: Deploy MkDocs on: push: branches: [ main development ] +permissions: + contents: write jobs: deploy: runs-on: ubuntu-latest From da53daae0eb97c9aed7c1601722adad130741169 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:00:38 +0100 Subject: [PATCH 011/144] [doc] workflow --- .github/workflows/mkdocs.yml | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 8dba701a..a12eccb1 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -1,15 +1,33 @@ name: Deploy MkDocs -on: +on: push: - branches: [ main development ] + branches: [ main, development ] permissions: contents: write + pages: write + id-token: write + jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: python-version: 3.x - - run: pip install mkdocs-material - - run: mkdocs gh-deploy \ No newline at end of file + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install mkdocs-material + + - name: Build docs + run: mkdocs build -v + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site \ No newline at end of file From 17fde9493e91b45d09b64015f00d4aa33abfa24e Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:06:40 +0100 Subject: [PATCH 012/144] [doc] mkdocs deployment --- .github/workflows/mkdocs.yml | 31 ++++++------------------------- .gitignore | 6 ++++++ 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index a12eccb1..c270ec7d 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -1,33 +1,14 @@ name: Deploy MkDocs -on: +on: push: branches: [ main, development ] -permissions: - contents: write - pages: write - id-token: write - jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - pip install mkdocs-material - - - name: Build docs - run: mkdocs build -v - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - if: github.ref == 'refs/heads/main' - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./site \ No newline at end of file + - uses: actions/setup-python@v4 + with: python-version: '3.x' + - run: pip install mkdocs-material + - run: mkdocs build + - run: mkdocs gh-deploy --force \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7d2e7c52..921a38e4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,12 @@ *.o[0-9][0-9][0-9][0-9]* +###################### +# Ignore mkdocs site # +###################### + +site/ + ################### # Matlab specific # ################### From 0ff68d07b1e2d0817615791e75edbadd00216c6e Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:11:42 +0100 Subject: [PATCH 013/144] [doc] update outputs --- documentation/doc_outputs.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index cbcd9fdb..c5ae3c9f 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -80,4 +80,6 @@ PRESTUS saves an overview of the parameters used to run the simulation and by de PRESTUS provides multiple figures for quick visual inspection and debugging. -TBD \ No newline at end of file +#### HTML + +PRESTUS provides a summary HTML that provides safety-relevant parameters in a dashboard, summarizes parameters and medium-specific acoustic properties, prints the full log, and summarizes figures. \ No newline at end of file From 1120090fb23babe7476483cd58c89e8705f412fe Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:18:50 +0100 Subject: [PATCH 014/144] [doc] update output --- documentation/doc_outputs.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index c5ae3c9f..3ca586d1 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -71,10 +71,13 @@ Images are provided in subject-space (```_orig_coord_```) and in MNI-152 space ( PRESTUS saves an overview of the parameters used to run the simulation and by default saves structures from acoustic and heating simulations. If these are detected in the results folder (and overwriting is deactivated), they will be loaded instead of performing the calculation. -- sub-XXX__parameters.mat | simulation parameters -- sub-XXX__kwave_source.mat | k-Wave source | parameters, kgrid, trans_pos_final, focus_pos_final -- sub-XXX__results.mat | acoustic simulation outputs [can be deactivated via ```savemat``` = 0] -- sub-XXX__heating_res.mat | thermal simulation outputs [can be deactivated via ```savemat``` = 0] +| Output | Description | +|-------------------------------------------------------|---------------------------------------------------------------------------| +| sub-XXX__parameters.mat | Unique identifier for the subject | +| sub-XXX__parameters.mat | simulation parameters | +| sub-XXX__kwave_source.mat | k-Wave source | parameters, kgrid, trans_pos_final, focus_pos_final | +| sub-XXX__results.mat | acoustic simulation outputs [can be deactivated via ```savemat``` = 0] | +| sub-XXX__heating_res.mat | thermal simulation outputs [can be deactivated via ```savemat``` = 0] | #### Figures From d76a82d15945cd42121f76e945e34d8f190a0e5d Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:25:09 +0100 Subject: [PATCH 015/144] [doc] update outputs --- documentation/doc_outputs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index 3ca586d1..07829bc1 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -4,7 +4,7 @@ PRESTUS provides multiple outputs. Some of these outputs are optional (or can be #### Summary table -Filename: sub-XXX__output_table.csv +Filename: `sub-XXX__output_table.csv` This output table provides an overview of key metrics. From 52221eb873eda28d61a52f43748d93c1d32be766 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:27:33 +0100 Subject: [PATCH 016/144] [doc] fix syntax --- .github/workflows/mkdocs.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index c270ec7d..66b48fb6 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -1,14 +1,17 @@ -name: Deploy MkDocs -on: +name: Deploy PRESTUS +on: push: - branches: [ main, development ] + branches: [ master, development ] + workflow_dispatch: +permissions: + contents: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 - with: python-version: '3.x' + with: + python-version: '3.11' - run: pip install mkdocs-material - - run: mkdocs build - run: mkdocs gh-deploy --force \ No newline at end of file From b17498c2934595675878c73e655acf76669894e5 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:36:02 +0100 Subject: [PATCH 017/144] [doc] fix links --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ca504955..9ce8ca47 100755 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ When working on the Donders High-Performance-Computing cluster, PRESTUS and its 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. -For more information on HPC usage, see [doc_hpc](doc_hpc.md "documentation/doc_hpc.md"). +For more information on HPC usage, see the [HPC guide](doc_hpc.md "https://github.com/Donders-Institute/PRESTUS/blob/development/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: @@ -42,7 +43,7 @@ cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} ## Outside the Donders HPC -Follow the [installation guide](doc_installation.md "documentation/doc_installation.md"). +Follow the [installation guide](doc_installation.md "https://github.com/Donders-Institute/PRESTUS/blob/development/documentation/doc_installation.md"). # Reference From d5f497e4859b4f57ddb6439ac05f9982cfd8bd6f Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:40:38 +0100 Subject: [PATCH 018/144] [doc] update --- .github/workflows/mkdocs.yml | 23 ++++++++++++++++------- README.md | 4 ++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 66b48fb6..551d1148 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -1,17 +1,26 @@ -name: Deploy PRESTUS +name: Deploy PRESTUS Docs on: push: - branches: [ master, development ] - workflow_dispatch: + branches: [ development, master ] + workflow_dispatch: # Manual trigger + permissions: contents: write + jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + + - uses: actions/setup-python@v5 with: - python-version: '3.11' - - run: pip install mkdocs-material - - run: mkdocs gh-deploy --force \ No newline at end of file + python-version: '3.x' + + - name: Install dependencies + run: | + pip install -U pip + pip install mkdocs mkdocs-material + + - name: Build & Deploy + run: mkdocs gh-deploy --force diff --git a/README.md b/README.md index 9ce8ca47..ed705133 100755 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ When working on the Donders High-Performance-Computing cluster, PRESTUS and its 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. -For more information on HPC usage, see the [HPC guide](doc_hpc.md "https://github.com/Donders-Institute/PRESTUS/blob/development/documentation/doc_hpc.md") +For more information on HPC usage, see the [HPC guide](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: @@ -43,7 +43,7 @@ cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} ## Outside the Donders HPC -Follow the [installation guide](doc_installation.md "https://github.com/Donders-Institute/PRESTUS/blob/development/documentation/doc_installation.md"). +Follow the [installation guide](documentation/doc_installation.md). # Reference From 78c36ddbda4ca7e8eb54ca81a31afa3a2f9475ed Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:43:31 +0100 Subject: [PATCH 019/144] [doc] gh action --- .github/workflows/mkdocs.yml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 551d1148..33432ecc 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -2,13 +2,18 @@ name: Deploy PRESTUS Docs on: push: branches: [ development, master ] - workflow_dispatch: # Manual trigger + workflow_dispatch: permissions: - contents: write + 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 @@ -17,10 +22,23 @@ jobs: 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 - - name: Build & Deploy - run: mkdocs gh-deploy --force + - 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 From 1fd43cdb89e540d72a2724bff60e41204451568a Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 12:50:43 +0100 Subject: [PATCH 020/144] [doc] format outputs --- documentation/doc_outputs.md | 97 ++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index 07829bc1..7abdab27 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -10,49 +10,49 @@ This output table provides an overview of key metrics. **Acoustic simulation** -| Parameter | Description | -|-------------------------------|-----------------------------------------------------| -| subject_id | Unique identifier for the subject | -| max_Isppa | Maximum intensity [W/cm²] | -| max_Isppa_after_exit_plane | Maximum intensity after the exit plane [W/cm²] | -| real_focal_distance | Empirical focal distance (distance between transducer and max. intensity in brain medium (if modelled) or in any medium beyond the exit plane) [mm] | -| max_Isppa_skin | Maximum intensity (skin medium) [W/cm²] | -| max_Isppa_skull | Maximum intensity (skull medium) [W/cm²] | -| max_Isppa_brain | Maximum intensity (brain medium) [W/cm²] | -| max_pressure_skin | Maximum pressure (skin medium) [Pa] | -| max_pressure_skull | Maximum pressure (skull medium) [Pa] | -| max_pressure_brain | Maximum pressure (brain medium) [Pa] | -| max_MI_skin | Maximum Mechanical Index (skin medium) | -| max_MI_skull | Maximum Mechanical Index (skull medium) | -| max_MI_brain | Maximum Mechanical Index (brain medium) | -| Ix_brain | X-coordinate of maximum intensity (brain medium) | -| Iy_brain | Y-coordinate of maximum intensity (brain medium) | -| Iz_brain | Z-coordinate of maximum intensity (brain medium) | -| trans_pos_final_1 | X-coordinate of final transducer position | -| trans_pos_final_2 | Y-coordinate of final transducer position | -| trans_pos_final_3 | Z-coordinate of final transducer position | -| focus_pos_final_1 | X-coordinate of final focus position | -| focus_pos_final_2 | Y-coordinate of final focus position | -| focus_pos_final_3 | Z-coordinate of final focus position | -| isppa_at_target | Intensity value at the target location [W/cm²] | -| avg_isppa_around_target | Average intensity around the target [W/cm²] | -| half_max_ISPPA_volume_brain | Volume of brain with half maximum intensity | +| Parameter | Description | +|-------------------------------|---------------------------------------------------| +| `subject_id` | Unique identifier for the subject | +| `max_Isppa` | Maximum intensity [W/cm²] | +| `max_Isppa_after_exit_plane` | Maximum intensity after the exit plane [W/cm²] | +| `real_focal_distance` | Empirical focal distance (distance between transducer and max. intensity in brain medium (if modelled) or in any medium beyond the exit plane) [mm] | +| `max_Isppa_skin` | Maximum intensity (skin medium) [W/cm²] | +| `max_Isppa_skull` | Maximum intensity (skull medium) [W/cm²] | +| `max_Isppa_brain` | Maximum intensity (brain medium) [W/cm²] | +| `max_pressure_skin` | Maximum pressure (skin medium) [Pa] | +| `max_pressure_skull` | Maximum pressure (skull medium) [Pa] | +| `max_pressure_brain` | Maximum pressure (brain medium) [Pa] | +| `max_MI_skin` | Maximum Mechanical Index (skin medium) | +| `max_MI_skull` | Maximum Mechanical Index (skull medium) | +| `max_MI_brain` | Maximum Mechanical Index (brain medium) | +| `Ix_brain` | X-coordinate of maximum intensity (brain medium) | +| `Iy_brain` | Y-coordinate of maximum intensity (brain medium) | +| `Iz_brain` | Z-coordinate of maximum intensity (brain medium) | +| `trans_pos_final_1` | X-coordinate of final transducer position | +| `trans_pos_final_2` | Y-coordinate of final transducer position | +| `trans_pos_final_3` | Z-coordinate of final transducer position | +| `focus_pos_final_1` | X-coordinate of final focus position | +| `focus_pos_final_2` | Y-coordinate of final focus position | +| `focus_pos_final_3` | Z-coordinate of final focus position | +| `isppa_at_target` | Intensity value at the target location [W/cm²] | +| `avg_isppa_around_target` | Average intensity around the target [W/cm²] | +| `half_max_ISPPA_volume_brain` | Volume of brain with half maximum intensity | **Thermal simulation** -| Parameter | Description | -|-------------------------------|-----------------------------------------------------| -| maxT | Maximum temperature (global medium) [°C] | -| maxCEM43 | Maximum CEM43 thermal dose (global medium) | -| maxT_brain | Maximum temperature (brain medium) [°C] | -| maxT_skull | Maximum temperature (skull medium) [°C] | -| maxT_skin | Maximum temperature (skin medium) [°C] | -| riseT_brain | Temperature rise (brain medium) [°C] | -| riseT_skull | Temperature rise (skull medium) [°C] | -| riseT_skin | Temperature rise (skin medium) [°C] | -| CEM43_brain | CEM43 Thermal dose (brain medium) [mins] | -| CEM43_skull | CEM43 Thermal dose (skull medium) [mins] | -| CEM43_skin | CEM43 Thermal dose (skin medium) [mins] | +| Parameter | Description | +|--------------------|------------------------------------------------------| +| `maxT` | Maximum temperature (global medium) [°C] | +| `maxCEM43` | Maximum CEM43 thermal dose (global medium) | +| `maxT_brain` | Maximum temperature (brain medium) [°C] | +| `maxT_skull` | Maximum temperature (skull medium) [°C] | +| `maxT_skin` | Maximum temperature (skin medium) [°C] | +| `riseT_brain` | Temperature rise (brain medium) [°C] | +| `riseT_skull` | Temperature rise (skull medium) [°C] | +| `riseT_skin` | Temperature rise (skin medium) [°C] | +| `CEM43_brain` | CEM43 Thermal dose (brain medium) [mins] | +| `CEM43_skull` | CEM43 Thermal dose (skull medium) [mins] | +| `CEM43_skin` | CEM43 Thermal dose (skin medium) [mins] | #### 2D/3D NIFTI images @@ -63,9 +63,9 @@ Images are provided in subject-space (```_orig_coord_```) and in MNI-152 space ( - isppa [acoustic] - MI [acoustic] - pressure [acoustic] -- heating [heating] -- heatrise [heating] -- CEM43 [heating] +- heating [thermal] +- heatrise [thermal] +- CEM43 [thermal] #### MATLAB structures @@ -73,11 +73,12 @@ PRESTUS saves an overview of the parameters used to run the simulation and by de | Output | Description | |-------------------------------------------------------|---------------------------------------------------------------------------| -| sub-XXX__parameters.mat | Unique identifier for the subject | -| sub-XXX__parameters.mat | simulation parameters | -| sub-XXX__kwave_source.mat | k-Wave source | parameters, kgrid, trans_pos_final, focus_pos_final | -| sub-XXX__results.mat | acoustic simulation outputs [can be deactivated via ```savemat``` = 0] | -| sub-XXX__heating_res.mat | thermal simulation outputs [can be deactivated via ```savemat``` = 0] | +| `sub-XXX__parameters.mat` | Unique identifier for the subject | +| `sub-XXX__parameters.mat` | Simulation parameters | +| `sub-XXX__kwave_source.mat` | k-Wave source \| parameters, kgrid, trans_pos_final, focus_pos_final | +| `sub-XXX__results.mat` | Acoustic simulation outputs [can be deactivated via `savemat = 0`] | +| `sub-XXX__heating_res.mat` | Thermal simulation outputs [can be deactivated via `savemat = 0`] | + #### Figures From e0f0ac50046218eb856cf919c6cd863e86bc397a Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:03:00 +0100 Subject: [PATCH 021/144] [doc] include readme --- documentation/index.md | 9 +++++++++ mkdocs.yaml | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 documentation/index.md diff --git a/documentation/index.md b/documentation/index.md new file mode 100644 index 00000000..b04dc024 --- /dev/null +++ b/documentation/index.md @@ -0,0 +1,9 @@ +index.md + +--- +edit_link: false +--- + +# PRESTUS Toolbox + +--8<-- README.md # Live includes entire root README.md! \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index b28c8a41..156a14be 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -73,4 +73,6 @@ markdown_extensions: permalink: true - tables # | tables | - pymdownx.highlight # Code highlighting - - pymdownx.superfences # ``` fences with highlighting \ No newline at end of file + - pymdownx.superfences # ``` fences with highlighting + - pymdownx.snippets: + basepath: .. # Points to repo root! \ No newline at end of file From 0ab229c0e15dd51e707081b56bb32dd412212be2 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:03:51 +0100 Subject: [PATCH 022/144] [doc] include readme --- mkdocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yaml b/mkdocs.yaml index 156a14be..cc08f891 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -36,7 +36,7 @@ theme: code: Roboto Mono nav: - - 🏠 Home: README.md + - 🏠 Home: index.md - 🚀 Getting Started: - Installation: doc_installation.md - Getting started: doc_simulations-getting-started.md From e9875fc5aaba59821f96793948a58f53ecada086 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:05:04 +0100 Subject: [PATCH 023/144] [doc] include readme --- mkdocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yaml b/mkdocs.yaml index cc08f891..e6f48a42 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -75,4 +75,4 @@ markdown_extensions: - pymdownx.highlight # Code highlighting - pymdownx.superfences # ``` fences with highlighting - pymdownx.snippets: - basepath: .. # Points to repo root! \ No newline at end of file + base_path: .. # Points to repo root! \ No newline at end of file From 63b3bf761ab5484be1c7c3b13b3cad44f257c7c7 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:08:57 +0100 Subject: [PATCH 024/144] [doc] update requirements --- .github/workflows/mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 33432ecc..80341e1e 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -30,7 +30,7 @@ jobs: - name: Install dependencies run: | pip install -U pip - pip install mkdocs mkdocs-material + pip install mkdocs mkdocs-material pymdown-extensions - name: Build run: mkdocs build --clean From cae3ff75de6f736eae5f80c91db5a93c02d08564 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:12:56 +0100 Subject: [PATCH 025/144] [doc] add requirements --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..74aea4b3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +mkdocs>=1.5.3 +mkdocs-material>=9.5.0 +pymdown-extensions>=10.7 \ No newline at end of file From 161a12aa853c7fbb1bbae81c6af96300ddad659b Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:17:24 +0100 Subject: [PATCH 026/144] [doc] remove index --- documentation/index.md | 9 --------- mkdocs.yaml | 6 ++---- requirements.txt | 3 --- 3 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 documentation/index.md delete mode 100644 requirements.txt diff --git a/documentation/index.md b/documentation/index.md deleted file mode 100644 index b04dc024..00000000 --- a/documentation/index.md +++ /dev/null @@ -1,9 +0,0 @@ -index.md - ---- -edit_link: false ---- - -# PRESTUS Toolbox - ---8<-- README.md # Live includes entire root README.md! \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index e6f48a42..a267f708 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -36,7 +36,7 @@ theme: code: Roboto Mono nav: - - 🏠 Home: index.md + # - 🏠 Home: index.md - 🚀 Getting Started: - Installation: doc_installation.md - Getting started: doc_simulations-getting-started.md @@ -73,6 +73,4 @@ markdown_extensions: permalink: true - tables # | tables | - pymdownx.highlight # Code highlighting - - pymdownx.superfences # ``` fences with highlighting - - pymdownx.snippets: - base_path: .. # Points to repo root! \ No newline at end of file + - pymdownx.superfences # ``` fences with highlighting \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 74aea4b3..00000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -mkdocs>=1.5.3 -mkdocs-material>=9.5.0 -pymdown-extensions>=10.7 \ No newline at end of file From b399537c177c748c512e45cf19b405349a3d9f50 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:20:31 +0100 Subject: [PATCH 027/144] [doc] relink root files --- documentation/CODE_OF_CONDUCT.md | 5 ----- documentation/CONTRIBUTING.md | 5 ----- mkdocs.yaml | 6 +++--- 3 files changed, 3 insertions(+), 13 deletions(-) delete mode 100644 documentation/CODE_OF_CONDUCT.md delete mode 100644 documentation/CONTRIBUTING.md diff --git a/documentation/CODE_OF_CONDUCT.md b/documentation/CODE_OF_CONDUCT.md deleted file mode 100644 index ccfefe4d..00000000 --- a/documentation/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,5 +0,0 @@ -XSym -0021 -a651e1a432e4ccffef6e6f65e4602347 -../CODE_OF_CONDUCT.md - \ No newline at end of file diff --git a/documentation/CONTRIBUTING.md b/documentation/CONTRIBUTING.md deleted file mode 100644 index 9a22115e..00000000 --- a/documentation/CONTRIBUTING.md +++ /dev/null @@ -1,5 +0,0 @@ -XSym -0018 -f45c180d73747f52fbe1c9fba7691053 -../CONTRIBUTING.md - \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index a267f708..9568eff4 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -36,7 +36,7 @@ theme: code: Roboto Mono nav: - # - 🏠 Home: index.md + - 🏠 Home: !https://github.com/Donders-Institute/PRESTUS/blob/development/README.md - 🚀 Getting Started: - Installation: doc_installation.md - Getting started: doc_simulations-getting-started.md @@ -60,8 +60,8 @@ nav: - Group Plots: doc_group-plots.md - 🆘 Troubleshooting: doc_troubleshooting.md - 📋 Community: - - Code of Conduct: CODE_OF_CONDUCT.md - - Contributing: CONTRIBUTING.md + - Code of Conduct: !https://github.com/Donders-Institute/PRESTUS/blob/development/CODE_OF_CONDUCT.md + - Contributing: !https://github.com/Donders-Institute/PRESTUS/blob/development/CONTRIBUTING.md plugins: - search From 4d1ad490ba6f2ec2fab4dbed4ca475a5f2d871ec Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:22:00 +0100 Subject: [PATCH 028/144] [doc] relink root files --- mkdocs.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mkdocs.yaml b/mkdocs.yaml index 9568eff4..c4a1ec55 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -36,7 +36,7 @@ theme: code: Roboto Mono nav: - - 🏠 Home: !https://github.com/Donders-Institute/PRESTUS/blob/development/README.md + - 🏠 Home: https://github.com/Donders-Institute/PRESTUS/blob/development/README.md - 🚀 Getting Started: - Installation: doc_installation.md - Getting started: doc_simulations-getting-started.md @@ -60,8 +60,8 @@ nav: - Group Plots: doc_group-plots.md - 🆘 Troubleshooting: doc_troubleshooting.md - 📋 Community: - - Code of Conduct: !https://github.com/Donders-Institute/PRESTUS/blob/development/CODE_OF_CONDUCT.md - - Contributing: !https://github.com/Donders-Institute/PRESTUS/blob/development/CONTRIBUTING.md + - Code of Conduct: https://github.com/Donders-Institute/PRESTUS/blob/development/CODE_OF_CONDUCT.md + - Contributing: https://github.com/Donders-Institute/PRESTUS/blob/development/CONTRIBUTING.md plugins: - search From a40b5ad6474a6048a03906e458df4ab83b1b35ad Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:29:22 +0100 Subject: [PATCH 029/144] [doc] duplicate content where necessary --- documentation/CODE_OF_CONDUCT.md | 43 +++++++++++++++++++++++++++++++ documentation/CONTRIBUTING.md | 39 ++++++++++++++++++++++++++++ documentation/README.md | 42 ++++++++++++++++++++++++++---- documentation/doc_installation.md | 18 ++++++++++++- mkdocs.yaml | 6 ++--- 5 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 documentation/CODE_OF_CONDUCT.md create mode 100644 documentation/CONTRIBUTING.md diff --git a/documentation/CODE_OF_CONDUCT.md b/documentation/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d61d5ce3 --- /dev/null +++ b/documentation/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Contributor Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others’ private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces and also applies when an individual is officially representing the project in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers. All complaints will be reviewed and investigated promptly and fairly. + +All project maintainers are obligated to respect the privacy and security of the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html + +[homepage]: https://www.contributor-covenant.org diff --git a/documentation/CONTRIBUTING.md b/documentation/CONTRIBUTING.md new file mode 100644 index 00000000..1477d3be --- /dev/null +++ b/documentation/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to PRESTUS + +Thank you for your interest in contributing to the open-source development of PRESTUS! + +We welcome all types of contributions, including code submissions, documentation, tests, and feedback. + +PRESTUS emerged from the need for tailored simulation solutions in the realm of transcranial ultrasound stimulation (TUS) research. As TUS research advances, new equipment and experimental paradigm requirements continually arise, often outpacing the capabilities of existing simulation tools. PRESTUS was developed to address these challenges by providing a flexible set of functions for ultrasonic simulations. This adaptability allows researchers to efficiently prototype, test, and refine simulation workflows that are responsive to the unique and evolving demands of their studies. + +Your contributions help maintain and usefully expand this evolving tool for the non-invasive brain stimulation (NIBS) community. + +## Getting Started +- Fork the repository and clone it locally. +- Install dependencies as described in [README.md](README.md). +- Please review open issues and discussions to avoid duplicating work and to align with ongoing development. + +## Coding Standards + +PRESTUS is a research tool, and we strive for transparency, reproducibility, and clarity—even if the workflows are evolving and at times built for specific use cases in scientific work. Please consider the following guiding principles: + +- **Motivation:** When proposing changes, open an issue or comment on an existing one to describe the scientific or technical motivation behind your contribution. +- **Documentation:** Clearly document any new features, changes, or known limitations directly in the code and in your pull request description. If your contribution addresses messy or ambiguous data, describe your approach and any assumptions made. +- **Reproducibility:** Strive to make your changes as reproducible as possible. If you use external data, scripts, or parameters, provide instructions or references so others can replicate your results. +- **Transparency:** If your solution involves workarounds, non-standard methods, or "messy" scientific practices, explain these choices in your code comments and pull request. This helps future users and contributors understand the context and rationale. +- **Code Quality:** Write clear, descriptive commit messages. Limit the impact of your updates to the target features/fixes, and minimize unintended effects on other workflows or users. +- **Backward Compatibility:** Remain backward-compatible where possible. If not feasible, document the necessary changes and impacts in your code, issue, and pull request. +- **Data Handling:** If your contribution involves new datasets or changes to data processing, include metadata and a description of data provenance, quality, and any preprocessing steps. + +## How to Submit Changes +- Create a new branch for your feature or fix. +- Ensure (to the best of your ability) that your features work as expected and the pipeline remains functional. Unit tests have not yet been implemented, so please verify your changes manually and describe your testing process. +- Submit a pull request to the `development` branch with a clear description. Before this pull request can be accepted, it will need to be reviewed by another contributor. +- [Optional] Attach a log or summary to the pull request, stating that the "pipeline has finished successfully" or describing any issues encountered. +- [Optional] Add yourself to the contributor list in the readme (you will automatically be recognized by GitHub's contribution tracker). Follow [these instructions](https://allcontributors.org/docs/en/bot/usage). + +## Code of Conduct +Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. + +## Contact +If you have questions, please [open an issue](https://github.com/Donders-Institute/PRESTUS/issues). diff --git a/documentation/README.md b/documentation/README.md index f9fd0649..62201a0f 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -1,5 +1,37 @@ -XSym -0012 -3af88e2d5f99b5321c2ed95978e7bab3 -../README.md - \ No newline at end of file +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.15095860.svg)](https://doi.org/10.5281/zenodo.15095860) + +# PRESTUS: PREprocessing & Simulations for Transcranial Ultrasound Stimulation + +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). +- (pseudo-)CT-informed continuous skull mapping. +- Virtual multi-element transducer calibration (free-water profile emulation). +- Estimation of entry-target coordinates. +- Flexible temporal protocol specification (e.g., including session breaks). +- k-Wave integration for robust acoustic and heating simulations. +- Support for high-performance computing. +- 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. + +A recent overview poster can be found [here](https://jkosciessa.github.io/downloads/2025-FUN25-PRESTUS.pdf). + +# Installation + +Follow the [installation guide](doc_installation.md). + +# License + +Released under GNU General Public License v3.0 (see LICENSE). + +> **Disclaimer** +> This software is currently under development and is provided “as is” without warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. This tool is intended for research purposes only and is not designed, intended, or approved for medical or clinical use, diagnosis, or treatment of patients. The contributors accept no liability for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) arising in any way out of the use of this software. Use at your own risk. + +# Development + +If you would like to assist the community development of this tool, please consider [CONTRIBUTING](CONTRIBUTING.md). \ No newline at end of file diff --git a/documentation/doc_installation.md b/documentation/doc_installation.md index 5bfbacf8..83b58dd6 100644 --- a/documentation/doc_installation.md +++ b/documentation/doc_installation.md @@ -80,4 +80,20 @@ parameters.ld_library_path = "/opt/gcc/7.2.0/lib64"; #### [Optional: Download C++ binaries] -For the `cpp_cpu` and `cpp_gpu` modes, download the C++ binaries from the [k-Wave website](http://www.k-wave.org/download.php) and place them into `k-Wave/binaries/`; see also the instructions [here](http://www.k-wave.org/documentation/kspaceFirstOrder3DC.php). \ No newline at end of file +For the `cpp_cpu` and `cpp_gpu` modes, download the C++ binaries from the [k-Wave website](http://www.k-wave.org/download.php) and place them into `k-Wave/binaries/`; see also the instructions [here](http://www.k-wave.org/documentation/kspaceFirstOrder3DC.php). + + +#### 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. + +For more information on HPC usage, see the [HPC guide](doc_hpc.md) + + +If you want to get started with simulations, you can use the PRESTUS example dataset. This command will copy the dataset to your home directory: + +``` +cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} +``` \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index c4a1ec55..b28c8a41 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -36,7 +36,7 @@ theme: code: Roboto Mono nav: - - 🏠 Home: https://github.com/Donders-Institute/PRESTUS/blob/development/README.md + - 🏠 Home: README.md - 🚀 Getting Started: - Installation: doc_installation.md - Getting started: doc_simulations-getting-started.md @@ -60,8 +60,8 @@ nav: - Group Plots: doc_group-plots.md - 🆘 Troubleshooting: doc_troubleshooting.md - 📋 Community: - - Code of Conduct: https://github.com/Donders-Institute/PRESTUS/blob/development/CODE_OF_CONDUCT.md - - Contributing: https://github.com/Donders-Institute/PRESTUS/blob/development/CONTRIBUTING.md + - Code of Conduct: CODE_OF_CONDUCT.md + - Contributing: CONTRIBUTING.md plugins: - search From d810fb21ec84e88b95b86117ed7aab24aae7280c Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:32:16 +0100 Subject: [PATCH 030/144] [doc] install reorder --- documentation/doc_installation.md | 44 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/documentation/doc_installation.md b/documentation/doc_installation.md index 83b58dd6..6bdbaba8 100644 --- a/documentation/doc_installation.md +++ b/documentation/doc_installation.md @@ -1,6 +1,6 @@ # Installation Guide -### PRESTUS installation +## PRESTUS installation Download and install these tools: @@ -35,7 +35,27 @@ git submodule init git submodule update ``` -### SimNIBS 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. + +For more information on HPC usage, see the [HPC guide](doc_hpc.md) + + +If you want to get started with simulations, you can use the PRESTUS example dataset. This command will copy the dataset to your home directory: + +``` +cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} +``` + +#### [Optional: Download C++ binaries] + +For the `cpp_cpu` and `cpp_gpu` modes, download the C++ binaries from the [k-Wave website](http://www.k-wave.org/download.php) and place them into `k-Wave/binaries/`; see also the instructions [here](http://www.k-wave.org/documentation/kspaceFirstOrder3DC.php). + +## SimNIBS installation You additionally need to install SimNIBS (https://simnibs.github.io/simnibs/build/html/index.html#simnibs-4): e.g., `simnibs_installer/install -s -t /home/USER/SimNIBS`. We recommend installing SimNIBS within an **anaconda environment** (especially on HPC clusters that often constrict individual user permissions). @@ -76,24 +96,4 @@ In PRESTUS, specify either in the config or directly in MATLAB both the path to ``` parameters.simnibs_bin_path = fullfile('/home', 'neuromod', 'USER', '.conda', 'envs', 'simnibs_env', 'bin'); parameters.ld_library_path = "/opt/gcc/7.2.0/lib64"; -``` - -#### [Optional: Download C++ binaries] - -For the `cpp_cpu` and `cpp_gpu` modes, download the C++ binaries from the [k-Wave website](http://www.k-wave.org/download.php) and place them into `k-Wave/binaries/`; see also the instructions [here](http://www.k-wave.org/documentation/kspaceFirstOrder3DC.php). - - -#### 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. - -For more information on HPC usage, see the [HPC guide](doc_hpc.md) - - -If you want to get started with simulations, you can use the PRESTUS example dataset. This command will copy the dataset to your home directory: - -``` -cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} ``` \ No newline at end of file From fcb29249b7ed0e68021ed717fc1abaa5c51bd8a6 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 13:46:34 +0100 Subject: [PATCH 031/144] [doc] update readmes --- .github/workflows/mkdocs.yml | 2 +- README.md | 30 ++++++------------------------ documentation/README.md | 4 ++++ 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 80341e1e..4cdff645 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -1,7 +1,7 @@ name: Deploy PRESTUS Docs on: push: - branches: [ development, master ] + branches: [ development, main ] workflow_dispatch: permissions: diff --git a/README.md b/README.md index ed705133..d82ea627 100755 --- a/README.md +++ b/README.md @@ -18,36 +18,16 @@ 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. - -For more information on HPC usage, see the [HPC guide](documentation/doc_hpc.md) +# Documentation - -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., Carpino, E., van der Zee, K., & Verhagen, L. (2025). PRESTUS (0.5.0). Zenodo. https://doi.org/10.5281/zenodo.15965832 # Contributors @@ -77,6 +57,8 @@ Chetverikov, A.\*, Kosciessa, J. Q.\*, Cornelissen, M., Carpino, E., van der Zee +If you would like to assist the community development of this tool, please consider [CONTRIBUTING](CONTRIBUTING.md). + # License Released under GNU General Public License v3.0 (see LICENSE). diff --git a/documentation/README.md b/documentation/README.md index 62201a0f..2e5114a3 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -25,6 +25,10 @@ A recent overview poster can be found [here](https://jkosciessa.github.io/downlo Follow the [installation guide](doc_installation.md). +### Versions + +PRESTUS is under active development. The default `main` branch is considered more stable. The [`development` branch](https://github.com/Donders-Institute/PRESTUS/tree/development) may contain both new features and stability updates, but is subject to more dynamic changes. [Releases](https://github.com/Donders-Institute/PRESTUS/releases) bundle major updates and are associated with a version number. Starting in PRESTUS v0.5 logs will print the git hash when PRESTS has been cloned as a git repository. + # License Released under GNU General Public License v3.0 (see LICENSE). From 013a6b6ffc847bb333372ffbefc3b6c387cfa990 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 14:53:01 +0100 Subject: [PATCH 032/144] [doc] image integration --- documentation/README.md | 2 ++ documentation/doc_backend.md | 1 + documentation/doc_calibration.md | 6 ++++++ documentation/doc_installation.md | 2 ++ 4 files changed, 11 insertions(+) diff --git a/documentation/README.md b/documentation/README.md index 2e5114a3..5ebcbeaf 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -4,6 +4,8 @@ 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. +![PRESTUS logo](https://github.com/jkosciessa/PRESTUS_bin/blob/main/img/logo_PRESTUS.png){width="70%" .left} + Key features include: - Automated MRI segmentation (using SimNIBS 4 charm) and preprocessing. diff --git a/documentation/doc_backend.md b/documentation/doc_backend.md index 05ac4040..50c3cde5 100644 --- a/documentation/doc_backend.md +++ b/documentation/doc_backend.md @@ -19,4 +19,5 @@ To use C++ with GPU support you may need to recompile the binary for your GPU ve - Download the [LINUX source files for kWave 1.3](http://www.k-wave.org/download.php). This includes the Makefile to compile the binary. - To compile the binaries, you can follow the instruction by [TU Delft](https://qiweb.tudelft.nl/sysman/kwave_hpc.html). Note that you need to include your GPU type in the Makefile. - Example scripts for compiling binaries on the Donders HPC can be found in `PRESTUS/examples/hpc_compile...` +- For the Donders HPC, you can download a precompiled binary [here](https://github.com/jkosciessa/PRESTUS_bin/raw/refs/heads/main/donders_kwave_bin/kspaceFirstOrder-CUDA). - Place the updated binary into `PRESTUS/toolboxes/k-Wave/binaries/` \ No newline at end of file diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index a63bf727..3937d7c6 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -1,3 +1,9 @@ +## Transducer definition + +![PRESTUS transducer distance definitions](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/transducer_distances.png){width="70%" .center} + +The figure shows canonical PRESTUS definitions for multi-element transducer calibration. + ## Calibration of multi-element transducers To emulate the actual transducers for the use in simulations, we initially have to optimise the virtual transducers such that they match the output we obtain in free water. The acoustic profile will vary between different transducers, as well as for different focal depth settings for any given transducer. Additionally, the amplitude of stimulation that yields the desired output intensity needs to be estimated. As such, calibration of amplitude will also be specific to the desired free water ISPPA. diff --git a/documentation/doc_installation.md b/documentation/doc_installation.md index 6bdbaba8..95d77d70 100644 --- a/documentation/doc_installation.md +++ b/documentation/doc_installation.md @@ -55,6 +55,8 @@ cp /opt/prestus/example_data/PRESTUS_example_data.zip ${HOME} For the `cpp_cpu` and `cpp_gpu` modes, download the C++ binaries from the [k-Wave website](http://www.k-wave.org/download.php) and place them into `k-Wave/binaries/`; see also the instructions [here](http://www.k-wave.org/documentation/kspaceFirstOrder3DC.php). +For more information on installing, (potentially) compiling, and specifying C++ binaries, see [the backend documentation](doc_backend.md). + ## SimNIBS installation You additionally need to install SimNIBS (https://simnibs.github.io/simnibs/build/html/index.html#simnibs-4): e.g., `simnibs_installer/install -s -t /home/USER/SimNIBS`. From a02cc42bcef736e3ca0fb2394d65b148cb545e62 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 16:57:12 +0100 Subject: [PATCH 033/144] [doc] update documentation pages --- configs/default_config.yaml | 2 +- documentation/README.md | 4 +- documentation/doc_calibration.md | 62 +++++++++++++++---- documentation/doc_config.md | 7 ++- documentation/doc_functions.md | 2 +- ...ting-started.md => doc_getting-started.md} | 29 ++++----- .../{doc_group-plots.md => doc_group.md} | 15 +++-- documentation/doc_heuristic-placement.md | 25 -------- documentation/doc_medium.md | 34 +++++----- documentation/doc_neuronav.md | 4 +- documentation/doc_placement.md | 60 ++++++++++++++++++ documentation/doc_preproc.md | 37 +++++++++++ documentation/doc_simulations-acoustic.md | 10 +-- documentation/doc_simulations-thermal.md | 42 ++++++------- documentation/doc_troubleshooting.md | 1 + mkdocs.yaml | 7 ++- 16 files changed, 234 insertions(+), 107 deletions(-) rename documentation/{doc_simulations-getting-started.md => doc_getting-started.md} (60%) rename documentation/{doc_group-plots.md => doc_group.md} (84%) delete mode 100644 documentation/doc_heuristic-placement.md create mode 100644 documentation/doc_placement.md create mode 100644 documentation/doc_preproc.md diff --git a/configs/default_config.yaml b/configs/default_config.yaml index a1050b2e..11e555cf 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -249,7 +249,7 @@ smooth_threshold_other: 0.5 # (higher threshold -> thinner mask) smooth_window: 5 # Number of voxels for smoothing # Settings for shrinkwrap (if requested) -skullfillmethod: 'imclose' # Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] +skullfillmethod: 'rubberwrap' # Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] wrapradius: 10 # skull rubber wrap radius [grid voxels]; Bigger = tighter rubber that ignores bigger dents (recommendaton: 2 to 10) skullwrap_visualize: 0 # Visualize results of skullwrap? (Currently causes issues on HPC) diff --git a/documentation/README.md b/documentation/README.md index 5ebcbeaf..723264ec 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -1,11 +1,11 @@ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.15095860.svg)](https://doi.org/10.5281/zenodo.15095860) +![PRESTUS logo](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/logo_PRESTUS.png) + # PRESTUS: PREprocessing & Simulations for Transcranial Ultrasound Stimulation 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. -![PRESTUS logo](https://github.com/jkosciessa/PRESTUS_bin/blob/main/img/logo_PRESTUS.png){width="70%" .left} - Key features include: - Automated MRI segmentation (using SimNIBS 4 charm) and preprocessing. diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index 3937d7c6..b0e0de59 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -1,20 +1,59 @@ ## Transducer definition -![PRESTUS transducer distance definitions](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/transducer_distances.png){width="70%" .center} +### Transducer specification -The figure shows canonical PRESTUS definitions for multi-element transducer calibration. +PRESTUS generally does not provide default transducer settings. Instead, it encourages users to set up transducers and optimize settings to match their empirical setup. + +See the full [parameter documentation](doc_config.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**] + +#### Placement parameters + +See also [transducer placement](doc_placement.md). + +`transducer.trans_pos` | Position of transducer bowl (XYZ, T1 grid voxel space). +`transducer.focus_pos` | Position of stimulation target (XYZ, T1 grid voxel space). + +#### Target distance parameters + +![PRESTUS transducer distance definitions](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/transducer_distances.png) + +The figure shows PRESTUS definitions for annular transducer distances. + +| `expected_focal_distance_ep` | Expected distance from the transducer exit plane to the stimulation focus (in mm). | +| `expected_focal_distance_bowl` | Expected distance from the transducer bowl to the stimulation focus (in mm). | + +#### Modeling parameters + +| `use_kWaveArray` | Use the kWaveArray class for modeling transducers. | ## Calibration of multi-element transducers -To emulate the actual transducers for the use in simulations, we initially have to optimise the virtual transducers such that they match the output we obtain in free water. The acoustic profile will vary between different transducers, as well as for different focal depth settings for any given transducer. Additionally, the amplitude of stimulation that yields the desired output intensity needs to be estimated. As such, calibration of amplitude will also be specific to the desired free water ISPPA. +To emulate transducers for use in simulations, PRESTUS aims to optimise virtual transducers such that they produce focal axis profiles similar to those measured in free water. The acoustic profile will vary between different transducers, as well as for different focal depth settings for any given transducer. Additionally, the amplitude of stimulation that yields the desired output intensity needs to be estimated. As such, calibration of amplitude will also be specific to the desired free water ISPPA. The calibration relies on additional config (`calibration_config`) that should be loaded as `parameters.calibration` (see the example script below). ### Workflow 1: Find calibration settings for one (or multiple) transducer-depth settings [standalone] -Script: `acoustic_profiling_standalone` +Script: `calibration_standalone` -The above script provides a one-click solution to generate calibrated profiles when using the transducer equipment at the Donders Institute. +The above script sets up and generate calibrated profiles when using the transducer equipment at the Donders Institute. - Prerequisites: - Manufacturer-provided phase tables @@ -26,30 +65,31 @@ The above script provides a one-click solution to generate calibrated profiles w - automatically load Donders-specific transducer information - set initial simulation to manufacturer data - interpolate requested distance from multiple empirically measured distances + - add Focal Distance Offset (FDO) to profile ### Workflow 2: Dynamically update calibration settings (e.g., when iterating across settings) -Function: `acoustic_profiling` +Function: `calibration_transducer` - Use cases: - More limited information is available (e.g., only the axial profile is known) - Calibration should be performed dynamically as part of a simulation loop (e.g., for sweeping an amplitude-distance parameter space) - Prerequisites: - - `profile_empirical.profile_focus` - Measured or theoretical intensity profile along beam axis - - `profile_empirical.dist_from_tran` - Distance (mm from transducer reference point) - - `profile_empirical.focus_wrt_exit_plane` - Corresponding focal distance (mm from transducer exit plane) + - `profile_empirical.profile_focus` - Desired intensity profile along focal beam axis + - `profile_empirical.dist_from_tran` - Distance (mm from transducer bowl) + - `profile_empirical.focus_wrt_exit_plane` - Requested focal distance (mm from transducer exit plane) ### Steps: -**Script (`acoustic_profiling_standalone`):** +**Script (`examples/calibration_standalone`):** - Define and initialize the simulation environment by setting paths and loading configuration files with equipment and user calibration data. - Identify specific equipment combinations and extract associated transducer and driving system parameters, setting default initial amplitudes and phases. - Load measured characterization data of the actual transducer’s acoustic field including axial intensity profiles and phase information provided by manufacturers. - Translate experimentally measured focal depths relative to the transducer exit plane into simulation-relevant coordinates centered on the transducer’s mid-bowl to align measurement and simulation references. - Select or interpolate the axial intensity profiles for the specified focal depth. -**Function (`acoustic_profiling`):** +**Function (`calibration_transducer`):** - Run free-water simulation. - Optimize the virtual transducer’s element amplitudes and phases so that its simulated acoustic field matches the measured and scaled transducer profile. - Confirm via free-water simulation with optimized virtual transducer settings. \ No newline at end of file diff --git a/documentation/doc_config.md b/documentation/doc_config.md index 7054e2e7..13713a66 100644 --- a/documentation/doc_config.md +++ b/documentation/doc_config.md @@ -1,4 +1,4 @@ -## PRESTUS configuration documentation +## PRESTUS configuration The following documents the parameters used in PRESTUS. The specification philosophy is the following: `default_config.yaml` provides a list of all parameters with default settings and will be read in first by the function `load_parameters`. This default configuration file should not be changed in standard applications to ensure that necessary fields are provided. @@ -24,8 +24,8 @@ To set up a specific application, an additional `config_.yaml` should be | **Parameter** | **Description** | **Comments** | |-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `simulation_medium` | Medium setup for simulation (`water` or `layered`). | Mandatory. | -| ` ` | Labels for layered simulation, defining mask indices for different tissue types. | Mandatory. | +| `simulation_medium` | Medium setup for simulation (`water`/`layered`/`phantom`). | Mandatory. | +| `layers` | Labels for layered simulation, defining mask indices for different tissue types. | Mandatory. This parameter allows adding or removing layers of interest (if the corresponding segmenation is available and `parameters.medium` has a corresponding label containing the acoustic properties). | | `seg_labels` | Labels for segmentations, specifying indices for CSF, bone mask, and eye regions. | Mandatory. | | `run_source_setup` | Set up acoustic sources. | (`1 = yes, 0 = no`) | | `run_acoustic_sims` | Run acoustic simulations. | (`1 = yes, 0 = no`) | @@ -62,6 +62,7 @@ To set up a specific application, an additional `config_.yaml` should be | `transducer.dist_to_plane_mm` | Distance from the geometric focus to the transducer plane (in mm). | | | `transducer.source_amp` | Amplitude of the acoustic source (in Pa). | Must be calibrated. | | `transducer.source_phase_deg` | Phase of the acoustic source (in degrees). | Must be calibrated. | +| `transducer.source_phase_rad` | Phase of the acoustic source (in radians). | Must be calibrated. | | `transducer.trans_pos` | Position of transducer bowl (XYZ, T1 grid voxel space). | | | `transducer.focus_pos` | Position of stimulation target (XYZ, T1 grid voxel space). | | | `expected_focal_distance_ep` | Expected distance from the transducer exit plane to the stimulation focus (in mm). | Transducer depth setting [Either `expected_focal_distance_ep`, `expected_focal_distance_bowl`, or [`transducer.focus_pos` and `transducer.trans_pos`] have to be specified.] | diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index 16d8f46b..8ef88259 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -1,4 +1,4 @@ -## PRESTUS function documentation +## PRESTUS functions The following documents the functions provided in PRESTUS. diff --git a/documentation/doc_simulations-getting-started.md b/documentation/doc_getting-started.md similarity index 60% rename from documentation/doc_simulations-getting-started.md rename to documentation/doc_getting-started.md index c0da53d6..1ad5d52a 100644 --- a/documentation/doc_simulations-getting-started.md +++ b/documentation/doc_getting-started.md @@ -1,35 +1,36 @@ -# How to design your own processing pipeline +# Getting started -### install packages (ensure the paths and subfolders are added in Matlab) +### 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) +### [Optional] 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 +### 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` +### Specify a transducer and target location +Specify locations via `parameters.transducer.trans_pos` and `parameters.transducer.focus_pos`. -### choose a simulation medium +See [placement documentation](doc_placement.md). -- see [Medium Setup](doc_medium.md) +### Choose simulation medium -### choose a simulation type +See [Medium Setup](doc_medium.md). -- see [Backend](doc_backend.md) +### Choose simulation type -### 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). +See [Backend](doc_backend.md). + +### Run the single_subject_pipeline + +When the configuration is completely specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `single_subject_pipeline` can be submitted using high performance computing jobs (see [HPC documentation](doc_hpc.md)). diff --git a/documentation/doc_group-plots.md b/documentation/doc_group.md similarity index 84% rename from documentation/doc_group-plots.md rename to documentation/doc_group.md index 0400a2bd..ea604f50 100644 --- a/documentation/doc_group-plots.md +++ b/documentation/doc_group.md @@ -1,22 +1,27 @@ -# How to use the 'create_group_MNI_plots.m' script -## Reasons for using this function +# Group analysis + +To facilitate group analysis, PRESTUS automatically outputs key results as Nifti images in MNI space. Projection to MNI space can be done either using SimNIBS’ subject2mni command, or in MATLAB by reading in MNI2conform_12DOF.txt. + +### create_group_MNI_plots + +#### Rationale - This will ensure that all figures will use the same scale - Allows one to add a ROI MNI mask to visualise targeting accuracy - Adds additional columns to each csv with information about intensity in the ROI mask, percentage of fwhm voxels within ROI etc. - When using structural MRI's, it also allows one to normalise the brightness so the discrepancy in average brightness between individual T1's is reduced. -## Necessary input: +#### Necessary input: - subject_list: list of all subjects that you want to include in the figures. - parameters: load the '.yaml' file using 'load_parameters.m' just as you would do in a pipeline. - options: a structure similar to 'parameters'. Most of these are (as the name would suggest) optional. only one of the two following parameters needs to be selected: - options.slice_to_plot = 0 (give the number of the slice) - options.plot_max_intensity = 0 (turn on by changing to 1) -## Plotting a ROI mask +#### Plotting a ROI mask - Use a mask saved in MNI space - Load it into the function under the option 'ROI_MNI_mask' -## Optional parameters: +##### Optional parameters: options.ROI_MNI_mask (:,:,:) # Loads in a 3d matrix of your ROI mask (in MNI space) options.slice_label = 'y' # Selects the axis along which your slice is made options.rotation = 90; # Rotates your structural background figure diff --git a/documentation/doc_heuristic-placement.md b/documentation/doc_heuristic-placement.md deleted file mode 100644 index eb19d951..00000000 --- a/documentation/doc_heuristic-placement.md +++ /dev/null @@ -1,25 +0,0 @@ -# Heuristic transducer placement - -The function ```transducer_positioning``` identifies heuristic locations for transducer placement. - -The function targets brain regions specified by MNI coordinates, converting them to subject-native space using SimNIBS tools. It aims to identify candidate transducer positions where the geometric focus aligns with a target within a user-defined focal distance range, while ensuring >50% aperture overlaps skull/skin and low variance in distances. For each position, it computes the intersection proportion between the transducer and skin, mean/variance distances to skin and skull, and geometric focus/exit plane positions to ensure efficient coupling while minimizing aberrations. - -## Coordinate table - -The `tpars_sub-XXX_target.csv` table contains transducer position candidates with the following columns. This information can be used to select a suitable candidate (e.g., by selecting a position with low intersection of the transducer and head tissue (prop_intersect < 0.05) and minimal Euclidean distance to the target; often supported by visual inspection). - -| Column | Description | -|-----------------|------------------------------------------------| -| idx | index | -| trans_x | Transducer x-coordinate (voxels) | -| trans_y | Transducer y-coordinate (voxels) | -| trans_z | Transducer z-coordinate (voxels) | -| targ_x | Target x-coordinate (voxels) | -| targ_y | Target y-coordinate (voxels) | -| targ_z | Target z-coordinate (voxels) | -| dist_to_target | Euclidean distance transducer-to-target (voxels)| -| prop_intersect | Proportion of transducer volume intersecting head | -| meandistskin | Mean distance to skin surface in aperture plane (voxels) | -| vardistskin | Variance of distances to skin (voxels²) | -| meandistskull | Mean distance to skull surface in aperture plane (voxels)| -| vardistskull | Variance of distances to skull (voxels²) | diff --git a/documentation/doc_medium.md b/documentation/doc_medium.md index 5deb221a..d650220b 100644 --- a/documentation/doc_medium.md +++ b/documentation/doc_medium.md @@ -1,12 +1,13 @@ -# Specifying homo- or heterogeneous medium properties +# Medium properties -### Choose a simulation medium +### Specifying a simulation medium -PRESTUS supports various medium configurations. These can be specified with ```parameter.simulation_medium```. The following options are supported out of the box. +PRESTUS supports homo- and heterogeneous medium configurations. These can be specified with `parameter.simulation_medium`. The following options are supported out of the box. - ```water``` - Places a transducer into homogeneous water tissue. + Places a transducer into homogeneous water tissue (at the inner edge of the requested PML layer). +
- ```layered``` @@ -18,24 +19,27 @@ PRESTUS supports various medium configurations. These can be specified with ```p - cortical skull bone - trabecular skull bone - Tissue can be removed (or added if more detailed segmentations and tissue properties are included) via the ```layers``` and ```medium``` configuration fields. The skull layer of a layered simulation can be informed by (pseudo-)CT images (see ```doc_pseudoCT```). This is a subtype of a layered simulation with a single skull layer. - -- ```phantom``` + Tissue can be removed (or added if more detailed segmentations and medium acoustic properties are included) via the ```layers``` configuration fields. The skull layer of a layered simulation can be informed by (pseudo-)CT images (see ```doc_pseudoCT```). The pCT implementation is a subtype of a `layered` simulation with a unified skull layer. If the segmentation for any requested layer is not available, requested layers will be removed from the specification. The output HTML provides an overview of which layers were modeled, along with their acoustic properties. + + `Layered` media will be [preprocessed](doc_preproc.md) during grid setup. +
- A 3D imaging-based simulation is not always necessary. For benchmarking, one may for instance externally design a 2D phantom, and run simulations in a well-defined space. As the 2D phantom is explicitly designed, no preprocessing (e.g., rotation, cropping, etc.) is necessary. Using the ```phantom``` flag allows the same tissue flexibility as the ```layered``` version, but maps segmentation files onto medium masks without any further image processing. The 2D segmentation phantom must be provided as a ```final_tissues.nii.gz``` file in a (dummy) SimNIBS output folder. +- ```phantom``` -PRESTUS allows for a flexible specification of segmentation layers to media to which acoustic properties are allocated. This is controlled by `parameters.layers`. If a segmentation for the requested layer is not available, requested layers will be removed from the specification. All segmentation ids that are not explicitly specified as other layers are automatically included in a water layer. + A 3D imaging-based simulation is not always necessary. For debugging and benchmarking, one may for instance externally design a 2D phantom, and run simulations in a well-defined space. As the 2D phantom is explicitly designed, no preprocessing (e.g., rotation, cropping, etc.) is necessary. Using the ```phantom``` flag allows the same tissue flexibility as the ```layered``` version, but maps segmentation files onto medium masks without any further image processing. The 2D segmentation phantom must be provided as a ```final_tissues.nii.gz``` file in a (dummy) SimNIBS output folder. + + A `phantom` Nifti is expected to contain artificial layers of a SimNIBS segmentation, including the PML padding. +
-To specify a `phantom`, indicate artificial layers of a SimNIBS segmentation. +### Default acoustic properties -Internally computed medium maps will contain indices corresponding to the layer position in the medium properties. This allows for flexibility in layer specifications with stable medium ids (unless `parameters.medium` is edited). +![acoustic_properties](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/acoustic_properties.png) -### Default medium properties +Accurate modeling of wave propagation through heterogeneous media requires valid assignment of acoustic parameters such as speed of sound, density, and attenuation to the separate tissue media. PRESTUS provides defaults based on the literature, while providing flexibility to alter assigned parameter values, e.g., to implement more conservative or liberal assumptions. -The following are the current default medium properties in a full layered simualtion. Parameters are specified in the default_config.yaml file (see doc_config.md). -When a homogeneous skull layer is requested, the default assumes the medium properties of cortical bone. +The following are the current default medium properties in a full layered simulation. Parameters are specified in the default_config.yaml file (see doc_config.md). -| Tissue | Density [kg/m³] | Sound Speed [m/s] | Attenuation Coefficient [dB/(cm·MHzy)] | Attenuation Power Law (y)| Thermal Conductivity [W/(m·K)]| Specific Heat [J/(kg·K)] | Perfusion [mL/min/kg] | Absorption Fraction [Note1] | +| Tissue | Density [kg/m³] | Sound Speed [m/s] | Attenuation Coefficient [dB/(cm·MHzy)] | Attenuation Power Law (y)| Thermal Conductivity [W/(m·K)]| Specific Heat [J/(kg·K)] | Perfusion [mL/min/kg] | Absorption Fraction [*Note1*] | |-----------------------|--------|----------|----------|-------|--------|------|-------|---| | Water | 994 | 1500 | 0.00217 | 2 | 0.6 | 4178 | 0 | 1 | | Brain | 1046 | 1546 | 0.59 | 1.2 | 0.51 | 3630 | 559 | 1 | diff --git a/documentation/doc_neuronav.md b/documentation/doc_neuronav.md index 20cfc88e..4818473b 100644 --- a/documentation/doc_neuronav.md +++ b/documentation/doc_neuronav.md @@ -1,10 +1,12 @@ +# 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. Multiple triggers can be sent during stimulation to acquire updates to the transducer location over time. Recorded locations will be recorded by localite to files named `TMSTrigger/TriggerMarkers_Coil0_'. Recorded locations will be averaged within a position (associated with a continuous time stamp stream). Multiple positions per recording session are supported. Average transducer and target locations can be provided in multiple coordinate spaces (native image space being the most relevant for PRESTUS). The functions also facilitate standard-space definitons (experimental), e.g., to plot average transducer positions in standard space. -To get started, see an example demo (wihtout provided data) in '/examples/demo_localite.m'. +To get started, see an example demo (wihtout provided data) in `/examples/demo_localite.m`. ### Relevant spaces and coordinate systems diff --git a/documentation/doc_placement.md b/documentation/doc_placement.md new file mode 100644 index 00000000..a22562cc --- /dev/null +++ b/documentation/doc_placement.md @@ -0,0 +1,60 @@ +# Transducer placement + +Simulations in free water will place the transducer in a homogeneous water medium without the need to specify a target. For head smulations, any transducer needs to be positioned close to the scalp (possibly at some distance to allow for coupling), and a target needs to be specified. + +PRESTUS expects coordinates of both the transducer (`transducer.trans_pos`) and target (`transducer.focus_pos`) to be specified as voxels in the T1w grid. The transducer coordinate describes the bowl of the transducer (i.e., not the exit plane in the case of curved transducers). Both coordinates need to be reported in the space of the planning image. + +## Manual coordinate selection + +Suitable coordinates (x/y/z) may be identified in preferred imaging software based on e.g. an fMRI hotspot, anatomical marker. + +## Heuristic coordinate selection + +The function ```transducer_positioning``` identifies heuristic locations for transducer placement. + +The function targets brain regions specified by MNI coordinates, converting them to subject-native space using SimNIBS tools. It aims to identify candidate transducer positions where the geometric focus aligns with a target within a user-defined focal distance range, while ensuring >50% aperture overlaps skull/skin and low variance in distances. For each position, it computes the intersection proportion between the transducer and skin, mean/variance distances to skin and skull, and geometric focus/exit plane positions to ensure efficient coupling while minimizing aberrations. Besides target MNI coordinates, the function does not require manual intervention, enabling automatic end-to-end workflows. + +Outside of PRESTUS, alternative tools such as [PlanTUS](https://github.com/mlueckel/PlanTUS) can be used to manually identify candidate transducer locations based on a broader range of criteria. + + +### Coordinate table + +The `tpars_sub-XXX_target.csv` table contains transducer position candidates with the following columns. This information can be used to select a suitable candidate (e.g., by selecting a position with low intersection of the transducer and head tissue (prop_intersect < 0.05) and minimal Euclidean distance to the target; often supported by visual inspection). + +| Column | Description | +|-----------------|------------------------------------------------| +| idx | index | +| trans_x | Transducer x-coordinate (voxels) | +| trans_y | Transducer y-coordinate (voxels) | +| trans_z | Transducer z-coordinate (voxels) | +| targ_x | Target x-coordinate (voxels) | +| targ_y | Target y-coordinate (voxels) | +| targ_z | Target z-coordinate (voxels) | +| dist_to_target | Euclidean distance transducer-to-target (voxels)| +| prop_intersect | Proportion of transducer volume intersecting head | +| meandistskin | Mean distance to skin surface in aperture plane (voxels) | +| vardistskin | Variance of distances to skin (voxels²) | +| meandistskull | Mean distance to skull surface in aperture plane (voxels)| +| vardistskull | Variance of distances to skull (voxels²) | + +## Neuronavigation coordinate selection + +PRESTUS provides helper functions to read-in coordinates acquired with neuronavigation systems. Currently, PRESTUS supports read-in of localite positions. + +An example file (`examples/demo_localite.m`) is provided. + +The example script `demo_localite.m` highlights a workflow for extracting localite transducer positions. + +- Based on the study design, localite may encode the positions from multiple stimulations in the same output file. For a requested session, `neuronav_select_and_average_localite` selects the latest available Localite trigger XML file. +- In a localite session, triggers may be repeatedly acquired during the full pulse train repetition duration. `neuronav_compute_series_statistics` computes statistics across stimulus series (using a user-defined voxel size and user-defined trigger trains). +- From these extracted metrics, `neuronav_create_marker_averags` generates averaged marker positions for transducer and target locations. +- These are converted from Localite tracker space to native voxel indices and RAS coordinates aligned with the planning T1 image (`neuronav_convert_trigger_to_voxels`). +- Optionally, coordinates can be transformed to MNI space via SIMNIBS registration matrices (`neuronav_convert_native_to_MNI`). + +Results are exported to a CSV file in the Localite data folder, ready for PRESTUS simulation input. + +> **Alternative localite read-in.** +> PRESTUS currently allows an alternative Localite read-in using the following parameters. This is not yet documented. +| `transducer_from_localite` | Load transducer position from Localite files?. | +| `reference_transducer_distance_mm` | Distance from tracker to transducer exit plane (in mm). | + diff --git a/documentation/doc_preproc.md b/documentation/doc_preproc.md new file mode 100644 index 00000000..6b61678f --- /dev/null +++ b/documentation/doc_preproc.md @@ -0,0 +1,37 @@ +# Head preprocessing + +PRESTUS preprocesses `layered` segmentations to set up simulation grids. + +![preprocessing](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/preprocessing.png) + +### Orientation + +PRESTUS initially creates a grid by orienting the segmentation to the focal plane of the first transducer. In multi-transducer setups, alignment is done to the first specified transducer. Specifically, `preproc_align_to_focal_axis` performs a two-step rotation to align an arbitrary focal axis (defined by transducer and focus positions) with the z-axis of the coordinate system. This prepares subject-specific head models for ultrasound neuromodulation simulations by standardizing the acoustic propagation direction along z. + +### Scaling + +The 3D planning image is then resliced to the requested grid resolution by isotropic scaling and centered regridding. These steps are applied to the planning image, the segmentation, and the bone mask (or (p)CT, if specified). For masks, nearest neighbor interpolation is used, for continuous values (e.g., from a (pseudo-)CT, linear interpolation is applied). + +### Medium Mapping (incl. segmentation smoothing) + +Segmentations are mapped onto the requested layer medium masks (to later assign acoustic properties). The standard (10-tissue) SimNIBS 4 segmentation will be converted into the (by default 5) specified layers (controlled by `parameters.layers`). If a segmentation for the requested layer is not available, requested layers will be removed from the specification. All segmentation IDs that are not explicitly assigned in dedicated layers are automatically included in a baseline water layer. Medium maps contain layer indices corresponding to the respective layer label position in the medium properties (`parameters.medium`). This allows for flexibility in requested layers with stable medium ids (unless `parameters.medium` is edited). + +Each layer is smoothed using one of the algorithms below. The size of smoothing kernels refer to the grid size of the medium mask, NOT the original dimensions of the planning image. If a multi-layer skull model is requested, both cortical and trabecular layers are initially combined into a skull segment and smoothed. Subsequently, the trabecular bone layer is smoothed and added. + +Multiple smoothing algorithms are available: +- `box` applies a uniform convolution, which is fastest, but also blocky and prone to skull holes at low binarization thresholds (default: 5-voxel kernel) +- `gaussian` (default) uses smooth3 for isotropic blurring, yielding smooth gradients and moderate edge erosion (default: 5-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 conrolled by `skullfillmethod`. 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. The CSF segmentation (if available) will be expanded by `csf_mask_expansion_factor` mm to guide bounds of the layered medium. Water layer will be assigned outside of the crop mask. 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). diff --git a/documentation/doc_simulations-acoustic.md b/documentation/doc_simulations-acoustic.md index 4fed4ec9..7af5f879 100644 --- a/documentation/doc_simulations-acoustic.md +++ b/documentation/doc_simulations-acoustic.md @@ -1,16 +1,16 @@ -# Performing acoustic simulations +# Aoustic simulations ## Axisymmetric simulations (3D simulations on 2D input setup) -[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. +[`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 (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. +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. 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-thermal.md b/documentation/doc_simulations-thermal.md index b17f948a..129b4407 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. diff --git a/documentation/doc_troubleshooting.md b/documentation/doc_troubleshooting.md index 78b23e31..081c76eb 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 diff --git a/mkdocs.yaml b/mkdocs.yaml index b28c8a41..dce87b46 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -39,11 +39,12 @@ nav: - 🏠 Home: README.md - 🚀 Getting Started: - Installation: doc_installation.md - - Getting started: doc_simulations-getting-started.md + - Getting started: doc_getting-started.md - 📖 Documentation: - Transducer Calibration: doc_calibration.md + - Head preprocessing: doc_preprocessing.md - Medium Setup: doc_medium.md - - Transducer Placement: doc_heuristic-placement.md + - Transducer Placement: doc_placement.md - Neuronavigation: doc_neuronav.md - Outputs: doc_outputs.md - 🔬 Simulations: @@ -57,7 +58,7 @@ nav: - Advanced Workflows: doc_advanced.md - Backend: doc_backend.md - HPC: doc_hpc.md - - Group Plots: doc_group-plots.md + - Group Plots: doc_group.md - 🆘 Troubleshooting: doc_troubleshooting.md - 📋 Community: - Code of Conduct: CODE_OF_CONDUCT.md From 4e7ad865d12715b7b64a70f05fe2c8757e25b58b Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 16:59:53 +0100 Subject: [PATCH 034/144] [doc] hotfixes --- documentation/doc_config.md | 2 +- mkdocs.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/doc_config.md b/documentation/doc_config.md index 13713a66..c3a6d5e0 100644 --- a/documentation/doc_config.md +++ b/documentation/doc_config.md @@ -1,4 +1,4 @@ -## PRESTUS configuration +## PRESTUS parameters The following documents the parameters used in PRESTUS. The specification philosophy is the following: `default_config.yaml` provides a list of all parameters with default settings and will be read in first by the function `load_parameters`. This default configuration file should not be changed in standard applications to ensure that necessary fields are provided. diff --git a/mkdocs.yaml b/mkdocs.yaml index dce87b46..ef82d2a3 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -42,7 +42,7 @@ nav: - Getting started: doc_getting-started.md - 📖 Documentation: - Transducer Calibration: doc_calibration.md - - Head preprocessing: doc_preprocessing.md + - Head preprocessing: doc_preproc.md - Medium Setup: doc_medium.md - Transducer Placement: doc_placement.md - Neuronavigation: doc_neuronav.md From e17dd01d17278eaba1696c8c645d6779beeeb1ab Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 17:01:11 +0100 Subject: [PATCH 035/144] [doc] beauty update --- mkdocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yaml b/mkdocs.yaml index ef82d2a3..240f039b 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -52,7 +52,7 @@ nav: - Thermal: doc_simulations-thermal.md - PseudoCT: doc_pseudoCT.md - 📚 References: - - Parameter Configuration: doc_config.md + - Parameters: doc_config.md - Functions: doc_functions.md - 🖥️ Advanced: - Advanced Workflows: doc_advanced.md From c583c59f40836dd6c2090e5a94910d9312c680c0 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 17:29:24 +0100 Subject: [PATCH 036/144] [pct] add example creation script --- examples/createPseudoCT.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 examples/createPseudoCT.sh 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 From ab9cc50562cde05b3bc05e552a3a690f791d658b Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 17:30:01 +0100 Subject: [PATCH 037/144] [doc] update documentation pages --- documentation/doc_backend.md | 4 +- documentation/doc_medium.md | 16 +++---- documentation/doc_pseudoCT.md | 59 +++++++++--------------- documentation/doc_simulations-thermal.md | 2 +- mkdocs.yaml | 4 +- 5 files changed, 34 insertions(+), 51 deletions(-) diff --git a/documentation/doc_backend.md b/documentation/doc_backend.md index 50c3cde5..71bbe336 100644 --- a/documentation/doc_backend.md +++ b/documentation/doc_backend.md @@ -1,6 +1,6 @@ -# Backend code implementation +# Simulation backend -PRESTUS allows code deployment using different computing setups (`parameters.code_type`). This will primarily affect acoustic simulations. With either of the below GPU variants, thermal simulations will also use GPU acceleration (if kWave 1.4.1 is provided). +PRESTUS allows k-Wave deployment using different computing setups (`parameters.code_type`). This will primarily affect acoustic simulations. With either of the GPU variants, thermal simulations will also use GPU acceleration (if k-Wave 1.4.1 is provided). - `matlab_cpu` - `kspaceFirstOrder3D` | `kspaceFirstOrder2D` | `kspaceFirstOrderAS` diff --git a/documentation/doc_medium.md b/documentation/doc_medium.md index d650220b..3244c2fb 100644 --- a/documentation/doc_medium.md +++ b/documentation/doc_medium.md @@ -1,15 +1,15 @@ # Medium properties -### Specifying a simulation medium +### Medium specification PRESTUS supports homo- and heterogeneous medium configurations. These can be specified with `parameter.simulation_medium`. The following options are supported out of the box. -- ```water``` +- `water` Places a transducer into homogeneous water tissue (at the inner edge of the requested PML layer).
-- ```layered``` +- `layered` Heterogeneous tissue composition based on charm segmentation. By default, the following media are included: @@ -24,22 +24,22 @@ PRESTUS supports homo- and heterogeneous medium configurations. These can be spe `Layered` media will be [preprocessed](doc_preproc.md) during grid setup.
-- ```phantom``` +- `phantom` A 3D imaging-based simulation is not always necessary. For debugging and benchmarking, one may for instance externally design a 2D phantom, and run simulations in a well-defined space. As the 2D phantom is explicitly designed, no preprocessing (e.g., rotation, cropping, etc.) is necessary. Using the ```phantom``` flag allows the same tissue flexibility as the ```layered``` version, but maps segmentation files onto medium masks without any further image processing. The 2D segmentation phantom must be provided as a ```final_tissues.nii.gz``` file in a (dummy) SimNIBS output folder. A `phantom` Nifti is expected to contain artificial layers of a SimNIBS segmentation, including the PML padding.
-### Default acoustic properties +### Medium acoustic properties ![acoustic_properties](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/acoustic_properties.png) Accurate modeling of wave propagation through heterogeneous media requires valid assignment of acoustic parameters such as speed of sound, density, and attenuation to the separate tissue media. PRESTUS provides defaults based on the literature, while providing flexibility to alter assigned parameter values, e.g., to implement more conservative or liberal assumptions. -The following are the current default medium properties in a full layered simulation. Parameters are specified in the default_config.yaml file (see doc_config.md). +The following default medium properties applt in a full layered simulation. Parameters are specified in the default_config.yaml file (see doc_config.md). While PRESTUS’s default parameters are chosen to align with commercially available tools and prior benchmarks, these should not be regarded as ground truths, and may need to be adjusted according to the goals at hand (e.g., a more conservative assessment of intensity and/or heating). -| Tissue | Density [kg/m³] | Sound Speed [m/s] | Attenuation Coefficient [dB/(cm·MHzy)] | Attenuation Power Law (y)| Thermal Conductivity [W/(m·K)]| Specific Heat [J/(kg·K)] | Perfusion [mL/min/kg] | Absorption Fraction [*Note1*] | +| Tissue | Density [kg/m³] | Sound Speed [m/s] | Attenuation Coefficient [dB/(cm·MHzy)] | Attenuation Power Law (y)| Thermal Conductivity [W/(m·K)]| Specific Heat [J/(kg·K)] | Perfusion [mL/min/kg] | Absorption Fraction | |-----------------------|--------|----------|----------|-------|--------|------|-------|---| | Water | 994 | 1500 | 0.00217 | 2 | 0.6 | 4178 | 0 | 1 | | Brain | 1046 | 1546 | 0.59 | 1.2 | 0.51 | 3630 | 559 | 1 | @@ -48,7 +48,7 @@ The following are the current default medium properties in a full layered simula | Skull (cortical) | 1850 | 2800 | 13.3 | 1 | 0.32 | 1313 | 10 | 0.28 | | Skull (trabecular) | 1700 | 2300 | 13.3 | 1 | 0.32 | 2274 | 30 | 0.28 | -[Note1] The fraction of attenuation (alpha) that is assumed to be absorbed in the bioheat equation (heating simulations). This is a best guess that is informed as follows: The bulk attenuation coefficient includes contributions from both longitudinal and shear waves. However, longitudinal waves contribute most (~ 80%, Wang et al., 2018) to absorption and heating in the skull (Pinton et al., 2012; White et al., 2006). For longitudinal and shear waves, absorption values of 2.7 dB/cm2/MHz, and 5.4 dB/cm2/MHz have been reported (Pinton et al., 2012), with a bulk attenuation of 13.3 dB/cm2/MHz. As such, we assume: absorption fraction = (longitudinal wave absorption + 0.2 * shear wave absorption) / bulk attenuation. This yields (2.7+0.2*5.4)/13.3 = 0.28. +> **Note: Absorption Fraction.** For thermal simulations, PRESTUS models the fraction of attenuation that is assumed to be absorbed in the bioheat equation. The current default estimate is informed as follows: The bulk attenuation coefficient includes contributions from both longitudinal and shear waves. However, longitudinal waves contribute most (~ 80%, Wang et al., 2018) to absorption and heating in the skull (Pinton et al., 2012; White et al., 2006). For longitudinal and shear waves, absorption values of 2.7 dB/cm2/MHz, and 5.4 dB/cm2/MHz have been reported (Pinton et al., 2012), with a bulk attenuation of 13.3 dB/cm2/MHz. As such, we assume: absorption fraction = (longitudinal wave absorption + 0.2 * shear wave absorption) / bulk attenuation. This yields (2.7+0.2*5.4)/13.3 = 0.28. This estimate of the bone absorption fraction (28%) is substantially lower than the default of 100% implemented in kWave, but larger than the default implemented in BabelBrain (16%). More measurements are needed to decrease uncertainty about the relative acoustic energy absorption in the skull layer. *References* diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 64d1ddef..0c9841fc 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -1,4 +1,4 @@ -# 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. @@ -7,14 +7,23 @@ PRESTUS supports the use of UTE-based images as a source of pseudo-Hounsfield un ### Creating a pseudoCT 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. 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. **pCT creation**. Run `pct_create_pseudoCT.sh` in bash. This calls the MATLAB function `pct_soft_tissue_peak` The pseudoCT and an associated mask file will be deposited in the `m2m` folder alongside the SimNIBS segmentation. To run the code you will need to install (or load the following modules): + - SimNIBS - FSL - ANTs @@ -36,11 +45,11 @@ The following steps are used to create the pseudoCT: - 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* @@ -48,35 +57,9 @@ The following steps are used to create the pseudoCT: - 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). +An example script to create a pseudoCT is provided at `examples/createPseudoCT.sh`. -The following is an example script that you can use for a `create_pseudoCT.sh` call. - -``` -#!/bin/bash - -rootpath="$(pwd)/.." -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}/tools/PRESTUS/functions -cd "${scriptpath}" || { echo "Directory not found"; exit 1; } - -# Source the script containing the function -source ${scriptpath}/create_pseudoCT.sh - -subject_id="001" # 'sub-' will automatically be added -m2m_path=${rootpath}/data/simnibs - -# Call the create_pseudoCT function -create_pseudoCT "$subject_id" "$m2m_path" "${scriptpath}" -``` - -### Starting acoustic + thermal simulations using pseudoCT +### Using (pseudo-)CTs to inform acoustic properties see [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43) diff --git a/documentation/doc_simulations-thermal.md b/documentation/doc_simulations-thermal.md index 129b4407..7dd38bab 100644 --- a/documentation/doc_simulations-thermal.md +++ b/documentation/doc_simulations-thermal.md @@ -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/mkdocs.yaml b/mkdocs.yaml index 240f039b..8818aa25 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -50,7 +50,7 @@ nav: - 🔬 Simulations: - Acoustic: doc_simulations-acoustic.md - Thermal: doc_simulations-thermal.md - - PseudoCT: doc_pseudoCT.md + - (pseudo)CT: doc_pseudoCT.md - 📚 References: - Parameters: doc_config.md - Functions: doc_functions.md @@ -58,7 +58,7 @@ nav: - Advanced Workflows: doc_advanced.md - Backend: doc_backend.md - HPC: doc_hpc.md - - Group Plots: doc_group.md + - Group: doc_group.md - 🆘 Troubleshooting: doc_troubleshooting.md - 📋 Community: - Code of Conduct: CODE_OF_CONDUCT.md From aea5acda257595a78709bcc3b7bd4d5aea458137 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 18:07:37 +0100 Subject: [PATCH 038/144] [doc] include examples --- documentation/doc_backend.md | 2 +- documentation/doc_medium.md | 2 +- documentation/doc_outputs.md | 26 +++++++++++++++----------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/documentation/doc_backend.md b/documentation/doc_backend.md index 71bbe336..88b3ef3d 100644 --- a/documentation/doc_backend.md +++ b/documentation/doc_backend.md @@ -16,7 +16,7 @@ PRESTUS allows k-Wave deployment using different computing setups (`parameters.c To use C++ with GPU support you may need to recompile the binary for your GPU version (e.g., for the A100 GPUs on the Donders HPC). -- Download the [LINUX source files for kWave 1.3](http://www.k-wave.org/download.php). This includes the Makefile to compile the binary. +- Download the [LINUX source files for k-Wave 1.3](http://www.k-wave.org/download.php). This includes the Makefile to compile the binary. - To compile the binaries, you can follow the instruction by [TU Delft](https://qiweb.tudelft.nl/sysman/kwave_hpc.html). Note that you need to include your GPU type in the Makefile. - Example scripts for compiling binaries on the Donders HPC can be found in `PRESTUS/examples/hpc_compile...` - For the Donders HPC, you can download a precompiled binary [here](https://github.com/jkosciessa/PRESTUS_bin/raw/refs/heads/main/donders_kwave_bin/kspaceFirstOrder-CUDA). diff --git a/documentation/doc_medium.md b/documentation/doc_medium.md index 3244c2fb..b4537c0d 100644 --- a/documentation/doc_medium.md +++ b/documentation/doc_medium.md @@ -48,7 +48,7 @@ The following default medium properties applt in a full layered simulation. Para | Skull (cortical) | 1850 | 2800 | 13.3 | 1 | 0.32 | 1313 | 10 | 0.28 | | Skull (trabecular) | 1700 | 2300 | 13.3 | 1 | 0.32 | 2274 | 30 | 0.28 | -> **Note: Absorption Fraction.** For thermal simulations, PRESTUS models the fraction of attenuation that is assumed to be absorbed in the bioheat equation. The current default estimate is informed as follows: The bulk attenuation coefficient includes contributions from both longitudinal and shear waves. However, longitudinal waves contribute most (~ 80%, Wang et al., 2018) to absorption and heating in the skull (Pinton et al., 2012; White et al., 2006). For longitudinal and shear waves, absorption values of 2.7 dB/cm2/MHz, and 5.4 dB/cm2/MHz have been reported (Pinton et al., 2012), with a bulk attenuation of 13.3 dB/cm2/MHz. As such, we assume: absorption fraction = (longitudinal wave absorption + 0.2 * shear wave absorption) / bulk attenuation. This yields (2.7+0.2*5.4)/13.3 = 0.28. This estimate of the bone absorption fraction (28%) is substantially lower than the default of 100% implemented in kWave, but larger than the default implemented in BabelBrain (16%). More measurements are needed to decrease uncertainty about the relative acoustic energy absorption in the skull layer. +> **Note: Absorption Fraction.** For thermal simulations, PRESTUS models the fraction of attenuation that is assumed to be absorbed in the bioheat equation. The current default estimate is informed as follows: The bulk attenuation coefficient includes contributions from both longitudinal and shear waves. However, longitudinal waves contribute most (~ 80%, Wang et al., 2018) to absorption and heating in the skull (Pinton et al., 2012; White et al., 2006). For longitudinal and shear waves, absorption values of 2.7 dB/cm2/MHz, and 5.4 dB/cm2/MHz have been reported (Pinton et al., 2012), with a bulk attenuation of 13.3 dB/cm2/MHz. As such, we assume: absorption fraction = (longitudinal wave absorption + 0.2 * shear wave absorption) / bulk attenuation. This yields (2.7+0.2*5.4)/13.3 = 0.28. This estimate of the bone absorption fraction (28%) is substantially lower than the default of 100% implemented in k-Plan, but larger than the default implemented in BabelBrain (16%). More measurements are needed to decrease uncertainty about the relative acoustic energy absorption in the skull layer. *References* diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index 7abdab27..043ed6fb 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -69,21 +69,25 @@ Images are provided in subject-space (```_orig_coord_```) and in MNI-152 space ( #### MATLAB structures -PRESTUS saves an overview of the parameters used to run the simulation and by default saves structures from acoustic and heating simulations. If these are detected in the results folder (and overwriting is deactivated), they will be loaded instead of performing the calculation. - -| Output | Description | -|-------------------------------------------------------|---------------------------------------------------------------------------| -| `sub-XXX__parameters.mat` | Unique identifier for the subject | -| `sub-XXX__parameters.mat` | Simulation parameters | -| `sub-XXX__kwave_source.mat` | k-Wave source \| parameters, kgrid, trans_pos_final, focus_pos_final | -| `sub-XXX__results.mat` | Acoustic simulation outputs [can be deactivated via `savemat = 0`] | -| `sub-XXX__heating_res.mat` | Thermal simulation outputs [can be deactivated via `savemat = 0`] | +PRESTUS saves an overview of the parameters used to run the simulation and by default saves structures from acoustic and heating simulations. If these are detected in the results folder (and overwriting is deactivated via `overwrite_files = never`), they will be loaded instead of performing the calculation. Matrix saving increases HDD space demands and can be globally deactivated via `savemat = 0`. Saving is automatically active as a part of free-water transducer calibration. +| Output | Description | +|-------------------------------------------------------|---------------------------------------| +| `sub-XXX__parameters.mat` | Simulation parameters [mandatory] | +| `sub-XXX_after_rotating_and_scaling.mat` | Head after grid scaling | +| `sub-XXX_after_cropping_and_smoothing.mat` | Cropped head incl. medium masks | +| `sub-XXX__kwave_source.mat` | k-Wave source | +| `sub-XXX__results.mat` | Acoustic simulation outputs | +| `sub-XXX__heating_res.mat` | Thermal simulation outputs | #### Figures -PRESTUS provides multiple figures for quick visual inspection and debugging. +PRESTUS provides multiple figures for quick visual inspection and debugging. For example, an overview of estimated thermal effects: + +![PRESTUS_fig_example_thermal](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/thermal_fig_examples.png) #### HTML -PRESTUS provides a summary HTML that provides safety-relevant parameters in a dashboard, summarizes parameters and medium-specific acoustic properties, prints the full log, and summarizes figures. \ No newline at end of file +PRESTUS provides a summary HTML that provides safety-relevant parameters in a dashboard, summarizes parameters and medium-specific acoustic properties, prints the full log, and summarizes figures. + +![PRESTUS_html](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/html_report.png) \ No newline at end of file From 6679702ec64de70ae0c473fccabb040c00a5ce01 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 18:09:11 +0100 Subject: [PATCH 039/144] [doc] attempt to link html --- documentation/doc_outputs.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index 043ed6fb..a8883e01 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -90,4 +90,14 @@ PRESTUS provides multiple figures for quick visual inspection and debugging. For PRESTUS provides a summary HTML that provides safety-relevant parameters in a dashboard, summarizes parameters and medium-specific acoustic properties, prints the full log, and summarizes figures. -![PRESTUS_html](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/html_report.png) \ No newline at end of file +![PRESTUS_html](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/html_report.png) + +A full example report on a phantom can be found here: + + + +**Raw source:** [example_report.html](https://raw.githubusercontent.com/jkosciessa/PRESTUS_bin/main/examples/example_report.html) From c96671218c27ff00bf2d7e34aba45ac606d46897 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 18:17:44 +0100 Subject: [PATCH 040/144] [doc] attempt to link html --- documentation/doc_outputs.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index a8883e01..8b6da0ea 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -92,12 +92,4 @@ PRESTUS provides a summary HTML that provides safety-relevant parameters in a da ![PRESTUS_html](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/html_report.png) -A full example report on a phantom can be found here: - - - -**Raw source:** [example_report.html](https://raw.githubusercontent.com/jkosciessa/PRESTUS_bin/main/examples/example_report.html) +A full example report on a phantom can be found here: [🔗 Download Example HTML](https://raw.githubusercontent.com/jkosciessa/PRESTUS_bin/main/examples/example_report.html) From 0fdb77429328836a56ac1d54faadd323b8755c0e Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 18:52:49 +0100 Subject: [PATCH 041/144] [doc] update documentation, starting guide --- documentation/doc_advanced.md | 2 +- documentation/doc_calibration.md | 2 +- documentation/doc_getting-started.md | 61 +++++++++++++++---- documentation/doc_installation.md | 7 ++- documentation/doc_medium.md | 2 +- .../{doc_config.md => doc_parameters.md} | 0 documentation/doc_pseudoCT.md | 50 ++++++++------- mkdocs.yaml | 2 +- 8 files changed, 80 insertions(+), 46 deletions(-) rename documentation/{doc_config.md => doc_parameters.md} (100%) diff --git a/documentation/doc_advanced.md b/documentation/doc_advanced.md index 5d72a9f3..6d69a75c 100644 --- a/documentation/doc_advanced.md +++ b/documentation/doc_advanced.md @@ -4,7 +4,7 @@ The base configuration of PRESTUS allows to specify a single setup, encompassing settings such as transducer specification, entry-target points, and temporal sequence characteristics. It may also be of interest to explore the effects of a range of different parameter settings on an outcome, for instance for benchamrking, but also for choosing a suitable stimulation sequence. -A general strategy toward such parameter iteration is to work on the basus of a single study setup configuration (as described in doc_config), whose values are iteratively overwritten in a MATLAB script. +A general strategy toward such parameter iteration is to work on the basus of a single study setup configuration (as described in the [starting guide](doc_getting_started.md)), whose values are iteratively overwritten in a MATLAB script. For many parameters, this can simply be done by specifying different values for ```parameters.<>```. One exception is the intensity and depth setting of the transducer output, as these depend on the internal transducer calibration. PRESTUS by default provides a transducer calibration ("profiling"). This should in general be used for a given depth and free water intensity. However, this may not provide sufficient flexibility for exploring different output amplitudes. For that reason, PRERSTUS also provides the function ```transducer_calibration```, which can be used within an interative MATLAB loop to find suitable transducer phase and amplitude settings that closely replicate the desired free water output profile. diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index b0e0de59..7d376670 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -4,7 +4,7 @@ PRESTUS generally does not provide default transducer settings. Instead, it encourages users to set up transducers and optimize settings to match their empirical setup. -See the full [parameter documentation](doc_config.md#transducer-specification). +See the full [parameter documentation](doc_parameters.md#transducer-specification). #### Static parameters diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index 1ad5d52a..51219477 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -1,36 +1,71 @@ # Getting started -### 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. +### Install packages -### [Optional] Start with the tutorial (documentation/PRESTUS_intro_tutorial.md) +See [Installation](doc_installation.md). + +### [DEPRECATED] Run 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. +### [Optional] Explore demos + +A simplified 2D benchmarking example can be found [here](https://github.com/jkosciessa/PRESTUS_2D_demo). It is a lightweight example that is designed to run on local CPU ressources. + +A 3D demo using SimNIBS' Ernie template is provided as a [DataLad dataset](https://gin.g-node.org/PRESTUS/sim_ernie/). [Note: This example is based on a prior PRESTUS version. It still has to be updated, but provides a practical example of a possible data management scheme.] + ### 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). + +Configuration metadata must be fully specified in (a set of) config files (in the .yaml format) placed in the 'configs' folder (see [the configuration overview](doc_parameters.md). Some parameters found in the 'default_config' are mandatory for the pipeline to run while others can be left out or are expected to be changed based on the requirements of the analysis. The different parameters that can be used are found in the `default_config.yaml` and in [the configuration overview](doc_parameters.md). The default config is not intended to be altered. Instead, parameter changes should be specified via an application-specific config file (or after loading the default config and before performing the subject call in MATLAB). + +The `load_parameters.m` function imports the configuration. Multiple configurations can be read in sequentially to overwrite specific portions of the default configuration. ### Specify a transducer and target location + Specify locations via `parameters.transducer.trans_pos` and `parameters.transducer.focus_pos`. -See [placement documentation](doc_placement.md). +See [Placement](doc_placement.md). -### Choose simulation medium +### Choose the Simulation Medium See [Medium Setup](doc_medium.md). -### Choose simulation type +### Choose the Simulation Backend See [Backend](doc_backend.md). +### Specify I/O and Planning Images + + +Specify folder management for inputs, segmentations, and simulations: + +``` +parameters.ld_library_path +parameters.data_path +parameters.seg_path +parameters.sim_path +parameters.paths_to_add = {pn.kwave, pn.minimize}; +parameters.simnibs_bin_path = fullfile('.conda', 'envs', 'simnibs_env', 'bin'); +``` + +For `layered` simulations, specify at least one T1w planning image (and optionally a T2w/UTE image) for the segmentation. + +``` +parameters.t1_path_template = fullfile(sprintf('m2m_sub-%03d', subject_id), "T1.nii.gz"); +parameters.t2_path_template = fullfile(sprintf('m2m_sub-%03d', subject_id), "T2_reg.nii.gz"); +``` + +### [Optional] Advance SimNIBS segmentation + +It may be desirable to run an advance SimNIBS call prior to running the full pipeline incl. segmentation postprocessing, source setup, acoustic and thermal simulations. By default, existing segmentations will be reused and not overwritten unless explicitly requested with `overwrite_simnibs` (regardless of `overwrite_files`). + +To this end, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, and `run_posthoc_water_sims` can be deactivated. This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). + ### Run the single_subject_pipeline -When the configuration is completely specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `single_subject_pipeline` can be submitted using high performance computing jobs (see [HPC documentation](doc_hpc.md)). +When the configuration is fully specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `single_subject_pipeline` can be submitted using high performance computing jobs (see [HPC documentation](doc_hpc.md)). + +PRESTUS can be parallelized across-subject and within-subject across treatment setups, e.g., by iterating across different transducers, transducer placement and target coordinates, or amplitudes and/or focal depth settings (requiring either dynamic [recalibration](doc_calibration.md) using `transducer_calibration` or a precomputed lookup table) via custom MATLAB scripts. diff --git a/documentation/doc_installation.md b/documentation/doc_installation.md index 95d77d70..18cc11fa 100644 --- a/documentation/doc_installation.md +++ b/documentation/doc_installation.md @@ -4,15 +4,15 @@ Download and install these tools: -- MATLAB (R2022b*) +- MATLAB (R2023b*) - [SimNIBS 4.0.0](https://github.com/simnibs/simnibs) (see instructions below) - toolboxes | They are automatically included as submodules if this repository is recursively cloned (see below). They must be added on MATLAB startup. - - [k-Wave (1.4)](https://github.com/ucl-bug/k-wave.git) + - [k-Wave (1.4.1)](https://github.com/ucl-bug/k-wave.git). While k-Wave 1.4 is supported, version 1.4.1 (currently [GitHub exclusive](https://github.com/ucl-bug/k-wave/releases/tag/v1.4.1)) introduced GPU support for thermal simulations with kWaveDiffusion. We therefore recommend cloning k-Wvae 1.4.1 from GitHub. - [export_fig](https://github.com/altmany/export_fig) - [FEX-minimize](https://github.com/rodyo/FEX-minimize.git) - [xml2struct](https://github.com/joe-of-all-trades/xml2struct) -\* Other versions may work as well, but R2022b was the default deployment. Especially on HPCs with GPUs, more recent MATLAB versions can cause issues. +\* Other versions may work as well, but R2023b is the current default deployment. Especially on HPCs with GPUs, more recent MATLAB versions can cause issues. The HPC scripts currently hardcode the MATLAB R2023b module on the Donders HPC. Before using the package, you need to have some libraries on your path. Major dependencies are included as submodules in the toolbox folder. @@ -35,6 +35,7 @@ git submodule init git submodule update ``` +Ensure that the paths and subfolders are added in MATLAB. See `simple_main.m` for an example. #### Donders Institute HPC Cluster diff --git a/documentation/doc_medium.md b/documentation/doc_medium.md index b4537c0d..db3685c5 100644 --- a/documentation/doc_medium.md +++ b/documentation/doc_medium.md @@ -37,7 +37,7 @@ PRESTUS supports homo- and heterogeneous medium configurations. These can be spe Accurate modeling of wave propagation through heterogeneous media requires valid assignment of acoustic parameters such as speed of sound, density, and attenuation to the separate tissue media. PRESTUS provides defaults based on the literature, while providing flexibility to alter assigned parameter values, e.g., to implement more conservative or liberal assumptions. -The following default medium properties applt in a full layered simulation. Parameters are specified in the default_config.yaml file (see doc_config.md). While PRESTUS’s default parameters are chosen to align with commercially available tools and prior benchmarks, these should not be regarded as ground truths, and may need to be adjusted according to the goals at hand (e.g., a more conservative assessment of intensity and/or heating). +The following default medium properties applt in a full layered simulation. Parameters are specified in the default_config.yaml file (see doc_parameters.md). While PRESTUS’s default parameters are chosen to align with commercially available tools and prior benchmarks, these should not be regarded as ground truths, and may need to be adjusted according to the goals at hand (e.g., a more conservative assessment of intensity and/or heating). | Tissue | Density [kg/m³] | Sound Speed [m/s] | Attenuation Coefficient [dB/(cm·MHzy)] | Attenuation Power Law (y)| Thermal Conductivity [W/(m·K)]| Specific Heat [J/(kg·K)] | Perfusion [mL/min/kg] | Absorption Fraction | |-----------------------|--------|----------|----------|-------|--------|------|-------|---| diff --git a/documentation/doc_config.md b/documentation/doc_parameters.md similarity index 100% rename from documentation/doc_config.md rename to documentation/doc_parameters.md diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 0c9841fc..31fcd8aa 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -5,43 +5,40 @@ PRESTUS supports the use of UTE-based images as a source of pseudo-Hounsfield un > [!WARNING] > (pseudo-)HU mapping is currently a beta feature in active development. -### Creating a pseudoCT from UTE scans +### Creating pseudoCTs from UTE scans -0. Acquire an MR sequence with detailed bone contrast +0. **MR acquisition** ->**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 + Acquire an MR sequence with detailed bone contrast -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. **pCT creation**. Run `pct_create_pseudoCT.sh` in bash. This calls the MATLAB function `pct_soft_tissue_peak` + >**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. +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**). -To run the code you will need to install (or load the following modules): +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): - 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 @@ -56,14 +53,15 @@ The following steps are used to create the pseudoCT: - 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). - -An example script to create a pseudoCT is provided at `examples/createPseudoCT.sh`. ### Using (pseudo-)CTs to inform acoustic properties -see [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43) +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. + +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 optionally model density, speed of sounds, and attenuation in the skull bone. + +[!warning] The most suitable model remains an active area of research. All mappings should be treated as explorative. See [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43). -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. - `carpino` | (**default**) Algorithm described in Carpino et al. (2024).
diff --git a/mkdocs.yaml b/mkdocs.yaml index 8818aa25..aa5a2701 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -52,7 +52,7 @@ nav: - Thermal: doc_simulations-thermal.md - (pseudo)CT: doc_pseudoCT.md - 📚 References: - - Parameters: doc_config.md + - Parameters: doc_parameters.md - Functions: doc_functions.md - 🖥️ Advanced: - Advanced Workflows: doc_advanced.md From 7f7eb56f8eb844f64711dc163d3facbbbbb2343b Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 19:30:24 +0100 Subject: [PATCH 042/144] [doc] update pages --- documentation/README.md | 4 +- documentation/doc_advanced.md | 2 +- documentation/doc_calibration.md | 49 +++++++++++++---------- documentation/doc_getting-started.md | 2 +- documentation/doc_placement.md | 2 +- documentation/doc_simulations-acoustic.md | 14 +++++-- 6 files changed, 43 insertions(+), 30 deletions(-) diff --git a/documentation/README.md b/documentation/README.md index 723264ec..e0d540a8 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -19,13 +19,13 @@ Key features include: - Support for high-performance computing. - 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. +These features make PRESTUS an accessible tool for researchers to plan, validate, and report transcranial ultrasound targeting. PRESTUS is intended solely for basic research use in non-clinical applications. A recent overview poster can be found [here](https://jkosciessa.github.io/downloads/2025-FUN25-PRESTUS.pdf). # Installation -Follow the [installation guide](doc_installation.md). +Follow the [Installation Guide](doc_installation.md) and continue with the [Quick Start Guide](doc_getting-started.md). ### Versions diff --git a/documentation/doc_advanced.md b/documentation/doc_advanced.md index 6d69a75c..efe372ec 100644 --- a/documentation/doc_advanced.md +++ b/documentation/doc_advanced.md @@ -4,7 +4,7 @@ The base configuration of PRESTUS allows to specify a single setup, encompassing settings such as transducer specification, entry-target points, and temporal sequence characteristics. It may also be of interest to explore the effects of a range of different parameter settings on an outcome, for instance for benchamrking, but also for choosing a suitable stimulation sequence. -A general strategy toward such parameter iteration is to work on the basus of a single study setup configuration (as described in the [starting guide](doc_getting_started.md)), whose values are iteratively overwritten in a MATLAB script. +A general strategy toward such parameter iteration is to work on the basus of a single study setup configuration (as described in the [starting guide](doc_getting-started.md)), whose values are iteratively overwritten in a MATLAB script. For many parameters, this can simply be done by specifying different values for ```parameters.<>```. One exception is the intensity and depth setting of the transducer output, as these depend on the internal transducer calibration. PRESTUS by default provides a transducer calibration ("profiling"). This should in general be used for a given depth and free water intensity. However, this may not provide sufficient flexibility for exploring different output amplitudes. For that reason, PRERSTUS also provides the function ```transducer_calibration```, which can be used within an interative MATLAB loop to find suitable transducer phase and amplitude settings that closely replicate the desired free water output profile. diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index 7d376670..c41834ff 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -1,34 +1,32 @@ ## Transducer definition -### Transducer specification - -PRESTUS generally does not provide default transducer settings. Instead, it encourages users to set up transducers and optimize settings to match their empirical setup. +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). +`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**] +`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**] #### Placement parameters -See also [transducer placement](doc_placement.md). +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). +`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 @@ -36,18 +34,27 @@ See also [transducer placement](doc_placement.md). The figure shows PRESTUS definitions for annular transducer distances. -| `expected_focal_distance_ep` | Expected distance from the transducer exit plane to the stimulation focus (in mm). | -| `expected_focal_distance_bowl` | Expected distance from the transducer bowl to the stimulation focus (in mm). | +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 -#### Modeling parameters +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: -| `use_kWaveArray` | Use the kWaveArray class for modeling transducers. | +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. ## Calibration of multi-element transducers -To emulate transducers for use in simulations, PRESTUS aims to optimise virtual transducers such that they produce focal axis profiles similar to those measured in free water. The acoustic profile will vary between different transducers, as well as for different focal depth settings for any given transducer. Additionally, the amplitude of stimulation that yields the desired output intensity needs to be estimated. As such, calibration of amplitude will also be specific to the desired free water ISPPA. +To emulate transducers, PRESTUS aims to optimise the velocity and phase settings of virtual transducers such that they produce focal axis profiles similar to those measured with real driving system - transducer setups in free water (measured either in-house via hydrophones or provided by device manufacturers). This calibrated emulation will vary between different transducers, for different focal depth settings, and free-water pressures. -The calibration relies on additional config (`calibration_config`) that should be loaded as `parameters.calibration` (see the example script below). +Transducer calibration relies on an additional config (`calibration_config`) that should be loaded as `parameters.calibration` (see the example `calibration_standalone`). ### Workflow 1: Find calibration settings for one (or multiple) transducer-depth settings [standalone] diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index 51219477..a4255b6d 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -1,4 +1,4 @@ -# Getting started +# Quick Start Guide ### Install packages diff --git a/documentation/doc_placement.md b/documentation/doc_placement.md index a22562cc..6630c569 100644 --- a/documentation/doc_placement.md +++ b/documentation/doc_placement.md @@ -10,7 +10,7 @@ Suitable coordinates (x/y/z) may be identified in preferred imaging software bas ## Heuristic coordinate selection -The function ```transducer_positioning``` identifies heuristic locations for transducer placement. +The function `transducer_positioning` identifies heuristic locations for transducer placement. The function targets brain regions specified by MNI coordinates, converting them to subject-native space using SimNIBS tools. It aims to identify candidate transducer positions where the geometric focus aligns with a target within a user-defined focal distance range, while ensuring >50% aperture overlaps skull/skin and low variance in distances. For each position, it computes the intersection proportion between the transducer and skin, mean/variance distances to skin and skull, and geometric focus/exit plane positions to ensure efficient coupling while minimizing aberrations. Besides target MNI coordinates, the function does not require manual intervention, enabling automatic end-to-end workflows. diff --git a/documentation/doc_simulations-acoustic.md b/documentation/doc_simulations-acoustic.md index 7af5f879..73914a30 100644 --- a/documentation/doc_simulations-acoustic.md +++ b/documentation/doc_simulations-acoustic.md @@ -1,10 +1,16 @@ -# Aoustic 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). + +## 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: From e4a602424df4ab34abff200db4728c3f3bbe15df Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 19:33:47 +0100 Subject: [PATCH 043/144] [doc] reorder start guide --- documentation/doc_getting-started.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index a4255b6d..4a2ab02d 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -23,12 +23,6 @@ Configuration metadata must be fully specified in (a set of) config files (in th The `load_parameters.m` function imports the configuration. Multiple configurations can be read in sequentially to overwrite specific portions of the default configuration. -### Specify a transducer and target location - -Specify locations via `parameters.transducer.trans_pos` and `parameters.transducer.focus_pos`. - -See [Placement](doc_placement.md). - ### Choose the Simulation Medium See [Medium Setup](doc_medium.md). @@ -58,9 +52,15 @@ parameters.t1_path_template = fullfile(sprintf('m2m_sub-%03d', subject_id), "T1. parameters.t2_path_template = fullfile(sprintf('m2m_sub-%03d', subject_id), "T2_reg.nii.gz"); ``` -### [Optional] Advance SimNIBS segmentation +### Specify a transducer and target location + +For `layered` simulations, specify locations via `parameters.transducer.trans_pos` and `parameters.transducer.focus_pos`. + +See [Placement](doc_placement.md). + +### [Optional] One-shot SimNIBS segmentation -It may be desirable to run an advance SimNIBS call prior to running the full pipeline incl. segmentation postprocessing, source setup, acoustic and thermal simulations. By default, existing segmentations will be reused and not overwritten unless explicitly requested with `overwrite_simnibs` (regardless of `overwrite_files`). +It may be desirable to run an intial SimNIBS call prior to running the full pipeline incl. segmentation postprocessing, source setup, acoustic and thermal simulations. By default, existing segmentations will be reused and not overwritten unless explicitly requested with `overwrite_simnibs` (regardless of `overwrite_files`). To this end, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, and `run_posthoc_water_sims` can be deactivated. This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). From 01869f6ee28ebec16a43401fea46d4a264647074 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 19:40:01 +0100 Subject: [PATCH 044/144] [doc] add pct image --- documentation/doc_pseudoCT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 31fcd8aa..f2f882e2 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -1,5 +1,7 @@ # (pseudo-)CT +![pct_ute_density](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/ute_pct_density.png) + PRESTUS supports the use of UTE-based images as a source of pseudo-Hounsfield units. These can replace the skull layer of a layered simulation for continuous tissue property mapping. > [!WARNING] From 525f50cde0bb5c01afebfd343fba74e1835454af Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 4 Mar 2026 19:52:38 +0100 Subject: [PATCH 045/144] [doc] add links --- documentation/README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/documentation/README.md b/documentation/README.md index e0d540a8..928429b9 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -4,20 +4,20 @@ # PRESTUS: PREprocessing & Simulations for Transcranial Ultrasound Stimulation -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. +PRESTUS (PREprocessing & Simulations for Transcranial Ultrasound Stimulation) is an open-source MATLAB toolbox that aims to streamline imaging-informed simulations of Transcanial Ultrasound Stimulation (TUS): from the segmentation of T1-weighted MRI head scans, [preprocessing](doc_preproc.md) of their tissue layers, and mapping of [acoustic tissue properties](doc_medium.md) (possibly informed for skull via [(pseudo-)CT](doc_pseudoCT.md) images) in a simulation grid, to the execution of [acoustic](doc_simulations-acoustic.md) and [thermal](doc_simulations-thermal.md) simulations using the widely adopted [k-Wave engine](doc_backend.md). High-performance computing ([HPC](doc_hpc.md)) support via SLURM and CentOS enables efficient parallelization, and large-scale analyses. Output 3D NifTI images are automatically mapped to a standard template (MNI) space to facilitate [group](doc_group.md) reporting. -Key features include: +Key features of the modular end-to-end pipeline include: -- Automated MRI segmentation (using SimNIBS 4 charm) and preprocessing. +- Automated MRI segmentation (using SimNIBS 4 charm) and [preprocessing](doc_preproc.md). - 2D / 3D grid setup. -- Multi-layer medium property mapping (water, skin, multi-layer skull, and brain). -- (pseudo-)CT-informed continuous skull mapping. -- Virtual multi-element transducer calibration (free-water profile emulation). -- Estimation of entry-target coordinates. -- Flexible temporal protocol specification (e.g., including session breaks). -- k-Wave integration for robust acoustic and heating simulations. -- Support for high-performance computing. -- 3D NifTI outputs for major reporting metrics (in subject- & MNI-space). +- Multi-layer [medium property mapping](doc_medium.md) (water, skin, multi-layer skull, and brain). +- [(pseudo-)CT](doc_pseudoCT.md)-informed continuous skull mapping. +- Virtual multi-element [transducer calibration](doc_calibration.md) (free-water profile emulation). +- Estimation of entry-target [coordinates](doc_placement.md) incl. [neuronavigation](doc_neuronav.md) read-in. +- Flexible [temporal protocol specification](doc_simulations-thermal.md) (e.g., including breaks). +- k-Wave integration for robust [acoustic](doc_simulations-acoustic.md) and [thermal](doc_simulations-thermal.md) simulations. +- Support for [high-performance computing](doc_hpc.md). +- 3D NifTI [outputs](doc_outputs.md) for major reporting metrics (in subject- & MNI-space). These features make PRESTUS an accessible tool for researchers to plan, validate, and report transcranial ultrasound targeting. PRESTUS is intended solely for basic research use in non-clinical applications. From 59155af7b25bb8710595ab07951c6082e1fbd471 Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Thu, 5 Mar 2026 11:42:26 +0100 Subject: [PATCH 046/144] added dynamic pressure display in the html report and remove CEM and temperature from water stimulation report --- functions/core/generate_simulation_report.m | 24 +++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index ab0e48e1..3c27bfc6 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -246,8 +246,6 @@ 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'); end function color = safety_color(value, limit) @@ -376,11 +374,12 @@ 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³')]; 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, 'max_pressure_Pa')); + html = [html summary_card('Max pressure', p_val, p_unit)]; end % Thermal summary if available - if isfield(parameters, 'run_heating_sims') && parameters.run_heating_sims + if is_layered && isfield(parameters, 'run_heating_sims') && parameters.run_heating_sims html = [html summary_card('Max temp.', csv_value(csv_table, 'maxT'), [char(176) 'C'])]; end @@ -402,6 +401,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; From 83ee0191c2eabb214bb772a051ec9c177efeae88 Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Thu, 5 Mar 2026 11:57:00 +0100 Subject: [PATCH 047/144] change the pressure units for the safety dashboard as well --- functions/core/generate_simulation_report.m | 40 ++++++++++++--------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index 3c27bfc6..2a5c9bb1 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -287,14 +287,20 @@ name = metric_names{i}; info = limits.(name); - % Extract value from CSV - value = NaN; - if ~isempty(csv_table) && ismember(name, csv_table.Properties.VariableNames) - val = csv_table.(name); - if isnumeric(val) && ~isempty(val) - value = val(end); % last row if multiple - end - end + % 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) + value = val(end); % last row if multiple + end + end + + % Special handling for pressure: use dynamic unit scaling + if strcmp(name, 'max_pressure_Pa') && ~isnan(value) + [value, display_unit] = scale_pressure(value); + end % Determine color color = safety_color(value, info.limit); @@ -303,15 +309,15 @@ html = [html sprintf('
', color)]; html = [html sprintf('
%s
', html_escape(info.label))]; - if isnan(value) - html = [html '
N/A
']; - html = [html '
No data
']; - else - html = [html sprintf('
%.3g
', value)]; - if isinf(info.limit) - html = [html sprintf('
%s (informational)
', info.unit)]; - else - html = [html sprintf('
Limit: %.3g %s
', info.limit, info.unit)]; + if isnan(value) + html = [html '
N/A
']; + html = [html '
No data
']; + else + html = [html sprintf('
%.3g
', value)]; + if isinf(info.limit) + html = [html sprintf('
%s (informational)
', display_unit)]; + else + 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)]; From 2dba8ecd7b23547315e14cd857cadd31bc816bce Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Thu, 5 Mar 2026 12:08:00 +0100 Subject: [PATCH 048/144] change the pressure units for the acoustic section as well --- functions/core/generate_simulation_report.m | 32 ++++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index 2a5c9bb1..92d12f25 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -609,17 +609,27 @@ limits = get_safety_limits(); acoustic_cols = get_acoustic_columns(); html = [html table2html(csv_table, limits, acoustic_cols)]; - else - % Water: filter to water-relevant columns only, no safety color coding - water_cols = get_acoustic_columns_water(); - avail_cols = intersect(water_cols, csv_table.Properties.VariableNames, 'stable'); - if ~isempty(avail_cols) - sub_table = csv_table(:, avail_cols); - html = [html table2html(sub_table, struct(), {})]; - else - html = [html '

No acoustic columns found in CSV.

']; - end - end + else + % Water: filter to water-relevant columns only, no safety color coding + water_cols = get_acoustic_columns_water(); + 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('max_pressure_Pa', sub_table.Properties.VariableNames) + pressure_vals = sub_table{:, 'max_pressure_Pa'}; + if ~all(isnan(pressure_vals)) + [scaled_vals, display_unit] = scale_pressure(pressure_vals); + sub_table{:, 'max_pressure_Pa'} = scaled_vals; + % Rename column header to reflect actual unit + sub_table.Properties.VariableNames{'max_pressure_Pa'} = ['max_pressure_' display_unit]; + end + end + html = [html table2html(sub_table, struct(), {})]; + else + html = [html '

No acoustic columns found in CSV.

']; + end + end else html = [html '

No acoustic CSV data found.

']; end From ac278437e087e541da211e86d22155c873514e87 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 5 Mar 2026 12:47:43 +0100 Subject: [PATCH 049/144] [doc] update contributions --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d82ea627..9d2b6c27 100755 --- a/README.md +++ b/README.md @@ -27,27 +27,27 @@ Please see the [PRESTUS documentation](https://donders-institute.github.io/PREST # Reference If you use this tool for publications, please cite: -Kosciessa, J. Q.\*, Chetverikov, A.\*, Cornelissen, M., Carpino, E., van der Zee, K., & Verhagen, L. (2025). PRESTUS (0.5.0). Zenodo. https://doi.org/10.5281/zenodo.15965832 +Kosciessa, J. Q.\*, Chetverikov, A.\*, Cornelissen, M., van der Zee, K., Carpino, E., Wimmers, M., Adelhöfer, N., Meijer, S., & Verhagen, L. (2026). PRESTUS (0.5.0). Zenodo. https://doi.org/10.5281/zenodo.15095860 # Contributors [![Contributors](https://img.shields.io/github/contributors/Donders-Institute/PRESTUS.svg?color=00B4D8&style=flat-square)](https://github.com/Donders-Institute/PRESTUS/graphs/contributors) -[![All Contributors](https://img.shields.io/github/all-contributors/Donders-Institute/PRESTUS?color=00B4D8&style=flat-square)](#contributors) - - - - - - - - - + + + + + + + + + +
Julian Kosciessa
Julian Kosciessa

💻 🤔 🚧 🐛 📖
Andrey Chetverikov
Andrey Chetverikov

💻 🤔
Kenneth van der Zee
Kenneth van der Zee

💻 🤔 🚧 🐛
Margely Cornelissen
Margely Cornelissen

💻 🚧 🐛
Eleonora Carpino
Eleonora Carpino

💻 🤔
Matthias Ekman
Matthias Ekman

🐛
Martin Wimmers
Martin Wimmers

🐛
Nico Adelhöfer
Nico Adelhöfer

🤔
Lennart Verhagen
Lennart Verhagen

🤔
Julian Kosciessa
Julian Kosciessa

💻 🤔 🚧 🐛 📖
Andrey Chetverikov
Andrey Chetverikov

💻 🤔
Kenneth van der Zee
Kenneth van der Zee

💻 🤔 🚧 🐛
Margely Cornelissen
Margely Cornelissen

💻 🚧 🐛
Eleonora Carpino
Eleonora Carpino

💻 🤔
Martin Wimmers
Martin Wimmers

💻 🐛
Nico Adelhöfer
Nico Adelhöfer

💻 🤔
meijer-s
meijer-s

💻 🤔
Matthias Ekman
Matthias Ekman

🐛
Lennart Verhagen
Lennart Verhagen

🤔
From f1e003594d890d0f99a562b7c4806c3dd1ddd28f Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 5 Mar 2026 13:38:03 +0100 Subject: [PATCH 050/144] [doc] add changelog --- documentation/CHANGELOG.md | 124 +++++++++++++++++++++++++++++++++++++ mkdocs.yaml | 1 + 2 files changed, 125 insertions(+) create mode 100644 documentation/CHANGELOG.md diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md new file mode 100644 index 00000000..9604cafb --- /dev/null +++ b/documentation/CHANGELOG.md @@ -0,0 +1,124 @@ +# Changelog + +Notable changes to this project will be documented here. + +## `development` v0.5.0 [*unreleased*] + +Note: While intended to be largely backwards compatible, parameters, their naming, and default values have changed. Please consult the [documentation](https://donders-institute.github.io/PRESTUS/) for the current parameter specification. + +#### Added + +- [**feature**] C++ support by @jkosciessa +- [**feature**] Advanced logging & benchmarking + - Key parameters are displayed at onset of simulation + - PRESTUS and k-Wave commit version logged (git-only) +- [**feature**] html summary output by @sirmrmarty + - Acoustic properties will only be printed for modeled layers @jkosciessa +- [**feature**] Parallel multi-transducer support for layered simulation by @dreamstimlab (see [PR](https://github.com/Donders-Institute/PRESTUS/pull/100)) +- [**skull**] new option `parameters.rubberwrap` by @meijer-s (see [PR](https://github.com/Donders-Institute/PRESTUS/pull/103)) + - Locally inflate the skull mask. + - New default for the layered simulation and only option during pCT creation. The sequence of where this is implemented currently varies between layered/pCT. For pCT, the rubber expansion is implemented following segmentation. For layered, it follows medium map creation (i.e., following the smoothing of masks) to catch potential issues where smoothing and binarization may (re-)introduce holes. +- [**doc**] GitHub pages [documentation](https://donders-institute.github.io/PRESTUS/) + - Full parameter & function overview + - Installation Guide + - Quick Start Guide + - Example demos + - Draft of processing steps +- [**thermal**] More flexible and standardised protocol timing specification +- [**thermal**] Output plot of requested timing protocol +- [**thermal**] Additional thermal timeseries of max. in tissue and heating at focus +- [**thermal**] Heating timeseries are saved as .mat +- [**parameter**] I/O handling of intermediate outputs. `savemat` can be set to 0 to not save processing matrices (save disk space) +- [**parameter**] Dedicated `debug` parameter + - saves plots of postprocessing etc in debug folder of sim directory + - parameter overview when loading config (cf. pipeline onset) + - saves nifti images of raw acoustic properties + - currectly active by default + +#### Changed + +- [**refactor**] Pipeline and main processing steps refactored + - Functions organized by relevant processing stage + - main pipeline: 1150 lines -> 450 lines (incl. updated logging) +- [**calibration**] Dedicated config and unified pipeline for transducer calibration, new parameters. Function for calibration. [@MaCuinea, @jkosciessa] +- [**parameter**] Minor parameters changes: some were dropped, some renamed, others added, check the function doc if in doubt. default_config and documentation updated accordingly. Behaviour should be comparable to v0.4.0 +- [**pCT**] pCT creation updated to new folder structure and simplified based on @meijer-s implementation. +- [**savemat**] kwave source matrix saving now can be deactivated (as intended) +- [**feature**] updates to axisymmetry support +- [**advanced**] Updates to the sequential simulations with different transducer-target specs @Kenneth van der Zee +- [**preproc**] segmentation smoothing choices changed @jkosciessa +- [**preproc**] grid interpolation for continuous data changed @jkosciessa + +#### Fixed + +Not all (hot-)fixes are reported here. + +- [**calibration**] potential bugs with distance calculation +- [**savemat**] kwave source matrix saving now can be deactivated (as intended) +- [**savemat**] water simulations will force-save matrix outputs + +#### Deprecated/Removed + +- [**feature**] Removed explicit ‘skull’-only simulations. These can be run by removing layers in the simulation setup. + +## v0.4.0 + +This release features major updates. Please consult the documentation for current parameter choices. + +- [**Feature**] Acoustic profiling (beta) @MaCuinea +- [**Feature**] pCT->acoustic skull medium incl. multiple mapping algorithms (beta, doc) @eleonoracarpino @jkosciessa +- [**Feature**] Run consecutive simulations @KTZ228 +- [**Feature**] GPU updates (doc) @jkosciessa +- [**Feature**] CEM43 ISO definition, see doc @jkosciessa +- [**Feature**] Modeling of absorption (as fraction of attenuation) and perfusion @jkosciessa. Be advised that this renders the heating estimates more liberal than in the past. See the documentation. Also see some comments in the issue. +- [**Feature**] axisymmetry simulations with 2D input grids (doc) @jkosciessa +- [**Feature**] simulations with computational phantoms (doc) @jkosciessa +- [**Documentation**] Initial draft of documentation (e.g., parameters) (see links above and here) @jkosciessa +Many smaller bug fixes and QOL improvements (e.g., ability to disable .mat saving, more debugging outputs) +Some features remain unstable and new bugs may pop up. Let's remain vigilant and try to quickly introduce hotfixes in those cases. + +DOC: minor typo in README.md by @mekman in #58 +Run simnibs segmentation code also supports SLURM by @MaCuinea in #62 +Prepare acoustic_profiling for merge by @MaCuinea in #69 +Semi-automated acoustic profiling by @MaCuinea in #71 +Added note to default config regarding ld_library_path in slurm by @KTZ228 in #70 +Merge pull request #70 from KTZ228/development by @MaCuinea in #77 +Fixed tutorial by @MaCuinea in #73 +Feature: consecutive simulations by @KTZ228 in #79 +Documentation, pCT, HCP updates & more, see above by @jkosciessa in #59 +The last one summarizes more than half a year of updates to code and documentation. + +## v0.3.0 + +PseudoCT feature by @eleonoracarpino and @jkosciessa +Clean-up repository by @jkosciessa +Incorrect allocation of attenuation values bug fix by @jkosciessa +Improve correct labeling of tissue by @jkosciessa +Improvement of 3D plots by @jkosciessa +Check simulation stability and adjust time step accordingly by @MaCuinea and @jkosciessa +Enable tissue-specific temp_0 specification by @jkosciessa +Fix heating plot bug by @jkosciessa + +## v0.2.0 + +DOC: add GNU license by @mekman in #17 +Documentation changes and bug fixes by @KTZ228 in #14 +Disabled masking of neural tissue in skin in smooth_and_crop_layered by @KTZ228 in #19 +Adjusted create_Group_MNI_plots by @KTZ228 in #22 +Documentation changes by @KTZ228 in #24 +Minor changes to comments for heating setup by @jkosciessa in #29 +Figure saving updates, some bug fixes by @jkosciessa in #8 +Kenneth master by @KTZ228 in #33 +Group plots fixes and improvements by @KTZ228 in #26 +FIX: revert to working version by @mekman in #35 +DOC: reworked installation instructions by @mekman in #36 + +## v0.1.0 + +Initial in-house development version by @achetverikov + +Dedicated segmentation path option, minor bug fixes, updated simnibs call by @jkosciessa in #2 +fix small errors by @jkosciessa in #3 +adjusted skull values by @eleonoracarpino in #4 +Bug fixes and documentation changes by @KTZ228 in #6 +DOC: simplify readme.md wording regarding requirements by @mekman in #7 \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index aa5a2701..889db5ef 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -63,6 +63,7 @@ nav: - 📋 Community: - Code of Conduct: CODE_OF_CONDUCT.md - Contributing: CONTRIBUTING.md + - Changelog: CHANGELOG.md plugins: - search From 450864b1c85158fb22dbca5452bb09d327000356 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 5 Mar 2026 13:48:02 +0100 Subject: [PATCH 051/144] [doc] update formatting --- documentation/CHANGELOG.md | 74 ++++++++++++++++---------------- documentation/doc_calibration.md | 47 ++++++++++++++------ 2 files changed, 70 insertions(+), 51 deletions(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 9604cafb..fbe33e28 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -63,7 +63,7 @@ Not all (hot-)fixes are reported here. ## v0.4.0 -This release features major updates. Please consult the documentation for current parameter choices. +This release features major updates. Please consult the documentation for current parameter choices. Some features remain unstable and new bugs may pop up. Let's remain vigilant and try to quickly introduce hotfixes in those cases. - [**Feature**] Acoustic profiling (beta) @MaCuinea - [**Feature**] pCT->acoustic skull medium incl. multiple mapping algorithms (beta, doc) @eleonoracarpino @jkosciessa @@ -74,51 +74,49 @@ This release features major updates. Please consult the documentation for curren - [**Feature**] axisymmetry simulations with 2D input grids (doc) @jkosciessa - [**Feature**] simulations with computational phantoms (doc) @jkosciessa - [**Documentation**] Initial draft of documentation (e.g., parameters) (see links above and here) @jkosciessa -Many smaller bug fixes and QOL improvements (e.g., ability to disable .mat saving, more debugging outputs) -Some features remain unstable and new bugs may pop up. Let's remain vigilant and try to quickly introduce hotfixes in those cases. - -DOC: minor typo in README.md by @mekman in #58 -Run simnibs segmentation code also supports SLURM by @MaCuinea in #62 -Prepare acoustic_profiling for merge by @MaCuinea in #69 -Semi-automated acoustic profiling by @MaCuinea in #71 -Added note to default config regarding ld_library_path in slurm by @KTZ228 in #70 -Merge pull request #70 from KTZ228/development by @MaCuinea in #77 -Fixed tutorial by @MaCuinea in #73 -Feature: consecutive simulations by @KTZ228 in #79 -Documentation, pCT, HCP updates & more, see above by @jkosciessa in #59 -The last one summarizes more than half a year of updates to code and documentation. +- Many smaller bug fixes and QOL improvements (e.g., ability to disable .mat saving, more debugging outputs) +- DOC: minor typo in README.md by @mekman in #58 +- Run simnibs segmentation code also supports SLURM by @MaCuinea in #62 +- Prepare acoustic_profiling for merge by @MaCuinea in #69 +- Semi-automated acoustic profiling by @MaCuinea in #71 +- Added note to default config regarding ld_library_path in slurm by @KTZ228 in #70 +- Merge pull request #70 from KTZ228/development by @MaCuinea in #77 +- Fixed tutorial by @MaCuinea in #73 +- Feature: consecutive simulations by @KTZ228 in #79 +- Documentation, pCT, HCP updates & more, see above by @jkosciessa in #59 +- The last one summarizes more than half a year of updates to code and documentation. ## v0.3.0 -PseudoCT feature by @eleonoracarpino and @jkosciessa -Clean-up repository by @jkosciessa -Incorrect allocation of attenuation values bug fix by @jkosciessa -Improve correct labeling of tissue by @jkosciessa -Improvement of 3D plots by @jkosciessa -Check simulation stability and adjust time step accordingly by @MaCuinea and @jkosciessa -Enable tissue-specific temp_0 specification by @jkosciessa -Fix heating plot bug by @jkosciessa +- PseudoCT feature by @eleonoracarpino and @jkosciessa +- Clean-up repository by @jkosciessa +- Incorrect allocation of attenuation values bug fix by @jkosciessa +- Improve correct labeling of tissue by @jkosciessa +- Improvement of 3D plots by @jkosciessa +- Check simulation stability and adjust time step accordingly by @MaCuinea and @jkosciessa +- Enable tissue-specific temp_0 specification by @jkosciessa +- Fix heating plot bug by @jkosciessa ## v0.2.0 -DOC: add GNU license by @mekman in #17 -Documentation changes and bug fixes by @KTZ228 in #14 -Disabled masking of neural tissue in skin in smooth_and_crop_layered by @KTZ228 in #19 -Adjusted create_Group_MNI_plots by @KTZ228 in #22 -Documentation changes by @KTZ228 in #24 -Minor changes to comments for heating setup by @jkosciessa in #29 -Figure saving updates, some bug fixes by @jkosciessa in #8 -Kenneth master by @KTZ228 in #33 -Group plots fixes and improvements by @KTZ228 in #26 -FIX: revert to working version by @mekman in #35 -DOC: reworked installation instructions by @mekman in #36 +- DOC: add GNU license by @mekman in #17 +- Documentation changes and bug fixes by @KTZ228 in #14 +- Disabled masking of neural tissue in skin in smooth_and_crop_layered by @KTZ228 in #19 +- Adjusted create_Group_MNI_plots by @KTZ228 in #22 +- Documentation changes by @KTZ228 in #24 +- Minor changes to comments for heating setup by @jkosciessa in #29 +- Figure saving updates, some bug fixes by @jkosciessa in #8 +- Kenneth master by @KTZ228 in #33 +- Group plots fixes and improvements by @KTZ228 in #26 +- FIX: revert to working version by @mekman in #35 +- DOC: reworked installation instructions by @mekman in #36 ## v0.1.0 Initial in-house development version by @achetverikov -Dedicated segmentation path option, minor bug fixes, updated simnibs call by @jkosciessa in #2 -fix small errors by @jkosciessa in #3 -adjusted skull values by @eleonoracarpino in #4 -Bug fixes and documentation changes by @KTZ228 in #6 -DOC: simplify readme.md wording regarding requirements by @mekman in #7 \ No newline at end of file +- Dedicated segmentation path option, minor bug fixes, updated simnibs call by @jkosciessa in #2 +- fix small errors by @jkosciessa in #3 +- adjusted skull values by @eleonoracarpino in #4 +- Bug fixes and documentation changes by @KTZ228 in #6 +- DOC: simplify readme.md wording regarding requirements by @mekman in #7 \ No newline at end of file diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index c41834ff..9ac7c197 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -6,27 +6,46 @@ See the full [parameter documentation](doc_parameters.md#transducer-specificatio #### 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). +`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**] +`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**] #### 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). +`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 @@ -36,9 +55,11 @@ 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_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`. +`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 From ae62bae3b841894d4c71fede1e4bcf46c8452c57 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 5 Mar 2026 21:19:23 +0100 Subject: [PATCH 052/144] [calibration] refactor, facilitate structure passing major refatcoring of calibration code to ease comparison between targeted, simulated, and analytical profiles, major internal variable renaming, adjustment of empirical target profile to length of simulation axis, fitting constraint to min/max depth of non-NaN intensities --- documentation/PRESTUS_intro_tutorial.md | 14 +- documentation/doc_calibration.md | 6 +- documentation/doc_functions.md | 2 +- examples/calibration_standalone.m | 36 +++-- .../calibration/calibration_transducer.m | 131 +++++++++--------- .../calibration/compute_oneil_solution.m | 70 +++++----- functions/calibration/compute_phases.m | 6 +- .../extract_real_intensity_profile.m | 18 +-- .../calibration/extract_simulated_profile.m | 89 ++++++++---- functions/calibration/perform_global_search.m | 20 +-- functions/calibration/plot_opt_sim_results.m | 110 ++++++--------- ...tical_sol.m => recompute_oneil_solution.m} | 53 ++++--- functions/calibration/save_optimized_values.m | 34 ++--- .../scale_real_intensity_profile.m | 37 ++++- functions/calibration/set_real_phases.m | 8 +- 15 files changed, 343 insertions(+), 291 deletions(-) rename functions/calibration/{recalculate_analytical_sol.m => recompute_oneil_solution.m} (54%) diff --git a/documentation/PRESTUS_intro_tutorial.md b/documentation/PRESTUS_intro_tutorial.md index 6d89f68d..2c786911 100644 --- a/documentation/PRESTUS_intro_tutorial.md +++ b/documentation/PRESTUS_intro_tutorial.md @@ -189,7 +189,7 @@ axial_position = (1:parameters.default_grid_dims(3))*0.5; % [mm] % evaluate pressure analytically % focusedAnnulusONeil provides an analytic solution for the pressure at the % focal (beam) axis -[p_axial_oneil] = focusedAnnulusONeil(parameters.transducer.curv_radius_mm/1e3, ... +[profile_oneil.axial_intensity] = focusedAnnulusONeil(parameters.transducer.curv_radius_mm/1e3, ... [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm]/1e3, repmat(velocity,1,parameters.transducer.n_elements), ... parameters.transducer.source_phase_rad, parameters.transducer.source_freq_hz, parameters.medium.water.sound_speed, ... parameters.medium.water.density, (axial_position-0.5)*1e-3); @@ -197,7 +197,7 @@ axial_position = (1:parameters.default_grid_dims(3))*0.5; % [mm] % plot focal axis pressure figure('Position', [10 10 900 500]); -plot(axial_position, p_axial_oneil .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); +plot(axial_position, profile_oneil.axial_intensity .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); xlabel('Axial Position [mm]'); ylabel('Intensity [W/cm^2]'); hold on @@ -213,7 +213,7 @@ title('Pressure along the beam axis') ```matlab:Code % what is distance to the maximum pressure? -fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n',axial_position(p_axial_oneil==max(p_axial_oneil))) +fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n',axial_position(profile_oneil.axial_intensity==max(profile_oneil.axial_intensity))) ``` ```text:Output @@ -224,7 +224,7 @@ We want the simulated results to match the real profile as closely as possible, ```matlab:Code % compute the approximate adjustment from simulated (on a grid) to analytic solution -simulated_grid_adj_factor = max(pred_axial_pressure(:))/max(p_axial_oneil(:)); +simulated_oneil_scaling = max(pred_axial_pressure(:))/max(profile_oneil.axial_intensity(:)); ``` # Optimize for a given distance and pressure @@ -290,7 +290,7 @@ opt_velocity = opt_phases_and_velocity(parameters.transducer.n_elements); parameters.medium.water.density, (axial_position-0.5)*1e-3); figure('Position', [10 10 900 500]); -plot(axial_position, p_axial_oneil.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); +plot(axial_position, profile_oneil.axial_intensity.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); xlabel('Axial Position [mm]'); ylabel('Intensity [W/cm^2]'); hold on @@ -328,7 +328,7 @@ Estimated distance to the center of half-maximum range: 68.25 mm Now we will redo the simulations with new parameters. We copy the configuration, update the settings with the optimized parameters, and rerun the simulations. ```matlab:Code -opt_source_amp = round(opt_velocity/velocity*parameters.transducer.source_amp/simulated_grid_adj_factor); +opt_source_amp = round(opt_velocity/velocity*parameters.transducer.source_amp/simulated_oneil_scaling); opt_parameters = load_parameters('tutorial_config.yaml'); opt_parameters.transducer.source_amp = opt_source_amp; @@ -385,7 +385,7 @@ pred_axial_pressure_opt = squeeze(p_max(opt_res.parameters.transducer.trans_pos( figure('Position', [10 10 900 500]); hold on -plot(axial_position, p_axial_oneil.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); +plot(axial_position, profile_oneil.axial_intensity.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); xlabel('Axial Position [mm]'); ylabel('Intensity [W/cm^2]'); plot(axial_position, p_axial_oneil_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index 9ac7c197..71996623 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -104,9 +104,9 @@ Function: `calibration_transducer` - Calibration should be performed dynamically as part of a simulation loop (e.g., for sweeping an amplitude-distance parameter space) - Prerequisites: - - `profile_empirical.profile_focus` - Desired intensity profile along focal beam axis - - `profile_empirical.dist_from_tran` - Distance (mm from transducer bowl) - - `profile_empirical.focus_wrt_exit_plane` - Requested focal distance (mm from transducer exit plane) + - `profile_empirical.axial_intensity` - Desired intensity profile along focal beam axis + - `profile_empirical.axial_distance_bowl` - Distance (mm from transducer bowl) + - `desired_focal_distance_ep` - Requested focal distance (mm from transducer exit plane) ### Steps: diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index 8ef88259..eee250d2 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -18,7 +18,7 @@ The following documents the functions provided in PRESTUS. | `phase_optimization_annulus_full_curve` | CALIBRATION | Optimizes the phase profile for an annular transducer by matching intensity curves. | | `phase_optimization_annulus` | CALIBRATION | Optimizes the phase profile by calculating focal distance error for an annular transducer. | | `plot_opt_sim_results` | CALIBRATION | Load optimized k-Wave results, generate 2D focal plane intensity maps and axial profile comparisons (simulated vs. analytical O’Neil vs. desired), and save annotated plots. | -| `recalculate_analytical_sol` | CALIBRATION | Recompute O’Neil analytical pressure profile using optimized phases/velocity, convert to intensity, plot comparisons with original/desired profiles (skipping near-field), and report focal metrics. | +| `recompute_oneil_solution` | CALIBRATION | Recompute O’Neil analytical pressure profile using optimized phases/velocity, convert to intensity, plot comparisons with original/desired profiles (skipping near-field), and report focal metrics. | | `save_optimized_values` | CALIBRATION | Append optimized transducer phases and amplitudes into a CSV table (indexed by intensity/focus) and save parameters as YAML for PRESTUS config integration. | | `scale_real_intensity_profile` | CALIBRATION | Linearly scale measured focal intensity profile and update transducer source amplitude (via acoustic impedance) to match desired peak Isppa (W/cm²). | | `set_real_phases` | CALIBRATION | Load or compute manufacturer-specific (Sonic Concepts/Imasonic) transducer element phases for given focal depth (wrt exit plane), interpolate/unwrap for virtual elements, and return phase degrees. | diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index cf10ab4c..db4fa2bf 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -105,38 +105,41 @@ 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 = focus_wrt_exit_plane; + 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 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_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_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; @@ -154,8 +157,8 @@ % 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 = focus_wrt_exit_plane; - parameters.expected_focal_distance_bowl = focus_wrt_exit_plane + dist_bowl_exit_plane; + 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 @@ -166,8 +169,8 @@ if isfield(parameters.calibration, 'addEPdistance') && parameters.calibration.addEPdistance == 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 initial value in amplitude profile - profile_focus = cat(1, repmat(profile_focus_ep(1),Nvals,1), profile_focus_ep'); + % set those to zero in amplitude profile + profile_focus = cat(1, repmat(0, Nvals,1), profile_focus_ep'); else profile_focus = profile_focus_ep; end @@ -176,9 +179,11 @@ profile_focus(profile_focus<0) = 0; % collect data on empirical profile - profile_empirical.profile_focus = profile_focus; - profile_empirical.dist_from_tran = dist_bowl_focus; - profile_empirical.focus_wrt_exit_plane = focus_wrt_exit_plane; + 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) % convert from default 3D to 2D axisymmetric simulation (if requested) if isfield(parameters.calibration, 'axisymmetric2D') && ... @@ -192,13 +197,14 @@ % 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)]) % perform the calibration calibration_transducer(... profile_empirical, ... equipment_name, ... desired_intensity, ... + desired_focal_distance_ep,... parameters,... sim_id) diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index 11f97f37..f474fe9e 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...') @@ -68,16 +84,16 @@ sim_param.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 + % 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 %% Load initial results @@ -85,56 +101,41 @@ sim_param.outputs_folder, sim_id, sim_param.results_filename_affix)); initial_params = initial_res.acoustic_info.parameters; + initial_params.calibration.prefix = 'Initial_'; + + %% Extract simulated intensity along the focal axis - %% Extract simulated pressure along the focal axis - [pred_axial_pressure] = extract_simulated_profile... - (initial_res, initial_params, profile_empirical, desired_intensity, equipment_name); + [profile_sim] = extract_simulated_profile(initial_res, initial_params); %% Optimization - - % 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 O'Neil solution and scaling factor to simulated intensity + [profile_oneil, simulated_oneil_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); % 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_oneil_scaling); % Collect phases opt_source_phase_rad = opt_phases; @@ -161,25 +162,29 @@ error('Submit medium does not correspond to available options.'); end + %% 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 c2c50f6b..7529b65c 100644 --- a/functions/calibration/compute_oneil_solution.m +++ b/functions/calibration/compute_oneil_solution.m @@ -1,66 +1,58 @@ -function [p_axial_oneil, simulated_grid_adj_factor, velocity, axial_position] = ... - compute_oneil_solution(parameters, pred_axial_pressure, dist_transducer, ... - 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. - % - p_max: Predicted pressure [Pa]. - % - dist_transducer: 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]. Will be used only for labelling. - % - 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] - % Note: transducer will be placed at initial location in grid - 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] (axial_position - 0.5) * 1e-3); % Axial positions (adjusted, in meters) - % Convert pressures to intensities [W/cm^2] + % Convert pressure 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; % 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; - % get the distance from the transducer bowl (i.e., trans_pos in PRESTUS) - axial_position_sim = axial_position - (parameters.transducer.trans_pos(end) - 1) * parameters.grid_step_mm; - plot(axial_position_sim, 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_transducer, adjusted_profile_focus, ... + plot(axial_position, profile_target.axial_intensity, ... 'LineWidth', 2, 'Color', [1 0 0], 'DisplayName', 'Desired Profile'); if isfield(parameters, 'expected_focal_distance_bowl') xline(parameters.expected_focal_distance_bowl, '--', ... - 'LineWidth', 1.2, 'DisplayName', 'Expected Focal Distance (mm from bowl)'); + 'LineWidth', 1.2, 'DisplayName', 'Expected Focal Distance (mm from bowl)', 'Color', [1 0 0]); end if isfield(parameters, 'expected_focal_distance_ep') && isfield(parameters, 'expected_focal_distance_bowl') - yline(parameters.expected_focal_distance_ep-parameters.expected_focal_distance_bowl, '--', ... + xline(parameters.transducer.focal_distance_offset, '--', ... 'LineWidth', 1.2, 'DisplayName', 'Exit Plane'); end hold off; @@ -71,19 +63,23 @@ 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 ccd8482e..ffa7de35 100644 --- a/functions/calibration/extract_real_intensity_profile.m +++ b/functions/calibration/extract_real_intensity_profile.m @@ -1,7 +1,7 @@ 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_exit_plane) @@ -13,7 +13,7 @@ % 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_exit_plane: Distance vector from the transducer [mm]. @@ -23,15 +23,15 @@ % - 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 @@ -66,7 +66,7 @@ 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 @@ -91,7 +91,7 @@ plot(dist_from_exit_plane, profile_2, '-x', 'DisplayName', ... ['Measurement 2, focus at ' num2str(focus_wrt_exit_plane_2)]); plot(dist_from_exit_plane, profile_focus, '-x', 'DisplayName', ... - ['Interpolated, focus at ' num2str(focus_wrt_exit_plane)]); + ['Interpolated, focus at ' num2str(desired_focal_distance_ep)]); legend; xlabel('Distance wrt exit plane [mm]'); ylabel('Intensity [W/cm^2]'); @@ -106,13 +106,13 @@ 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); diff --git a/functions/calibration/extract_simulated_profile.m b/functions/calibration/extract_simulated_profile.m index 900e687d..ec4c5430 100644 --- a/functions/calibration/extract_simulated_profile.m +++ b/functions/calibration/extract_simulated_profile.m @@ -1,4 +1,4 @@ -function [pred_axial_pressure] = extract_simulated_profile(initial_res, initial_params, profile_empirical, desired_intensity, equipment_name) +function [profile_sim] = extract_simulated_profile(initial_res, parameters) %-------------------------------------------------------------------------- % % PURPOSE: @@ -9,17 +9,19 @@ % % INPUTS: % initial_res - simulation results structure (contains sensor_data) -% initial_params - structure containing simulation and grid parameters -% profile_empirical - structure with empirical profile info (for labeling) -% desired_intensity - numeric target intensity (for output naming) -% equipment_name - string identifying the experimental setup +% 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: -% pred_axial_pressure - simulated pressure values along the focal axis +% 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, depending on -% initial_params.n_sim_dims (2 or 3). +% The code works for both 2D and 3D simulations %-------------------------------------------------------------------------- %% Retrieve and prepare pressure data @@ -33,36 +35,47 @@ figure; % Convert grid indices to physical distances in millimeters. - p_distance = (1:size(p_max, 1)) * initial_params.grid_step_mm; - p_width = (1:size(p_max, initial_params.n_sim_dims)) * initial_params.grid_step_mm; + p_distance = (1:size(p_max, 1)) * parameters.grid_step_mm; + p_width = (1:size(p_max, parameters.n_sim_dims)) * parameters.grid_step_mm; % Depending on the dimensionality, select an appropriate plane: - if initial_params.n_sim_dims == 2 + if parameters.n_sim_dims == 2 % 2D simulation -> directly available pressure field. p_axialprofile = squeeze(p_max(:, :))'; - elseif initial_params.n_sim_dims == 3 + elseif parameters.n_sim_dims == 3 % 3D simulation -> take the central slice at the transducer’s lateral position. - p_axialprofile = squeeze(p_max(:, initial_params.transducer.trans_pos(2), :))'; + p_axialprofile = squeeze(p_max(:, parameters.transducer.trans_pos(2), :))'; else error('Unsupported simulation dimensionality: expected 2 or 3.'); end + % location of the transducer + i_bowl = parameters.transducer.trans_pos(end); + i_ep = round(parameters.transducer.trans_pos(end)+... + (parameters.transducer.focal_distance_offset/parameters.grid_step_mm)); + i_focus = round(parameters.transducer.trans_pos(end)+... + (parameters.transducer.expected_focal_distance_bowl/parameters.grid_step_mm)); % = parameters.transducer.focus_pos(end); + % 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 [mm]'); - cb = colorbar; %#ok % keep reference in case of future annotations + 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(initial_params.outputs_folder, ... - strcat('Initial_Intensity_map_2D_at_F_', ... - num2str(profile_empirical.focus_wrt_exit_plane), ... - '_at_I_', num2str(desired_intensity), ... - '_', equipment_name, '.png')); + 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); @@ -71,18 +84,40 @@ %% Extract the simulated pressure profile along the focal axis % This section isolates the 1D pressure distribution along the acoustic beam. - if initial_params.n_sim_dims == 2 + if parameters.n_sim_dims == 2 % For 2D: extract the axial profile along the transducer's horizontal position. - i_x = initial_params.transducer.trans_pos(1); - pred_axial_pressure = squeeze(p_max(i_x, :)); + 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 initial_params.n_sim_dims == 3 + elseif parameters.n_sim_dims == 3 % For 3D: extract along both lateral center coordinates. - 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, :)); + 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.water.sound_speed * parameters.medium.water.density) * 1e-4; + + % Compute particle velocity [m/s] + velocity = parameters.transducer.source_amp(1) / ... + (parameters.medium.water.density * parameters.medium.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_step_mm; + axial_end_pos_mm = parameters.default_grid_dims(end) * parameters.grid_step_mm; + axial_position_sim_mm = axial_trans_pos_mm:parameters.grid_step_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/perform_global_search.m b/functions/calibration/perform_global_search.m index 77615d55..8918e1b6 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: @@ -13,7 +13,11 @@ % - min_err: Minimum error achieved during optimization. if ~isfield(parameters.calibration, 'opt_limits') - opt_limits = [1, max(axial_position)]; + % 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 @@ -30,8 +34,8 @@ 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); @@ -90,8 +94,8 @@ 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); diff --git a/functions/calibration/plot_opt_sim_results.m b/functions/calibration/plot_opt_sim_results.m index 364f2d5d..da2099a8 100644 --- a/functions/calibration/plot_opt_sim_results.m +++ b/functions/calibration/plot_opt_sim_results.m @@ -1,77 +1,45 @@ -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(opt_param, sim_id, 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. + % .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. % - 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. + % - 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') + % Get distance of profiles from bowl (shared between profiles) + axial_position = profile_target.axial_distance_bowl; - % 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(axial_position, profile_target.axial_intensity, ... + 'LineWidth', 2, 'Color', 'r', 'DisplayName', 'Target Profile'); + plot(axial_position, profile_oneil.axial_intensity, ... + 'LineWidth', 1, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Original (Analytical))'); + plot(axial_position, 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(axial_position, profile_sim.axial_intensity, ... + 'LineWidth', 1, 'Color', [0.75 0.75 0.75], 'DisplayName', 'Original (Simulated))'); + plot(axial_position, 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 @@ -87,7 +55,9 @@ function plot_opt_sim_results(opt_param, sim_id, axial_position, dist_exit_plane % 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')); + 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,16 +65,14 @@ 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(axial_position, profile_sim_opt.axial_intensity, ... 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'Optimized (Simulated)'); - plot(dist_exit_plane, adjusted_profile_focus, ... + plot(axial_position, 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'); + yline(parameters.calibration.desired_intensity, '--', 'DisplayName', 'Desired Intensity'); xlabel('Distance wrt Mid-Bowl of Transducer [mm]'); ylabel('Intensity [W/cm^2]'); legend('Location', 'best'); @@ -113,13 +81,15 @@ function plot_opt_sim_results(opt_param, sim_id, axial_position, dist_exit_plane % 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')); + 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 - 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)); + max_intensity_index = find(profile_sim_opt.axial_intensity == max(profile_sim_opt.axial_intensity), 1); + fprintf('Estimated distance to the point of maximum intensity: %.2f mm\n', axial_position(max_intensity_index)); end \ No newline at end of file diff --git a/functions/calibration/recalculate_analytical_sol.m b/functions/calibration/recompute_oneil_solution.m similarity index 54% rename from functions/calibration/recalculate_analytical_sol.m rename to functions/calibration/recompute_oneil_solution.m index 63670eef..954d7d06 100644 --- a/functions/calibration/recalculate_analytical_sol.m +++ b/functions/calibration/recompute_oneil_solution.m @@ -1,20 +1,23 @@ -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) +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. - % - p_axial_oneil: Initial O'Neil solution for pressure [Pa]. + % - .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]. - % - 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]. + % - 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(... @@ -28,11 +31,8 @@ (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 = profile_oneil.axial_intensity; 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]); @@ -41,11 +41,11 @@ 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, ... + plot(axial_position, profile_target.axial_intensity, ... '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'); + 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]'); @@ -57,26 +57,33 @@ % 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')); + 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 - p_axial_oneil_opt_summary = p_axial_oneil_opt; + i_axial_oneil_opt_summary = i_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) = []; + i_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))) + fprintf('Estimated distance to the point of maximum intensity: %.2f mm\n', ... + axial_position(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, p_axial_oneil_opt_summary)) + get_flhm_center_position(axial_position, 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 be169cc0..d0866219 100644 --- a/functions/calibration/scale_real_intensity_profile.m +++ b/functions/calibration/scale_real_intensity_profile.m @@ -1,19 +1,23 @@ -function [adjusted_profile_focus, parameters] = 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 @@ -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.default_grid_dims(end)-parameters.pml_size-1)*parameters.grid_step_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_step_mm); + if pad_length > 0 + pad_dist = profile_target.axial_distance_bowl(end) + ... + (1:pad_length) * parameters.grid_step_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..a6444a16 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.water.sound_speed, tran, focal_distance_ep, phase_table); else error('Unsupported transducer manufacturer: %s', tran.manufact); end From 23b3e648544db64093615b61d2cb778eadec642d Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 5 Mar 2026 21:21:05 +0100 Subject: [PATCH 053/144] [hotfix] uncomment calibration --- .../calibration/calibration_transducer.m | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index f474fe9e..f67aaa77 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -83,17 +83,17 @@ sim_param.overwrite_files = 'always'; sim_param.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 + 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 %% Load initial results @@ -104,7 +104,7 @@ initial_params.calibration.prefix = 'Initial_'; %% Extract simulated intensity along the focal axis - + [profile_sim] = extract_simulated_profile(initial_res, initial_params); %% Optimization From 7889769f7f911a3c4caacab735e61fa85e4f64da Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 5 Mar 2026 21:37:18 +0100 Subject: [PATCH 054/144] [calibration] hotfixes + outsource force overwrite --- examples/calibration_standalone.m | 3 +++ functions/calibration/calibration_transducer.m | 3 +-- functions/calibration/extract_simulated_profile.m | 15 +++++++-------- functions/calibration/plot_opt_sim_results.m | 9 ++++----- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index db4fa2bf..f7719ab1 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -199,6 +199,9 @@ disp(['Profiling: ', num2str(sim_id), ' - ', equipment_name{1}, ... ' F ', num2str(desired_focal_distance_ep), ' I ', num2str(desired_intensity)]) + % Set to always overwrite existing calibration files + parameters.overwrite_files = 'always'; + % perform the calibration calibration_transducer(... profile_empirical, ... diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index f67aaa77..204f5f2c 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -80,10 +80,9 @@ parameters.calibration.force_kwavearray == 1 sim_param.use_kwavearray = 1; % force to run with kwavearray setup end - sim_param.overwrite_files = 'always'; sim_param.interactive = 0; - Run the simulation based on the submission method + % 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); diff --git a/functions/calibration/extract_simulated_profile.m b/functions/calibration/extract_simulated_profile.m index ec4c5430..7b3dc773 100644 --- a/functions/calibration/extract_simulated_profile.m +++ b/functions/calibration/extract_simulated_profile.m @@ -49,14 +49,13 @@ error('Unsupported simulation dimensionality: expected 2 or 3.'); end - % location of the transducer - i_bowl = parameters.transducer.trans_pos(end); - i_ep = round(parameters.transducer.trans_pos(end)+... - (parameters.transducer.focal_distance_offset/parameters.grid_step_mm)); - i_focus = round(parameters.transducer.trans_pos(end)+... - (parameters.transducer.expected_focal_distance_bowl/parameters.grid_step_mm)); % = parameters.transducer.focus_pos(end); - - % Plot the pressure map. + % Location of the transducer bowl, exit plane, and focus (in mm) + i_bowl = parameters.transducer.trans_pos(end)*parameters.grid_step_mm; + i_ep = round(parameters.transducer.trans_pos(end)*parameters.grid_step_mm+... + parameters.transducer.focal_distance_offset); + i_focus = parameters.transducer.focus_pos(end)*parameters.grid_step_mm; + + % Plot the pressure map imagesc(p_distance, p_width, p_axialprofile); axis image; hold on; diff --git a/functions/calibration/plot_opt_sim_results.m b/functions/calibration/plot_opt_sim_results.m index da2099a8..ad6457ad 100644 --- a/functions/calibration/plot_opt_sim_results.m +++ b/functions/calibration/plot_opt_sim_results.m @@ -1,4 +1,4 @@ -function plot_opt_sim_results(opt_param, sim_id, profile_target, profile_oneil, profile_oneil_opt, profile_sim, profile_sim_opt, min_err) +function plot_opt_sim_results(opt_param, profile_target, profile_oneil, profile_oneil_opt, profile_sim, profile_sim_opt, min_err) % Plot optimized simulation results and compare with desired profiles % % Arguments: @@ -9,7 +9,6 @@ function plot_opt_sim_results(opt_param, sim_id, profile_target, profile_oneil, % .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. - % - sim_id: Simulation ID for loading specific results. % - profile_target % .axial_distance_bowl: Axial position vector [mm from bowl]. % .axial_intensity: Adjusted desired intensity profile [W/cm^2]. @@ -43,15 +42,15 @@ function plot_opt_sim_results(opt_param, sim_id, profile_target, profile_oneil, 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, ... From 1b3aaf3a1b20a7f9a0e3fd4f752777dc1f2d7923 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 5 Mar 2026 21:56:34 +0100 Subject: [PATCH 055/144] [calibration] hotfix --- functions/calibration/calibration_transducer.m | 1 - 1 file changed, 1 deletion(-) diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index 204f5f2c..cb9061c1 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -174,7 +174,6 @@ % Plot optimized simulation results plot_opt_sim_results(... opt_param, ... - sim_id, ... profile_target, ... profile_oneil, ... profile_oneil_opt, ... From 24e24613892d4a8e9d89a20f330af4fe19088793 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 10:42:53 +0100 Subject: [PATCH 056/144] [calibration] hotfixes --- .../calibration/compute_oneil_solution.m | 2 +- functions/calibration/plot_opt_sim_results.m | 33 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/functions/calibration/compute_oneil_solution.m b/functions/calibration/compute_oneil_solution.m index 7529b65c..5f485070 100644 --- a/functions/calibration/compute_oneil_solution.m +++ b/functions/calibration/compute_oneil_solution.m @@ -51,7 +51,7 @@ xline(parameters.expected_focal_distance_bowl, '--', ... 'LineWidth', 1.2, 'DisplayName', 'Expected Focal Distance (mm from bowl)', 'Color', [1 0 0]); end - if isfield(parameters, 'expected_focal_distance_ep') && isfield(parameters, 'expected_focal_distance_bowl') + if isfield(parameters.transducer, 'focal_distance_offset') xline(parameters.transducer.focal_distance_offset, '--', ... 'LineWidth', 1.2, 'DisplayName', 'Exit Plane'); end diff --git a/functions/calibration/plot_opt_sim_results.m b/functions/calibration/plot_opt_sim_results.m index ad6457ad..86650b40 100644 --- a/functions/calibration/plot_opt_sim_results.m +++ b/functions/calibration/plot_opt_sim_results.m @@ -1,8 +1,8 @@ -function plot_opt_sim_results(opt_param, profile_target, profile_oneil, profile_oneil_opt, profile_sim, profile_sim_opt, 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. + % - 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]. @@ -53,7 +53,7 @@ function plot_opt_sim_results(opt_param, profile_target, profile_oneil, profile_ xlim([0 inf]); % Save the profile comparison figure - fig_path = fullfile(opt_param.outputs_folder, ... + 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')); @@ -70,16 +70,26 @@ function plot_opt_sim_results(opt_param, profile_target, profile_oneil, profile_ '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'); + 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, ... + 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')); @@ -89,6 +99,15 @@ function plot_opt_sim_results(opt_param, profile_target, profile_oneil, profile_ %% Display summary max_intensity_index = find(profile_sim_opt.axial_intensity == max(profile_sim_opt.axial_intensity), 1); - fprintf('Estimated distance to the point of maximum intensity: %.2f mm\n', axial_position(max_intensity_index)); + 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', ... + axial_position(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', ... + axial_position(max_intensity_index)-parameters.transducer.focal_distance_offset); + end \ No newline at end of file From e2d91eda91b0e3f13c16b60e299ea0e165e7ecfe Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 12:03:33 +0100 Subject: [PATCH 057/144] [calibration] hotfixes, documentation --- configs/calibration_config.yaml | 10 +- documentation/doc_calibration.md | 126 +++++++++++++----- documentation/doc_parameters.md | 37 ++--- examples/calibration_standalone.m | 10 -- .../calibration/calibration_transducer.m | 28 +++- functions/calibration/perform_global_search.m | 20 +-- 6 files changed, 149 insertions(+), 82 deletions(-) diff --git a/configs/calibration_config.yaml b/configs/calibration_config.yaml index 2c63ab49..d7c2b901 100644 --- a/configs/calibration_config.yaml +++ b/configs/calibration_config.yaml @@ -9,16 +9,16 @@ # Internal settings - submit_medium: 'slurm' # Simulation submit mode: matlab (debug; doesn't overwrite; force cpu mode), slurm (recommended), or qsub save_in_calibration_folder: true # Boolean flag to save outputs in general output folder or simulation-specific folder. If TRUE, simulations are run in calibration output directory; results are appended to existing calibration for this equipment instead of overwritten. + submit_medium: 'slurm' # Simulation submit mode: matlab (debug; doesn't overwrite; force cpu mode), slurm (recommended), or qsub force_kwavearray: 1 # Force to run free-water simulations with kwavearray? axisymmetric2D: 0 # [EXPERIMENTAL; default: 0] Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency addEPdistance: 1 # Append distance from transducer bowl to exit plane (if zero point in provided profiles = exit plane, != transducer bowl) skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts - optmethod: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) - weights: 0 # Weighting of the original profile during fitting (0 = equal weighting, >1 increasingly narrow Gaussian FWHM) - seed: 251024 # Random seed for optimization - gs_upper_velocity: 0.2 # Upper velocity in global search + opt_method: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) + opt_weights: 0 # Weighting of the original profile during fitting (0 = uniform weighting, >1 increasingly narrow Gaussian FWHM) + opt_seed: 251024 # Random seed for optimization + opt_upper_velocity: 0.2 # Upper velocity in global search # List combinations of setups (e.g., transducer-TPO) and calibration targets. # Every [] specification will be performed for the corresponding (ordered) index equipment combination. diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index 71996623..2041e922 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -77,47 +77,109 @@ To emulate transducers, PRESTUS aims to optimise the velocity and phase settings Transducer calibration relies on an additional config (`calibration_config`) that should be loaded as `parameters.calibration` (see the example `calibration_standalone`). -### Workflow 1: Find calibration settings for one (or multiple) transducer-depth settings [standalone] +### Standalone calibration setup -Script: `calibration_standalone` +**Script: `examples/calibration_standalone`** -The above script sets up and generate calibrated profiles when using the transducer equipment at the Donders Institute. +#### Use cases -- Prerequisites: - - Manufacturer-provided phase tables - - Measured/estimated axial profiles +- Create a library of calibration settings for one (or multiple) transducer-depth settings +- Incorporate manufacturer information +- Template to set up and generate calibrated profiles for transducer equipment at the Donders Institute. + +#### Prerequisites + +- Manufacturer-provided phase tables +- Measured/estimated axial profiles - For the Donders, the empirical profiles (steering tables) can be found on the [FUSInitiative OneDrive](https://radbouduniversiteit.sharepoint.com/:f:/r/sites/FUSInitiative-SHAREDResearchinformation/Shared%20Documents/Software/PRESTUS/Acoustic%20profiling/) +For the Donders, the empirical profiles (steering tables) can be found on the [FUSInitiative OneDrive](https://radbouduniversiteit.sharepoint.com/:f:/r/sites/FUSInitiative-SHAREDResearchinformation/Shared%20Documents/Software/PRESTUS/Acoustic%20profiling/) -- Features: - - automatically load Donders-specific transducer information - - set initial simulation to manufacturer data - - interpolate requested distance from multiple empirically measured distances - - add Focal Distance Offset (FDO) to profile +#### Steps -### Workflow 2: Dynamically update calibration settings (e.g., when iterating across settings) +- Define and initialize the simulation environment by setting paths and loading configuration files with equipment and user calibration data. +- Automatically load Donders-specific transducer information: Identify specific equipment combinations and extract associated transducer and driving system parameters, setting default initial amplitudes and phases. +- Set initial simulation to manufacturer data: Load measured characterization data of the actual transducer’s acoustic field including axial intensity profiles and phase information provided by manufacturers. +- Interpolate requested distance from multiple empirically measured distances +- Add Focal Distance Offset (FDO; via `addEPdistance`): Translate measured focal depths relative to the transducer exit plane into simulation-relevant coordinates centered on the transducer’s mid-bowl. Missing distances are zero-interpolated. +- Select or interpolate the axial intensity profiles for the specified focal depth. +- Call `calibration_transducer` -Function: `calibration_transducer` +### Calibrate phase and amplitude settings -- Use cases: - - More limited information is available (e.g., only the axial profile is known) - - Calibration should be performed dynamically as part of a simulation loop (e.g., for sweeping an amplitude-distance parameter space) +**Function: `calibration_transducer`** -- Prerequisites: - - `profile_empirical.axial_intensity` - Desired intensity profile along focal beam axis - - `profile_empirical.axial_distance_bowl` - Distance (mm from transducer bowl) - - `desired_focal_distance_ep` - Requested focal distance (mm from transducer exit plane) +#### Use cases -### Steps: +- Flexibility: only need to specify the desired axial profile +- Dynamic integration into end-to-end simulation loops (e.g., iterate across a amplitude-distance parameter space) -**Script (`examples/calibration_standalone`):** -- Define and initialize the simulation environment by setting paths and loading configuration files with equipment and user calibration data. -- Identify specific equipment combinations and extract associated transducer and driving system parameters, setting default initial amplitudes and phases. -- Load measured characterization data of the actual transducer’s acoustic field including axial intensity profiles and phase information provided by manufacturers. -- Translate experimentally measured focal depths relative to the transducer exit plane into simulation-relevant coordinates centered on the transducer’s mid-bowl to align measurement and simulation references. -- Select or interpolate the axial intensity profiles for the specified focal depth. +#### Prerequisites + +- `profile_empirical.axial_intensity` | Desired intensity profile along focal beam axis +- `profile_empirical.axial_distance_bowl` | Distance (mm from transducer bowl) +- `desired_focal_distance_ep` | Requested focal distance (mm from transducer exit plane) + +#### Steps + +1. Scale the requested profile to the desired intensity +2. Run free-water simulation + + How simulations will be run will largely be determined by the main `parameters` (e.g., `parameters.code_type`). + However, additional settings in `parameters.calibration` can overwrite default behaviour: + + - `submit_medium` + Simulation submit mode: `slurm` (recommended), `matlab`, `qsub` + - `axisymmetric2D` + [*Experimental*] Overwrite default 3D simulation to perform axisymmetric 2D water simulations (`1` = yes, `0` = no (default)). + - `force_kwavearray` + Force run free-water simulations with kwavearray (recommended)? If set to `0`, simulations use the setting in the default or study-specific config. + +3. Extract simulated intensity along the focal axis +4. Compute analytical O'Neil solution + + Also computes `simulated_analytical_scaling` factor from analytical to simulated intensity + +5. Optimize the virtual transducer’s element velocity and phases + + Goal: match settings such that analytical profile corresponds to scaled empirical profile + Multiple parameters configure the calibration: + - `opt_method` + Optimization backend to use: `FEXminimize` (open source subtoolbox, default) | `GlobalSearch` (MATLAB's Global Optimization Toolbox) + - `opt_weights` + Weighting of the original profile during fitting (0 = uniform weighting, >1 increasingly narrow Gaussian FWHM). + - Uniform (`opt_weights == 0`): Equal weighting across entire profile—optimizes global shape. + - Gaussian (`opt_weights >= 1`): FWHM-centered Gaussian peaking at focal maximum to emphasize near-focus optimization. Higher weights yield narrower Gaussians (sigma = focus_pos / weights). + - `opt_limits` + Distance limits for optimization [mm] + - `opt_seed` + Random seed for optimization. Specifying a seed increases reproducibility. + - `opt_upper_velocity` + Upper velocity to use in global search. + +
+ + > Note: `skip_front_peak_mm` specifies the distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts when calculating the peak distance and FWHM. It does not impact fitting. If the fit is intended over a narower range, define `opt_limits`. + + ![calibration_fitting](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_fitting.png) + The above figure shows an example profile fit. Here, uniform `opt_weights` are used. +
+ +6. Recalculate analytical solution with optimized phases and velocity +7. Calculate optimized source amplitude: + + $$ \mathrm{amplitude\_optimized} = \left( \frac{\mathrm{velocity\_optimized}}{\mathrm{velocity\_original}} \right) \cdot \left( \frac{\mathrm{amplitude\_original}}{\mathrm{simulated\_analytical\_scaling}} \right) $$ + +8. Rerun water simulation with optimized phases and source amplitude +9. Extract simulated optimized intensity along the focal axis + + | Initial simulation | Optimized simulation | + |--------------------|----------------------| + | ![calibration_initial_intensity](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_initial_intensity.png) | ![calibration_opt_intensity](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_opt_intensity.png) | + | Visualized in Step 3. | Visualized in Step 9. | + + Note: The black line indicates the position of the **transducer bowl**, the red line indicated the position of the **transducer exit plane**, the white line indicates the maximum estimated **(focal) intensity**. +
+ +10. Plot comparison between original and optimized results (analytical and simulated) -**Function (`calibration_transducer`):** -- Run free-water simulation. -- Optimize the virtual transducer’s element amplitudes and phases so that its simulated acoustic field matches the measured and scaled transducer profile. -- Confirm via free-water simulation with optimized virtual transducer settings. \ No newline at end of file + ![calibration_optimized_analytical](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_optimized_analytical.png) \ No newline at end of file diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index c3a6d5e0..392589a2 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -195,22 +195,23 @@ see doc_hpc.md For transducer calibration, a separate `calibration_config.yaml` applies that should be loaded as `parameters.calibration`. -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `path_input_axial` | Directory containing axial profiles | | -| `path_input_phase` | Directory containing phase data | | -| `path_output` | Directory for saving free-water simulation results | | -| `path_output_profiles` | Directory for saving optimized profile data | | -| `filename_calibrated_CSV` | Filename of calibrated CSV data | Mandatory only when not generated within standalone script based on equipment name. | -| `submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab` (debug; doesn't overwrite), `qsub` | -| `axisymmetric2D` | Overwrite default 3D simulation to perform axisymmetric 2D. | (`1 = yes, 0 = no`) | +| **Parameter** | **Description** | **Comments** | +|---------------------------------|---------------------------------------------------------|---------------------------------------| +| `path_input_axial` | Directory containing axial profiles | | +| `path_input_phase` | Directory containing phase data | | +| `path_output` | Directory for saving free-water simulation results | | +| `path_output_profiles` | Directory for saving optimized profile data | | +| `filename_calibrated_CSV` | Filename of calibrated CSV data | Mandatory only when not generated within standalone script based on equipment name. | | `save_in_calibration_folder` | `TRUE` (default): save in `path_output` ; `FALSE`: save outputs in `sim_path` (see regular config). | Note: If TRUE calibration results are also appended to existing calibration for this equipment instead of overwritten. | -| `skip_front_peak_mm` | Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts. | Used only for calculating the peak distance and FWHM. | -| `optmethod` | `FEXminimize` (open source subtoolbox) or `GlobalSearch` (MATLAB's Global Optimization Toolbox) | | -| `weights` | Weighting of the original profile during fitting (1 = equal weighting, > 1 Gaussian weighting, increasingly narrow with laregr weights) | | -| `seed` | Random seed for optimization | | -| `addEPdistance` | Append distance from transducer bowl to exit plane (set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl). The profile is padded (between bowl and exit plane) with the initial value available in the profile. This can stabilize the fitting procedure in the near field. | | -| `force_kwavearray` | Force to run free-water simulations with kwavearray? | If active, free-water simulations always use kwavearray. If set to `0`, simulations use the setting in the default or study-specific config. | -| `combinations` | Equipment combinations (must refer to equipment in `equipment_config.yaml`) | Multiple combinations can be specified. Every [] specification will be performed for the corresponding (ordered) index equipment combination. Example: all foci within the first [] will be executed for the first equipment combination. If array is empty ([]), all focal depths of available characterization data will be used. | -| `focal_depths_wrt_exit_plane` | List of focal depths (in mm) to be characterized. | Multiple combinations can be specified. | -| `desired_intensities` | Desired free-water intensities [W/cm^2] | Multiple combinations can be specified. | +| `combinations` | Equipment combinations (must refer to equipment in `equipment_config.yaml`) | Multiple combinations can be specified. Every [] specification will be performed for the corresponding (ordered) index equipment combination. Example: all foci within the first [] will be executed for the first equipment combination. If array is empty ([]), all focal depths of available characterization data will be used. | +| `focal_depths_wrt_exit_plane` | List of focal depths (in mm) to be characterized. | Multiple combinations can be specified. | +| `desired_intensities` | Desired free-water intensities [W/cm^2] | Multiple combinations can be specified. | +| `addEPdistance` | Append distance from transducer bowl to exit plane (set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl). The profile is padded (between bowl and exit plane) with the initial value available in the profile. This can stabilize the fitting procedure in the near field. | | +| `submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab` (debug; doesn't overwrite), `qsub` | +| `axisymmetric2D` | Overwrite default 3D simulation to perform axisymmetric 2D. | (`1 = yes, 0 = no`) | +| `force_kwavearray` | Force to run free-water simulations with kwavearray? | If active, free-water simulations always use kwavearray. If set to `0`, simulations use the setting in the default or study-specific config. | +| `opt_method` | `FEXminimize` (open source subtoolbox) or `GlobalSearch` (MATLAB's Global Optimization Toolbox) | | +| `opt_limits` | Distance limits for optimization [mm] | | +| `opt_weights` | Weighting of the original profile during fitting (1 = equal weighting, > 1 Gaussian weighting, increasingly narrow with larger weights) | | +| `opt_seed` | Random seed for optimization | | +| `skip_front_peak_mm` | Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts. | Used only for calculating the peak distance and FWHM. | \ No newline at end of file diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index f7719ab1..a81b9501 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -185,16 +185,6 @@ % figure; plot(profile_empirical.axial_distance_bowl, profile_empirical.profile_focus); % hold on; xline(parameters.expected_focal_distance_bowl) - % 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 - % Show current iteration disp(['Profiling: ', num2str(sim_id), ' - ', equipment_name{1}, ... ' F ', num2str(desired_focal_distance_ep), ' I ', num2str(desired_intensity)]) diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index cb9061c1..9f94e305 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -71,15 +71,29 @@ % Copy calibration settings to relevant entries in simulation config sim_param = parameters; - sim_param.submit_medium = parameters.calibration.submit_medium; - - % Manage the submission setup + % Overwrite calibration submission medium (if specified) + if isfield(parameters.calibration, 'submit_medium') + sim_param.submit_medium = parameters.calibration.submit_medium; + end + % Force water medium sim_param.simulation_medium = 'water'; - sim_param.savemat = 1; % always save water results + % Force save result matrices + sim_param.savemat = 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 end + % 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 + % Force deactivate interactive mode sim_param.interactive = 0; % Run the simulation based on the submission method @@ -108,8 +122,8 @@ %% Optimization - % Compute O'Neil solution and scaling factor to simulated intensity - [profile_oneil, simulated_oneil_scaling] = ... + % Compute analytical O'Neil solution and scaling factor to simulated intensity + [profile_oneil, simulated_analytical_scaling] = ... compute_oneil_solution(... initial_params, ... profile_sim, ... @@ -134,7 +148,7 @@ % Calculate optimized source amplitude opt_source_amp = round(opt_velocity / profile_sim.velocity * ... initial_params.transducer.source_amp / ... - simulated_oneil_scaling); + simulated_analytical_scaling); % Collect phases opt_source_phase_rad = opt_phases; diff --git a/functions/calibration/perform_global_search.m b/functions/calibration/perform_global_search.m index 8918e1b6..c716df39 100644 --- a/functions/calibration/perform_global_search.m +++ b/functions/calibration/perform_global_search.m @@ -12,7 +12,7 @@ % - opt_velocity: Optimized particle velocity [m/s]. % - min_err: Minimum error achieved during optimization. - if ~isfield(parameters.calibration, 'opt_limits') + 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))); @@ -22,10 +22,10 @@ opt_limits = parameters.calibration.opt_limits; end - if ~isfield(parameters.calibration, 'weights') + 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. @@ -41,19 +41,19 @@ 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, 'gs_upper_velocity') || isempty(parameters.calibration.gs_upper_velocity) - parameters.calibration.gs_upper_velocity = 0.2; % set default for upper velocity to 20 mm/s; + 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), 0.001]; % Lower bounds: [0 rad, 1 mm/s] - upper_bounds = [2 * pi * ones(1, parameters.transducer.n_elements), parameters.calibration.gs_upper_velocity]; % Upper bounds: [2pi rad, 200 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); @@ -70,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; From f036139e74279a724119b2f45b2f7d49d9ef5c67 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 12:08:21 +0100 Subject: [PATCH 058/144] [doc] fix formatting --- documentation/doc_calibration.md | 39 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index 2041e922..bbf207a9 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -21,30 +21,30 @@ 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` +`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` +`transducer.source_amp` Amplitude of the acoustic source (in Pa). [**CALIBRATED**] -`transducer.source_phase_deg` +`transducer.source_phase_deg` Phase of the acoustic source (in degrees). [**CALIBRATED**] -`transducer.source_phase_rad` +`transducer.source_phase_rad` Phase of the acoustic source (in radians). [**CALIBRATED**] #### 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` +`transducer.trans_pos` Position of transducer bowl (XYZ, T1 grid voxel space). -`transducer.focus_pos` +`transducer.focus_pos` Position of stimulation target (XYZ, T1 grid voxel space). #### Target distance parameters @@ -115,9 +115,12 @@ For the Donders, the empirical profiles (steering tables) can be found on the [F #### Prerequisites -- `profile_empirical.axial_intensity` | Desired intensity profile along focal beam axis -- `profile_empirical.axial_distance_bowl` | Distance (mm from transducer bowl) -- `desired_focal_distance_ep` | Requested focal distance (mm from transducer exit plane) +- `profile_empirical.axial_intensity` + Desired intensity profile along focal beam axis +- `profile_empirical.axial_distance_bowl` + Distance (mm from transducer bowl) +- `desired_focal_distance_ep` + Requested focal distance (mm from transducer exit plane) #### Steps @@ -127,11 +130,11 @@ For the Donders, the empirical profiles (steering tables) can be found on the [F How simulations will be run will largely be determined by the main `parameters` (e.g., `parameters.code_type`). However, additional settings in `parameters.calibration` can overwrite default behaviour: - - `submit_medium` + - `submit_medium` Simulation submit mode: `slurm` (recommended), `matlab`, `qsub` - - `axisymmetric2D` + - `axisymmetric2D` [*Experimental*] Overwrite default 3D simulation to perform axisymmetric 2D water simulations (`1` = yes, `0` = no (default)). - - `force_kwavearray` + - `force_kwavearray` Force run free-water simulations with kwavearray (recommended)? If set to `0`, simulations use the setting in the default or study-specific config. 3. Extract simulated intensity along the focal axis @@ -142,18 +145,20 @@ For the Donders, the empirical profiles (steering tables) can be found on the [F 5. Optimize the virtual transducer’s element velocity and phases Goal: match settings such that analytical profile corresponds to scaled empirical profile + Multiple parameters configure the calibration: - - `opt_method` + + - `opt_method` Optimization backend to use: `FEXminimize` (open source subtoolbox, default) | `GlobalSearch` (MATLAB's Global Optimization Toolbox) - - `opt_weights` + - `opt_weights Weighting of the original profile during fitting (0 = uniform weighting, >1 increasingly narrow Gaussian FWHM). - Uniform (`opt_weights == 0`): Equal weighting across entire profile—optimizes global shape. - Gaussian (`opt_weights >= 1`): FWHM-centered Gaussian peaking at focal maximum to emphasize near-focus optimization. Higher weights yield narrower Gaussians (sigma = focus_pos / weights). - - `opt_limits` + - `opt_limits` Distance limits for optimization [mm] - - `opt_seed` + - `opt_seed` Random seed for optimization. Specifying a seed increases reproducibility. - - `opt_upper_velocity` + - `opt_upper_velocity` Upper velocity to use in global search.
From 3fd5b933101949eaf3d32c628d79051fce70e30e Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 12:14:37 +0100 Subject: [PATCH 059/144] [doc] formula formatting --- documentation/doc_calibration.md | 8 ++++---- mkdocs.yaml | 8 +++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index bbf207a9..84d21e05 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -21,14 +21,14 @@ 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`. +`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` +`transducer.source_amp` Amplitude of the acoustic source (in Pa). [**CALIBRATED**] `transducer.source_phase_deg` @@ -55,10 +55,10 @@ 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_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_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 diff --git a/mkdocs.yaml b/mkdocs.yaml index 889db5ef..1e17ac37 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -75,4 +75,10 @@ markdown_extensions: permalink: true - tables # | tables | - pymdownx.highlight # Code highlighting - - pymdownx.superfences # ``` fences with highlighting \ No newline at end of file + - 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 From 874c322f1c2037b46e5cab07537eb9ae06ebb75f Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 12:49:36 +0100 Subject: [PATCH 060/144] [calibration, doc] document calibration config --- configs/calibration_config.yaml | 57 ++++++++++++++++--------------- documentation/doc_calibration.md | 41 ++++++++++++++++++---- documentation/doc_parameters.md | 2 +- examples/calibration_standalone.m | 4 +-- 4 files changed, 66 insertions(+), 38 deletions(-) diff --git a/configs/calibration_config.yaml b/configs/calibration_config.yaml index d7c2b901..be8cf2ae 100644 --- a/configs/calibration_config.yaml +++ b/configs/calibration_config.yaml @@ -1,26 +1,12 @@ # The configuration is structured using YAML (https://yaml.org/). Note that YAML uses spaces, not tabs, for identation. - # Simulation paths (note: specific for calibration) [Change to your directories...] + ## Parameters for standalone calibration (using Donders equipment) + + # Simulation paths (no defaults; change to your directories...) path_input_axial: '//home/common/matlab/PRESTUS/Axial_profiles/' # Base path for axial profiles - path_input_phase: '//home/common/matlab/PRESTUS/Phase_tables/' # Base path for phase data - path_output: '/project/2425122.01/calibration/data/calibration/' # Folder path for saving simulation results - path_output_profiles: '/project/2425122.01/calibration/data/PRESTUS_virtual_parameters/' # Folder for saving optimized profile data - filename_calibrated_CSV: '' # Filename of calibrated CSV data (default: standalone script generates name based on equipment) + path_input_phase: '//home/common/matlab/PRESTUS/Phase_tables/' # Base path for phase data - # Internal settings - - save_in_calibration_folder: true # Boolean flag to save outputs in general output folder or simulation-specific folder. If TRUE, simulations are run in calibration output directory; results are appended to existing calibration for this equipment instead of overwritten. - submit_medium: 'slurm' # Simulation submit mode: matlab (debug; doesn't overwrite; force cpu mode), slurm (recommended), or qsub - force_kwavearray: 1 # Force to run free-water simulations with kwavearray? - axisymmetric2D: 0 # [EXPERIMENTAL; default: 0] Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency - addEPdistance: 1 # Append distance from transducer bowl to exit plane (if zero point in provided profiles = exit plane, != transducer bowl) - skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts - opt_method: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) - opt_weights: 0 # Weighting of the original profile during fitting (0 = uniform weighting, >1 increasingly narrow Gaussian FWHM) - opt_seed: 251024 # Random seed for optimization - opt_upper_velocity: 0.2 # Upper velocity in global search - - # List combinations of setups (e.g., transducer-TPO) and calibration targets. + # List combinations of setups (e.g., transducer-TPO) and calibration targets (no defaults) # Every [] specification will be performed for the corresponding (ordered) index equipment combination. # Example: all foci within the first [] will be executed for the first equipment combination. # If array is empty ([]), all focal depths of available characterization data will be used. @@ -35,12 +21,27 @@ - [35, 83] - [35, 83] - # Example for multiple equipment-depth-intensity combinations: - # combinations: - # - IS_PCD15287_01001_IGT_128_ch_comb_10_ch - # - IS_PCD15287_01002_IGT_128_ch_comb_10_ch - # focal_depths_wrt_exit_plane: - # - [85] - # - [30, 50] - # desired_intensities: - # - [30] \ No newline at end of file + # For curved transducers: are distances in empirical profiles provided from the exit plane? + add_FDO: 1 # Append Focal Distance Offset (distance from transducer bowl to exit plane). Use if zero point in provided profiles = exit plane, != transducer bowl. + + ## Parameters for calibration function + + # I/O + path_output: '/project/2425122.01/calibration/data/calibration/' # Folder for saving simulation results [Change to your directories...] + path_output_profiles: '/project/2425122.01/calibration/data/PRESTUS_virtual_parameters/' # Folder for saving optimized profile data [Change to your directories...] + save_in_calibration_folder: true # Boolean flag to save outputs in general output folder or simulation-specific folder (`parameters.calibration.path_output`). If TRUE, simulations are run in calibration output directory; results are appended to existing calibration for this equipment instead of overwritten. + filename_calibrated_CSV: '' # Filename of calibrated CSV data (default: standalone script generates name based on equipment) + + # Simulation submission overrrides + submit_medium: 'slurm' # Simulation submit mode: matlab (debug; doesn't overwrite; force cpu mode), slurm (recommended), or qsub + force_kwavearray: 1 # Force to run free-water simulations with kwavearray? + axisymmetric2D: 0 # Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency + + # Optimization + opt_method: 'FEXminimize' # 'FEXminimize' (open source subtoolbox) | 'GlobalSearch' (MATLAB's Global Optimization Toolbox) + opt_weights: 0 # Weighting of the original profile during fitting (0 = uniform weighting, >1 increasingly narrow Gaussian FWHM) + opt_seed: 251024 # Random seed for optimization + opt_upper_velocity: 0.2 # Upper velocity in global search + + # Other + skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts in reporting \ No newline at end of file diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index 84d21e05..cc274702 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -89,18 +89,45 @@ Transducer calibration relies on an additional config (`calibration_config`) tha #### Prerequisites -- Manufacturer-provided phase tables -- Measured/estimated axial profiles - +- Measured/estimated axial profiles (`path_input_axial`) +- Manufacturer-provided phase tables (`path_input_phase`) +- Entries in the equipment configuration (`PRESTUS/configs/equipment/equipment_config.yaml`) + For the Donders, the empirical profiles (steering tables) can be found on the [FUSInitiative OneDrive](https://radbouduniversiteit.sharepoint.com/:f:/r/sites/FUSInitiative-SHAREDResearchinformation/Shared%20Documents/Software/PRESTUS/Acoustic%20profiling/) - + +Requested calibrations for unique TPO-transducer & focal depth & intensity combinations can be specified in `calibration_config.yaml` via `combinations`, `focal_depths_wrt_exit_plane`, and `desired_intensities`. + +Each line in the configuration corresponds to a unique TPO-transducer setup. In the following example specification, an IGT transducer with ID `PCD15287_01001` would be emulated for two focal depths of 40 and 50 mm from the exit plane, each for free-water intensities of 30 and 60 W/cm2 respectively. For a second transducer (`PCD15473_01001`), emulated phases and amplitudes would be provided for a depth of 40 mm at an intensity of 30 W/cm2. + +> This example fits a 32-channel transducer with 10 artificial channels. The number of emulated channels can impact the stability of the fitting solution. It is governed by the setup in `equipment_config.yaml`. + +Transducer-TPO setups to be characterized: +``` + combinations: + - IS_PCD15287_01001_IGT_32_ch_comb_10_ch + - IS_PCD15473_01001_IGT_32_ch_comb_10_ch +``` +List of focal depths (in mm) to be characterized: +``` + focal_depths_wrt_exit_plane: + - [40, 50] + - [40] +``` +List of intensities (in free-water W/cm2) to be characterized: + +``` + desired_intensities: + - [30, 60] + - [30] +``` + #### Steps - Define and initialize the simulation environment by setting paths and loading configuration files with equipment and user calibration data. - Automatically load Donders-specific transducer information: Identify specific equipment combinations and extract associated transducer and driving system parameters, setting default initial amplitudes and phases. - Set initial simulation to manufacturer data: Load measured characterization data of the actual transducer’s acoustic field including axial intensity profiles and phase information provided by manufacturers. - Interpolate requested distance from multiple empirically measured distances -- Add Focal Distance Offset (FDO; via `addEPdistance`): Translate measured focal depths relative to the transducer exit plane into simulation-relevant coordinates centered on the transducer’s mid-bowl. Missing distances are zero-interpolated. +- Add Focal Distance Offset (FDO; via `add_FDO`): Translate measured focal depths relative to the transducer exit plane into simulation-relevant coordinates centered on the transducer’s mid-bowl. Missing distances are zero-interpolated. - Select or interpolate the axial intensity profiles for the specified focal depth. - Call `calibration_transducer` @@ -133,7 +160,7 @@ For the Donders, the empirical profiles (steering tables) can be found on the [F - `submit_medium` Simulation submit mode: `slurm` (recommended), `matlab`, `qsub` - `axisymmetric2D` - [*Experimental*] Overwrite default 3D simulation to perform axisymmetric 2D water simulations (`1` = yes, `0` = no (default)). + Overwrite default 3D simulation to perform axisymmetric 2D water simulations (`1` = yes, `0` = no (default)). - `force_kwavearray` Force run free-water simulations with kwavearray (recommended)? If set to `0`, simulations use the setting in the default or study-specific config. @@ -182,7 +209,7 @@ For the Donders, the empirical profiles (steering tables) can be found on the [F | ![calibration_initial_intensity](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_initial_intensity.png) | ![calibration_opt_intensity](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_opt_intensity.png) | | Visualized in Step 3. | Visualized in Step 9. | - Note: The black line indicates the position of the **transducer bowl**, the red line indicated the position of the **transducer exit plane**, the white line indicates the maximum estimated **(focal) intensity**. + Note: The black line indicates the position of the **transducer bowl**, the red line indicated the position of the **transducer exit plane**, the white line indicates the maximum estimated **(focal) intensity** (see [distance definitions](#target-distance-parameters)).
10. Plot comparison between original and optimized results (analytical and simulated) diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 392589a2..e701f593 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -206,7 +206,7 @@ For transducer calibration, a separate `calibration_config.yaml` applies that sh | `combinations` | Equipment combinations (must refer to equipment in `equipment_config.yaml`) | Multiple combinations can be specified. Every [] specification will be performed for the corresponding (ordered) index equipment combination. Example: all foci within the first [] will be executed for the first equipment combination. If array is empty ([]), all focal depths of available characterization data will be used. | | `focal_depths_wrt_exit_plane` | List of focal depths (in mm) to be characterized. | Multiple combinations can be specified. | | `desired_intensities` | Desired free-water intensities [W/cm^2] | Multiple combinations can be specified. | -| `addEPdistance` | Append distance from transducer bowl to exit plane (set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl). The profile is padded (between bowl and exit plane) with the initial value available in the profile. This can stabilize the fitting procedure in the near field. | | +| `add_FDO` | Append Focal Distance Offset (distance from transducer bowl to exit plane). Set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl. | The profile is padded (between bowl and exit plane) with zero. This can stabilize the fitting procedure in the near field. | | `submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab` (debug; doesn't overwrite), `qsub` | | `axisymmetric2D` | Overwrite default 3D simulation to perform axisymmetric 2D. | (`1 = yes, 0 = no`) | | `force_kwavearray` | Force to run free-water simulations with kwavearray? | If active, free-water simulations always use kwavearray. If set to `0`, simulations use the setting in the default or study-specific config. | diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index a81b9501..21d301d5 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -148,7 +148,7 @@ % 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, 'addEPdistance') && parameters.calibration.addEPdistance == 1 + 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; @@ -166,7 +166,7 @@ % that space (i.e., no strong near-field interference) % add distance values prior to the exit plane - if isfield(parameters.calibration, 'addEPdistance') && parameters.calibration.addEPdistance == 1 + 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 From d2af40bfe9d42cdf0ffad63784d8ef698368cc17 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 12:59:58 +0100 Subject: [PATCH 061/144] [doc] separate transducer and calibration doc --- documentation/doc_calibration.md | 77 +------------------------------- documentation/doc_transducer.md | 77 ++++++++++++++++++++++++++++++++ mkdocs.yaml | 7 +-- 3 files changed, 83 insertions(+), 78 deletions(-) create mode 100644 documentation/doc_transducer.md diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index cc274702..4c179d44 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -1,77 +1,4 @@ -## 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**] - -#### Placement parameters - -See also [transducer placement](doc_placement.md). In `water` simulations, the placement will be automatically determined based on the edge of the PML layer. - -`transducer.trans_pos` -Position of transducer bowl (XYZ, T1 grid voxel space). - -`transducer.focus_pos` -Position of stimulation target (XYZ, T1 grid voxel space). - -#### Target distance parameters - -![PRESTUS transducer distance definitions](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/transducer_distances.png) - -The figure shows PRESTUS definitions for annular transducer distances. - -PRESTUS expects one either of the following to be specified (or it will attempt a geometric distance calculation based on `trans_pos` and `focus_pos`, if specified). - -`expected_focal_distance_ep` -Expected distance from the transducer exit plane to the stimulation focus (mm). This often corresponds to the focal depth setting of the driving system - transducer calibration. - -`expected_focal_distance_bowl` -Expected distance from the transducer bowl to the stimulation focus (mm). Will be internally calculated based on `expected_focal_distance_ep`, `dist_to_plane_mm`, and `curv_radius_mm`. - -## Transducer modeling - -The `setup_source` function creates realistic transducer sources for k-Wave simulations, supporting both simple uniform elements and advanced curved arrays that produce focused beams. The function produces binary masks (active source locations) and time-varying pressure signals. Two setups are supported: - -1. Realistic kWaveArray Elements (**recommended**) -The kWaveArray class (`use_kWaveArray`) in the k-Wave toolbox enables realistic simulation of multi-element phased arrays by defining transducers via physical parameters (radius, element position, apodization) rather than discrete grid points. Elements render smoothly regardless of grid resolution. Independent delays/weights per element enable dynamic focusing and element overlap is automatically handled through weighted element contributions. Supports annular rings, axisymmetric 2D simulations, and GPU acceleration. This is the recommended setup type. -
-2. Simple Custom Elements -Creates single- or multi-element bowl-shaped (3D) or arc-shaped (2D) pressure source regions for each transducer element using geometric functions (k-Wave function: `makeBowl`). Each element gets identical continuous wave (CW) signals. Best for uniform, non-overlapping arrays where you want simple control. The discretization of grid points can introduce staircasing artifacts in the resulting `source.p` masks. - -## Calibration of multi-element transducers +## Transducer Calibration To emulate transducers, PRESTUS aims to optimise the velocity and phase settings of virtual transducers such that they produce focal axis profiles similar to those measured with real driving system - transducer setups in free water (measured either in-house via hydrophones or provided by device manufacturers). This calibrated emulation will vary between different transducers, for different focal depth settings, and free-water pressures. @@ -209,7 +136,7 @@ List of intensities (in free-water W/cm2) to be characterized: | ![calibration_initial_intensity](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_initial_intensity.png) | ![calibration_opt_intensity](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/calibration_opt_intensity.png) | | Visualized in Step 3. | Visualized in Step 9. | - Note: The black line indicates the position of the **transducer bowl**, the red line indicated the position of the **transducer exit plane**, the white line indicates the maximum estimated **(focal) intensity** (see [distance definitions](#target-distance-parameters)). + Note: The black line indicates the position of the **transducer bowl**, the red line indicated the position of the **transducer exit plane**, the white line indicates the maximum estimated **(focal) intensity** (see [distance definitions](doc_transducer.md#target-distance-parameters)).
10. Plot comparison between original and optimized results (analytical and simulated) diff --git a/documentation/doc_transducer.md b/documentation/doc_transducer.md new file mode 100644 index 00000000..e7e470be --- /dev/null +++ b/documentation/doc_transducer.md @@ -0,0 +1,77 @@ +## Transducer definition + +PRESTUS does not provide default transducer calibrations. Instead, it encourages users to set up transducers and optimize settings to match their empirical setup. + +See the full [parameter documentation](doc_parameters.md#transducer-specification). + +#### Static parameters + +`transducer.source_freq_hz` +Central frequency of the acoustic source (in Hz). + +`transducer.n_elements` +Number of transducer elements. + +`transducer.Elements_ID_mm` +Inner diameter of each transducer element (in mm). + +`transducer.Elements_OD_mm` +Outer diameter of each transducer element (in mm). + +`transducer.curv_radius_mm` +Radius of curvature of the transducer bowl (in mm). + +`transducer.dist_to_plane_mm` +Distance from the geometric focus to the transducer plane (in mm). + +#### Dynamic parameters: amplitude and focus + +These parameters are traditionally calibrated in free-water simulations (see below). + +`transducer.source_amp` +Amplitude of the acoustic source (in Pa). [**CALIBRATED**] + +`transducer.source_phase_deg` +Phase of the acoustic source (in degrees). [**CALIBRATED**] + +`transducer.source_phase_rad` +Phase of the acoustic source (in radians). [**CALIBRATED**] + +See [transducer calibration](doc_calibration.md). + +#### Placement parameters + +See also [transducer placement](doc_placement.md). + +In `water` simulations, the placement will be automatically determined based on the edge of the PML layer. + +`transducer.trans_pos` +Position of transducer bowl (XYZ, T1 grid voxel space). + +`transducer.focus_pos` +Position of stimulation target (XYZ, T1 grid voxel space). + +#### Target distance parameters + +![PRESTUS transducer distance definitions](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/transducer_distances.png) + +The figure shows PRESTUS definitions for annular transducer distances. + +PRESTUS expects one either of the following to be specified (or it will attempt a geometric distance calculation based on `trans_pos` and `focus_pos`, if specified). + +`expected_focal_distance_ep` +Expected distance from the transducer exit plane to the stimulation focus (mm). This often corresponds to the focal depth setting of the driving system - transducer calibration. + +`expected_focal_distance_bowl` +Expected distance from the transducer bowl to the stimulation focus (mm). Will be internally calculated based on `expected_focal_distance_ep`, `dist_to_plane_mm`, and `curv_radius_mm`. + +## Transducer modeling + +The `setup_source` function creates realistic transducer sources for k-Wave simulations, supporting both simple uniform elements and advanced curved arrays that produce focused beams. The function produces binary masks (active source locations) and time-varying pressure signals. Two setups are supported: + +1. Realistic kWaveArray Elements (**recommended**) +The kWaveArray class (`use_kWaveArray`) in the k-Wave toolbox enables realistic simulation of multi-element phased arrays by defining transducers via physical parameters (radius, element position, apodization) rather than discrete grid points. Elements render smoothly regardless of grid resolution. Independent delays/weights per element enable dynamic focusing and element overlap is automatically handled through weighted element contributions. Supports annular rings, axisymmetric 2D simulations, and GPU acceleration. This is the recommended setup type. +
+2. Simple Custom Elements +Creates single- or multi-element bowl-shaped (3D) or arc-shaped (2D) pressure source regions for each transducer element using geometric functions (k-Wave function: `makeBowl`). Each element gets identical continuous wave (CW) signals. Best for uniform, non-overlapping arrays where you want simple control. The discretization of grid points can introduce staircasing artifacts in the resulting `source.p` masks. + diff --git a/mkdocs.yaml b/mkdocs.yaml index 1e17ac37..4bd28c6e 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -41,12 +41,13 @@ nav: - Installation: doc_installation.md - Getting started: doc_getting-started.md - 📖 Documentation: + - Transducer Setup: doc_transducer.md - Transducer Calibration: doc_calibration.md - - Head preprocessing: doc_preproc.md - - Medium Setup: doc_medium.md - Transducer Placement: doc_placement.md - - Neuronavigation: doc_neuronav.md + - Head Processing: doc_preproc.md + - Medium Setup: doc_medium.md - Outputs: doc_outputs.md + - Neuronavigation Placement: doc_neuronav.md - 🔬 Simulations: - Acoustic: doc_simulations-acoustic.md - Thermal: doc_simulations-thermal.md From b67d51c88d96d29d8f4ca8fa8266bd5326867ed8 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 15:16:26 +0100 Subject: [PATCH 062/144] [bug] fix exit plane visualization, fix bowl distance param readin --- documentation/doc_advanced.md | 32 ++--- documentation/doc_functions.md | 9 +- functions/plot/plot_overlay.m | 47 +------ functions/plot/plot_transducer_overlay.m | 115 ++++++++++++++++++ .../transducer/focal_distance_calculation.m | 37 ++++-- 5 files changed, 164 insertions(+), 76 deletions(-) create mode 100644 functions/plot/plot_transducer_overlay.m diff --git a/documentation/doc_advanced.md b/documentation/doc_advanced.md index efe372ec..1721dd29 100644 --- a/documentation/doc_advanced.md +++ b/documentation/doc_advanced.md @@ -1,32 +1,32 @@ -## Advanced workflows +## Advanced Workflows -#### Iterating input parameters +#### Iterating Parameters The base configuration of PRESTUS allows to specify a single setup, encompassing settings such as transducer specification, entry-target points, and temporal sequence characteristics. It may also be of interest to explore the effects of a range of different parameter settings on an outcome, for instance for benchamrking, but also for choosing a suitable stimulation sequence. -A general strategy toward such parameter iteration is to work on the basus of a single study setup configuration (as described in the [starting guide](doc_getting-started.md)), whose values are iteratively overwritten in a MATLAB script. +A general strategy toward such parameter iteration is to work on the basus of a single study setup configuration (as described in the [Quick Start Guide](doc_getting-started.md)), whose values are iteratively overwritten in a MATLAB script. -For many parameters, this can simply be done by specifying different values for ```parameters.<>```. One exception is the intensity and depth setting of the transducer output, as these depend on the internal transducer calibration. PRESTUS by default provides a transducer calibration ("profiling"). This should in general be used for a given depth and free water intensity. However, this may not provide sufficient flexibility for exploring different output amplitudes. For that reason, PRERSTUS also provides the function ```transducer_calibration```, which can be used within an interative MATLAB loop to find suitable transducer phase and amplitude settings that closely replicate the desired free water output profile. +For many parameters, this can simply be done by specifying different values for `parameters.<>`. One exception is the intensity and depth setting of the transducer output, as these depend on the internal transducer calibration. PRESTUS by default provides a transducer calibration ("profiling"). This should in general be used for a given depth and free water intensity. However, this may not provide sufficient flexibility for exploring different output amplitudes. For that reason, PRERSTUS also provides the function `transducer_calibration`, which can be used within an interative MATLAB loop to find suitable transducer phase and amplitude settings that closely replicate the desired free water output profile ([see transducer calibration](doc_calibration.md)). -#### Sequential simulations +#### Sequential Simulations -Applicable when you stimulate from different coordinates in sequence (for stimulation in parallel, see 'Modeling multiple transducers'). Instead of starting each heating simulation with the default starting temperatures, you can start your nth stimulation with the temperature and CEM43 maps from the previous simulation. To do this, you only need to feed the pipeline your other configs. +Applicable when you stimulate from different coordinates in sequence (for stimulation in parallel, see [multi-transducer modeling](#multi-transducer-modeling)). Instead of starting each heating simulation with the default starting temperatures, you can start your nth stimulation with the temperature and CEM43 maps from the previous simulation. To do this, you need to feed the pipeline your other configurations. Example: Since each config is a structure, you can easily place multiple configs in one structure without any converting. Let's say you have config_1, config_2 and config_3 and you want to run them in sequence. If you want the heatmaps to not carry over, you would run your pipeline like this: -`single_subject_pipeline(subject_id, config_1)` -`single_subject_pipeline(subject_id, config_2)` -`single_subject_pipeline(subject_id, config_3)` +`single_subject_pipeline(subject_id, config_1)` +`single_subject_pipeline(subject_id, config_2)` +`single_subject_pipeline(subject_id, config_3)` But now, you will also feed it the configs for each subsequent simulation: -`sequential_configs.config_2 = config_2` -`sequential_configs.config_3 = config_3` -`single_subject_pipeline(subject_id, config_1, 'sequential_configs', sequential_configs)` +`sequential_configs.config_2 = config_2` +`sequential_configs.config_3 = config_3` +`single_subject_pipeline(subject_id, config_1, 'sequential_configs', sequential_configs)` -Please note that you have to use the names `config_x` in the sequential_configs, and that you have to use integers. So names like 'config_-5', 'config_0', 'config_1234' and 'config_007'. +Please note that you have to use the names `config_x` in the sequential_configs, and that you have to use integers. So names like `config_-5`, `config_0`, `config_1234` and `config_007`. ``` parameters.adopted_heatmap | path to nifti file @@ -34,7 +34,9 @@ parameters.adopted_cumulative_heat | path to nifti file options.sequential_configs ``` -#### Modeling multiple transducers (experimental support) +#### Multi-Transducer Modeling + +Status: *experimental support* Multiple transducers can be specified in layered simulations. See [this pull request](https://github.com/Donders-Institute/PRESTUS/pull/100) for examples. @@ -52,6 +54,6 @@ transducer: Limitations: - simulations that do not inclue a skull or layered setup (e.g., 'water') only model the first transducer - setup with kWaveArray not supported -- different source frequencies not supported (see ```source_sensor_setup.m```) +- different source frequencies not supported (see `source_sensor_setup.m`) - exit-plane related metrics refer to the first transducer - Thermal diffusion is simulated for the COMBINED field, but ALL focal-plane time-course heating plots reflect ONLY the focal plane of the first transducer and MAY MISS HOTSPOTS near other beams. DO NOT use these 1D/2D plots as an exhaustive safety check, but ALWAYS inspect 3D maxT and CEM43 volumes (NIfTIs). \ No newline at end of file diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index eee250d2..84c2fec7 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -77,7 +77,7 @@ The following documents the functions provided in PRESTUS. | `neuronav_get_group_mean_mni` | NEURONAV | Compute group-level average MNI coordinates (both mm and voxel). | | `neuronav_select_and_average_localite` | NEURONAV | Select most recent Localite XML for a session. | | `position_transducer_localite` | NEURONAV | Determines transducer and focus positions in voxel space using Localite data and MRI header. | -| `pct_create_pseudoCT` | PSEUDO-CT | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | +| `pct_create_pseudoCT` | PSEUDO-CT | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | | `pct_skullmapping` | PSEUDO-CT | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. | | `pct_soft_tissue_peak` | PSEUDO-CT | Identifies the soft tissue peak from UTE intensity distribution histograms. | | `fit_pairwiselinear` | PSEUDO-CT | Perform a pairwise linear fit between HU and density values with optional plot. | @@ -86,6 +86,7 @@ The following documents the functions provided in PRESTUS. | `plot_overlay_2d` | PLOT | Overlays map on a background image slice with key positions highlighted. | | `plot_overlay` | PLOT | Visualizes map overlaid on a 2D slice of a 3D background image with advanced options. | | `plot_t1_with_transducer` | PLOT | Creates a plot of a T1 slice oriented along the transducer's axis with overlays. | +| `plot_transducer_overlay` | PLOT | Visualize curved transducer geometry with exit plane. | | `show_3d_head` | PLOT | Visualizes segmented brain images in 3D with transducer placement and target location. | | `show_positioning_plots` | PLOT | Visualizes transducer positioning before and after preprocessing using segmented images. | | `grid_axisymmetry` | SOURCE | Convert grid to axisymmetry | @@ -97,7 +98,7 @@ The following documents the functions provided in PRESTUS. | `thermal_parameters` | THERMAL | Checks and converts protocol timing setup for thermal estimation | | `thermal_plot_protocol` | THERMAL | Visualize the requested protocol timing | | `thermal_plot_sim` | THERMAL | Visualizes heating simulation results over time (temperature, rise, CEM43). Optional video | -| `thermal_simulation` | THERMAL | Simulate ultrasound-induced heating and thermal dose (CEM43) using k-Wave’s kWaveDiffusion from acoustic pressure data across pulsed train repetitions. | +| `thermal_simulation` | THERMAL | Simulate ultrasound-induced heating and thermal dose (CEM43) using k-Wave’s kWaveDiffusion from acoustic pressure data across pulsed train repetitions. | | `focal_distance_calculation` | TRANSDUCER | Compute expected focal distances for (multi-)transducer setup. | | `get_arc` | TRANSDUCER | Generates the coordinates of an arc in 2D space. | | `get_trans_pos_from_trigger_markers` | TRANSDUCER | Determines transducer and target positions using Localite trigger marker files. | @@ -107,8 +108,8 @@ The following documents the functions provided in PRESTUS. | `transducer_positioning` | TRANSDUCER | Determines heuristic transducer placement for a given target. | | `transducer_setup` | TRANSDUCER | Create a transducer mask and label matrix for a computational grid. | | `convert_2d_to_axisymmetric` | TRANSFORM | Converts 2D k-Wave simulation grid, medium properties, and source to axisymmetric form by halving the shorter dimension (right half from center). | -| `convert_axisymmetric_to_2d` | TRANSFORM | Mirrors axisymmetric simulation data (sensor, medium, masks, source) left-right to full 2D, doubles radial dimension, transposes axes, and updates kgrid/positions. | -| `convert_axisymmetric_to_3d` | TRANSFORM | Expands 2D axisymmetric data (via radialExpand2DTo3D) to full 3D grid by duplicating radial dimension, updates positions/grid/kgrid for cubic symmetry. | +| `convert_axisymmetric_to_2d` | TRANSFORM | Mirrors axisymmetric simulation data (sensor, medium, masks, source) left-right to full 2D, doubles radial dimension, transposes axes, and updates kgrid/positions. | +| `convert_axisymmetric_to_3d` | TRANSFORM | Expands 2D axisymmetric data (via radialExpand2DTo3D) to full 3D grid by duplicating radial dimension, updates positions/grid/kgrid for cubic symmetry. | | `convert_final_to_MNI_matlab` | TRANSFORM | Converts an image from subject space to MNI space using MATLAB. | | `convert_final_to_MNI_simnibs` | TRANSFORM | Converts an image to MNI space using SimNIBS. | | `mni2subject_coords_LDfix` | TRANSFORM | Transforms a set of coordinates in MNI space to subject space. | diff --git a/functions/plot/plot_overlay.m b/functions/plot/plot_overlay.m index 502bac93..004920be 100644 --- a/functions/plot/plot_overlay.m +++ b/functions/plot/plot_overlay.m @@ -188,37 +188,8 @@ % 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 ) + options.grid_step = parameters.grid_step_mm; + plot_transducer_overlay(trans_pos, focus_pos, max_data_pos, parameters, options, 0.3, [0.2 0.6 1]) end if options.overlay_segmented @@ -258,20 +229,6 @@ 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') diff --git a/functions/plot/plot_transducer_overlay.m b/functions/plot/plot_transducer_overlay.m new file mode 100644 index 00000000..7b84d029 --- /dev/null +++ b/functions/plot/plot_transducer_overlay.m @@ -0,0 +1,115 @@ +function plot_transducer_overlay(trans_pos, focus_pos, max_data_pos, parameters, options, overlay_weight, overlay_color) +%PLOT_TRANSDUCER_OVERLAY Complete transducer visualization with rectangles and labels +% Inputs: +% 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 +% parameters - Struct with .transducer(1).{curv_radius_mm, dist_to_plane_mm, +% Elements_OD_mm, grid_step_mm, trans_pos, grid_dims} +% 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_step_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_step_mm)), ... + 'Color', 'w', 'FontSize', 10, 'FontWeight', 'bold'); + + % GRID ONSET MARKERS + 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 (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_step_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_step_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/transducer/focal_distance_calculation.m b/functions/transducer/focal_distance_calculation.m index 5470cf83..352598f1 100644 --- a/functions/transducer/focal_distance_calculation.m +++ b/functions/transducer/focal_distance_calculation.m @@ -40,6 +40,25 @@ % % 1) Implement provided expected focal distance + +% 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_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 + % 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 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') || ... @@ -51,22 +70,16 @@ parameters.transducer(ti).expected_focal_distance_ep = parameters.transducer(ti).expected_focal_distance_bowl-parameters.transducer(ti).focal_distance_offset; end end -elseif 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_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 + % 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 end % 2) Rely on specification of transducer and target position - -warning('Expected focal distance not specified for all transducers, trying to get it from transducer and target positions ...') +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 % Fill missing expected_focal_distance_bowl per transducer for ti = 1:numel(parameters.transducer) From da99bbab5f0ea71c99c93e884a2428231a7dca41 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 17:04:37 +0100 Subject: [PATCH 063/144] [doc] update simnibs installation guide --- documentation/doc_installation.md | 80 +++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/documentation/doc_installation.md b/documentation/doc_installation.md index 18cc11fa..b68e9a68 100644 --- a/documentation/doc_installation.md +++ b/documentation/doc_installation.md @@ -39,13 +39,17 @@ Ensure that the paths and subfolders are added in MATLAB. See `simple_main.m` fo #### Donders Institute HPC Cluster -When working on the Donders High-Performance-Computing cluster, PRESTUS and its dependencies (SimNIBS, k-Wave) are already installed. +When working on the Donders High-Performance-Computing cluster, PRESTUS and its dependencies (SimNIBS, k-Wave) are already installed. Note that this may not be the most recent version of either the `main` or `development` branch. -Type ``module load simnibs/4.0.0`` (or add the command to your .bashrc so that it is executed automatically once you login) and add ``addpath('/opt/prestus/dev')`` to your matlab path. 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. +Type `module load simnibs/4.0.0` (or add the command to your .bashrc so that it is executed automatically once you login) and add `addpath('/opt/prestus/dev')` to your matlab path. If you want to use an older version you can also use `addpath('/opt/prestus/0.2.0')`, or older versions. Now you can start matlab R2022b. -For more information on HPC usage, see the [HPC guide](doc_hpc.md) +> `simnibs/4.0.0` and `simnibs/4.1.0` are currently available on the Donders HPC. + +> `/opt/prestus/0.1.0`, `/opt/prestus/0.2.0`, and (an outdated) `/opt/prestus/dev` are currently available on the Donders HPC. +For more information on HPC usage, see the [HPC guide](doc_hpc.md) + If you want to get started with simulations, you can use the PRESTUS example dataset. This command will copy the dataset to your home directory: ``` @@ -61,27 +65,65 @@ For more information on installing, (potentially) compiling, and specifying C++ ## SimNIBS installation You additionally need to install SimNIBS (https://simnibs.github.io/simnibs/build/html/index.html#simnibs-4): e.g., `simnibs_installer/install -s -t /home/USER/SimNIBS`. -We recommend installing SimNIBS within an **anaconda environment** (especially on HPC clusters that often constrict individual user permissions). +We recommend installing SimNIBS within an **anaconda environment** (especially on HPC clusters that often constrict individual user permissions). This allows for the flexible installation of multiple SimNIBS versions. You can follow the [official installation guide](https://simnibs.github.io/simnibs/build/html/installation/conda.html). -The following is a step-by-step guide to install SIMNIBS on the computing cluster. Some steps and filepaths may differ between computing environments. +The following is a step-by-step guide to install SIMNIBS on the Linus computing cluster. Some steps and filepaths may differ between computing environments. 1. Log in to the cluster 2. Open terminal -3. Start an interactive session to come into another execution node (change walltime and memory as needed): `qsub -I -X -N 'simnibs' -l walltime=01:00:00,mem=8g` -4. Download the evironment for SIMNIBS: `wget https://github.com/simnibs/simnibs/releases/latest/download/environment_linux.yml` -5. Copy the generated environment file to your home directory (i.e., /home/neuromod/USER) -6. Navigate to your home directory (i.e., `cd ~`) -7. Load the anaconda environment (e.g., `module load anaconda3/2020.07`) -8. Create the anaconda environment: `conda env create -f ~/environment_linux.yml` -9. Activate the simnibs environment: `source activate simnibs_env` -10. Install the latest version of SIMNIBS: `pip install -f https://github.com/simnibs/simnibs/releases/latest simnibs` -11. [Optional] To setup menu icons, file association, the MATLAB library, and add SimNIBS to the system path, run the postinstall_simnibs script +3. Start an interactive session to come into another execution node (change walltime and memory as needed) + + QSUB: `qsub -I -X -N 'simnibs' -l walltime=01:00:00,mem=8g` + SLURM (Donders default): `srun --pty --x11 -J simnibs -t 1:00:00 --mem=8G bash` -``` -mkdir $HOME/SimNIBS -postinstall_simnibs --setup-links -d $HOME/SimNIBS -``` +4. Download the environment for the desired SIMNIBS version to your home directory (i.e., `/home/neuromod/USER`) + + `wget https://github.com/simnibs/simnibs/releases/download/v4.6.0/environment_linux.yml -O ~/environment_linux_v4.6.0.yml`. This includes dependencies such as Python, MATLAB Runtime, and other prerequisites. + + > **SimNIBS environment versions** + Multiple SimNIBS 4+ environment versions are available: + https://github.com/simnibs/simnibs/releases/download/v4.0.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.1.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.5.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.6.0/environment_linux.yml + To download the latest environment, select the following: + https://github.com/simnibs/simnibs/releases/latest/download/environment_linux.yml + +5. Navigate to your home directory (i.e., `cd ~`) +6. Load the anaconda environment + + e.g., `module load anaconda3/2020.07` + +7. Create the anaconda environment + + `conda env create -f ~/environment_linux_v4.6.0.yml -n simnibs_v4.6.0` + + This will create an environment in `~/.conda/envs/`. + +8. Activate the simnibs environment + + `source activate simnibs_v4.6.0` + +9. Install the desired SimNIBS version + + `pip install https://github.com/simnibs/simnibs/releases/download/v4.6.0/simnibs-4.6.0-cp311-cp311-linux_x86_64.whl` + + > **SimNIBS versions** + Multiple SimNIBS 4+ LINUX versions are available: + https://github.com/simnibs/simnibs/releases/download/v4.0.0/simnibs-4.0.0-cp39-cp39-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.1.0/simnibs-4.1.0-cp39-cp39-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.5.0/simnibs-4.5.0-cp311-cp311-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.6.0/simnibs-4.6.0-cp311-cp311-linux_x86_64.whl + To download the latest environment, select the following: + https://github.com/simnibs/simnibs/releases/latest + +10. [Optional] To setup menu icons, file association, the MATLAB library, and add SimNIBS to the system path, run the postinstall_simnibs script + + ``` + mkdir $HOME/SimNIBS + postinstall_simnibs --setup-links -d $HOME/SimNIBS + ``` #### [Optional: Starting SimNIBS after first installation] @@ -91,7 +133,7 @@ Exit out of everything and following the steps below for starting SimNIBS after 2. `module load anaconda3/2020.07` 3. `source activate simnibs_env` 4. `simnibs_gui` -5. To close the virtual SimNIBS environment: conda deactivate +5. To close the virtual SimNIBS environment: `conda deactivate` #### Specifying SimNIBS paths in PRESTUS From 471d4492234ee29230005b51f3fb8fb57aa52522 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 17:47:04 +0100 Subject: [PATCH 064/144] [seg] hotfix --- documentation/doc_getting-started.md | 8 ++-- documentation/doc_installation.md | 67 +++++++++++++++------------- functions/head/segmentation_run.m | 8 ++-- 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index 4a2ab02d..8164b551 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -1,10 +1,12 @@ # Quick Start Guide -### Install packages +### Install PRESTUS and depencencies See [Installation](doc_installation.md). -### [DEPRECATED] Run the tutorial (documentation/PRESTUS_intro_tutorial.md) +### [Deprecated] Run 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. @@ -64,7 +66,7 @@ It may be desirable to run an intial SimNIBS call prior to running the full pipe To this end, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, and `run_posthoc_water_sims` can be deactivated. This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). -### Run the single_subject_pipeline +### Run the single_subject_pipeline (and iterate) When the configuration is fully specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `single_subject_pipeline` can be submitted using high performance computing jobs (see [HPC documentation](doc_hpc.md)). diff --git a/documentation/doc_installation.md b/documentation/doc_installation.md index b68e9a68..2d107fb3 100644 --- a/documentation/doc_installation.md +++ b/documentation/doc_installation.md @@ -4,17 +4,14 @@ Download and install these tools: -- MATLAB (R2023b*) -- [SimNIBS 4.0.0](https://github.com/simnibs/simnibs) (see instructions below) +- `MATLAB (R2023b)`. Other versions may work as well, but R2023b is the current default deployment. Especially on HPCs with GPUs, more recent MATLAB versions can cause issues. The HPC scripts currently hardcode the MATLAB R2023b module on the Donders HPC. +- [`SimNIBS 4`](https://github.com/simnibs/simnibs) (see [SimNIBS installation](#simnibs-installation)) - toolboxes | They are automatically included as submodules if this repository is recursively cloned (see below). They must be added on MATLAB startup. - - [k-Wave (1.4.1)](https://github.com/ucl-bug/k-wave.git). While k-Wave 1.4 is supported, version 1.4.1 (currently [GitHub exclusive](https://github.com/ucl-bug/k-wave/releases/tag/v1.4.1)) introduced GPU support for thermal simulations with kWaveDiffusion. We therefore recommend cloning k-Wvae 1.4.1 from GitHub. - - [export_fig](https://github.com/altmany/export_fig) - - [FEX-minimize](https://github.com/rodyo/FEX-minimize.git) - - [xml2struct](https://github.com/joe-of-all-trades/xml2struct) + - [`k-Wave (1.4.1)`](https://github.com/ucl-bug/k-wave.git). While k-Wave 1.4 is supported, version 1.4.1 (currently [GitHub exclusive](https://github.com/ucl-bug/k-wave/releases/tag/v1.4.1)) introduced GPU support for thermal simulations with kWaveDiffusion. We therefore recommend cloning k-Wvae 1.4.1 from GitHub. + - [`export_fig`](https://github.com/altmany/export_fig) + - [`FEX-minimize`](https://github.com/rodyo/FEX-minimize.git) + - [`xml2struct`](https://github.com/joe-of-all-trades/xml2struct) -\* Other versions may work as well, but R2023b is the current default deployment. Especially on HPCs with GPUs, more recent MATLAB versions can cause issues. The HPC scripts currently hardcode the MATLAB R2023b module on the Donders HPC. - -Before using the package, you need to have some libraries on your path. Major dependencies are included as submodules in the toolbox folder. If you clone this repository, you can retrieve the submodules as follows: ``` @@ -47,7 +44,6 @@ Type `module load simnibs/4.0.0` (or add the command to your .bashrc so that it > `/opt/prestus/0.1.0`, `/opt/prestus/0.2.0`, and (an outdated) `/opt/prestus/dev` are currently available on the Donders HPC. - For more information on HPC usage, see the [HPC guide](doc_hpc.md) If you want to get started with simulations, you can use the PRESTUS example dataset. This command will copy the dataset to your home directory: @@ -64,8 +60,7 @@ For more information on installing, (potentially) compiling, and specifying C++ ## SimNIBS installation -You additionally need to install SimNIBS (https://simnibs.github.io/simnibs/build/html/index.html#simnibs-4): e.g., `simnibs_installer/install -s -t /home/USER/SimNIBS`. -We recommend installing SimNIBS within an **anaconda environment** (especially on HPC clusters that often constrict individual user permissions). This allows for the flexible installation of multiple SimNIBS versions. +You additionally need to install [SimNIBS 4](https://simnibs.github.io/simnibs/build/html/index.html#simnibs-4). We recommend installing SimNIBS within an **anaconda environment** (especially on HPC clusters that often constrict individual user permissions). This allows for the flexible installation of multiple SimNIBS versions. You can follow the [official installation guide](https://simnibs.github.io/simnibs/build/html/installation/conda.html). The following is a step-by-step guide to install SIMNIBS on the Linus computing cluster. Some steps and filepaths may differ between computing environments. @@ -81,14 +76,18 @@ The following is a step-by-step guide to install SIMNIBS on the Linus computing `wget https://github.com/simnibs/simnibs/releases/download/v4.6.0/environment_linux.yml -O ~/environment_linux_v4.6.0.yml`. This includes dependencies such as Python, MATLAB Runtime, and other prerequisites. +
+ > **SimNIBS environment versions** - Multiple SimNIBS 4+ environment versions are available: - https://github.com/simnibs/simnibs/releases/download/v4.0.0/environment_linux.yml - https://github.com/simnibs/simnibs/releases/download/v4.1.0/environment_linux.yml - https://github.com/simnibs/simnibs/releases/download/v4.5.0/environment_linux.yml - https://github.com/simnibs/simnibs/releases/download/v4.6.0/environment_linux.yml - To download the latest environment, select the following: - https://github.com/simnibs/simnibs/releases/latest/download/environment_linux.yml + Multiple SimNIBS 4+ environment versions are available: + https://github.com/simnibs/simnibs/releases/download/v4.0.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.1.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.5.0/environment_linux.yml + https://github.com/simnibs/simnibs/releases/download/v4.6.0/environment_linux.yml + To download the latest environment, select the following: + https://github.com/simnibs/simnibs/releases/latest/download/environment_linux.yml + +
5. Navigate to your home directory (i.e., `cd ~`) 6. Load the anaconda environment @@ -108,30 +107,34 @@ The following is a step-by-step guide to install SIMNIBS on the Linus computing 9. Install the desired SimNIBS version `pip install https://github.com/simnibs/simnibs/releases/download/v4.6.0/simnibs-4.6.0-cp311-cp311-linux_x86_64.whl` + +
+ + > **SimNIBS versions** + Multiple SimNIBS 4+ LINUX versions are available: + https://github.com/simnibs/simnibs/releases/download/v4.0.0/simnibs-4.0.0-cp39-cp39-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.1.0/simnibs-4.1.0-cp39-cp39-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.5.0/simnibs-4.5.0-cp311-cp311-linux_x86_64.whl + https://github.com/simnibs/simnibs/releases/download/v4.6.0/simnibs-4.6.0-cp311-cp311-linux_x86_64.whl + To download the latest environment, select the following: + https://github.com/simnibs/simnibs/releases/latest - > **SimNIBS versions** - Multiple SimNIBS 4+ LINUX versions are available: - https://github.com/simnibs/simnibs/releases/download/v4.0.0/simnibs-4.0.0-cp39-cp39-linux_x86_64.whl - https://github.com/simnibs/simnibs/releases/download/v4.1.0/simnibs-4.1.0-cp39-cp39-linux_x86_64.whl - https://github.com/simnibs/simnibs/releases/download/v4.5.0/simnibs-4.5.0-cp311-cp311-linux_x86_64.whl - https://github.com/simnibs/simnibs/releases/download/v4.6.0/simnibs-4.6.0-cp311-cp311-linux_x86_64.whl - To download the latest environment, select the following: - https://github.com/simnibs/simnibs/releases/latest +
10. [Optional] To setup menu icons, file association, the MATLAB library, and add SimNIBS to the system path, run the postinstall_simnibs script ``` - mkdir $HOME/SimNIBS - postinstall_simnibs --setup-links -d $HOME/SimNIBS + mkdir $HOME/SimNIBS_v4.6.0 + postinstall_simnibs --setup-links -d $HOME/SimNIBS_v4.6.0 ``` #### [Optional: Starting SimNIBS after first installation] Exit out of everything and following the steps below for starting SimNIBS after first installation to check whether installation was successful. If installation was successful you should now be able to see the SimNIBS gui. -1. Start an interactive session, e.g., `qsub -I -X -N 'simnibs' -l walltime=01:00:00,mem=8g` +1. Start an interactive session (see Step 3 [above](#simnibs-installation)) 2. `module load anaconda3/2020.07` -3. `source activate simnibs_env` +3. `source activate simnibs_v4.6.0` 4. `simnibs_gui` 5. To close the virtual SimNIBS environment: `conda deactivate` @@ -139,6 +142,6 @@ Exit out of everything and following the steps below for starting SimNIBS after In PRESTUS, specify either in the config or directly in MATLAB both the path to the SimNIBS binaries, and the path to shared libraries for GCC 7.2.0: ``` -parameters.simnibs_bin_path = fullfile('/home', 'neuromod', 'USER', '.conda', 'envs', 'simnibs_env', 'bin'); +parameters.simnibs_bin_path = fullfile('/home', 'neuromod', 'USER', '.conda', 'envs', 'simnibs_v4.6.0', 'bin'); parameters.ld_library_path = "/opt/gcc/7.2.0/lib64"; ``` \ No newline at end of file diff --git a/functions/head/segmentation_run.m b/functions/head/segmentation_run.m index a3e23c68..36d06134 100644 --- a/functions/head/segmentation_run.m +++ b/functions/head/segmentation_run.m @@ -54,7 +54,7 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param 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') + if strcmp(parameters.hpc_submit_medium, '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], ... fullfile(log_dir, sprintf('%s_qsub_segment_output_$timestamp.log', subj_id_string)),... @@ -74,7 +74,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.hpc_submit_medium, 'slurm') % Create a temporary SLURM batch script file temp_slurm_file = tempname(log_dir); @@ -115,8 +115,8 @@ 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') 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.hpc_submit_medium); end end From dfaf6f658cf97b974a0474e82063370cc770612e Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 6 Mar 2026 22:59:35 +0100 Subject: [PATCH 065/144] [log] track SimNIBS version used for segmentation --- documentation/doc_functions.md | 1 + documentation/doc_getting-started.md | 2 ++ functions/head/preproc_segmentation.m | 9 ++++-- functions/helper/simnibs_version.m | 44 +++++++++++++++++++++++++++ functions/single_subject_pipeline.m | 2 +- 5 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 functions/helper/simnibs_version.m diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index 84c2fec7..f74fb085 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -56,6 +56,7 @@ The following documents the functions provided in PRESTUS. | `mergeStructure` | HELPER | Merges multiple scalar structures into one. | | `read_ini_file` | HELPER | Read an INI file into MATLAB | | `round_if_integer` | HELPER | Rounds values if they are sufficiently close to integers; otherwise raises an error. | +| `simnibs_version` | HELPER | Get SimNIBS version of segmentation from HTML, print, allocate to `parameters`. | | `subset_fields` | HELPER | Copy designated structure field to new structure. | | `tissuemask_binary` | HELPER | Extract binary tissue segmentation masks (for indexing) | | `upsample_to_grid` | HELPER | Upsamples a 3D image to a higher resolution grid. | diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index 8164b551..57ec7ff7 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -66,6 +66,8 @@ It may be desirable to run an intial SimNIBS call prior to running the full pipe To this end, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, and `run_posthoc_water_sims` can be deactivated. This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). +> It is currently not possible to run SimNIBS in `matlab` submission mode. If this is chosen, MATLAB will print the terminal command to run manually. + ### Run the single_subject_pipeline (and iterate) When the configuration is fully specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `single_subject_pipeline` can be submitted using high performance computing jobs (see [HPC documentation](doc_hpc.md)). diff --git a/functions/head/preproc_segmentation.m b/functions/head/preproc_segmentation.m index 46c7f371..e608476b 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 % @@ -26,7 +26,8 @@ function preproc_segmentation(parameters) 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)); + filename_segmented = fullfile(segmentation_folder, ... + sprintf('sub-%03d_final_contr.nii.gz', parameters.subject_id)); end % Run segmentation (if necessary) @@ -39,8 +40,10 @@ function preproc_segmentation(parameters) 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); + 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/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/single_subject_pipeline.m b/functions/single_subject_pipeline.m index c92591dd..3cb28195 100644 --- a/functions/single_subject_pipeline.m +++ b/functions/single_subject_pipeline.m @@ -48,7 +48,7 @@ if contains(parameters.simulation_medium, {'layered'}) log_timer('start','segmentation', parameters.seg_path); - preproc_segmentation(parameters) + parameters = preproc_segmentation(parameters); log_timer('stop','segmentation'); else disp('No head segmentation necessary...') From f57facc3bd71b9207b6770467ff21c8e5db82dfc Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 15:27:07 +0100 Subject: [PATCH 066/144] [pct] individual media mapping flags and functions, updated doc --- configs/default_config.yaml | 5 +- documentation/doc_functions.md | 3 + documentation/doc_pseudoCT.md | 190 +++++++++++++++---- functions/medium/medium_pct_attenuation.m | 52 ++++++ functions/medium/medium_pct_density.m | 91 ++++++++++ functions/medium/medium_pct_soundspeed.m | 35 ++++ functions/medium/medium_setup.m | 212 +++------------------- functions/thermal/thermal_simulation.m | 11 +- 8 files changed, 371 insertions(+), 228 deletions(-) create mode 100644 functions/medium/medium_pct_attenuation.m create mode 100644 functions/medium/medium_pct_density.m create mode 100644 functions/medium/medium_pct_soundspeed.m diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 11e555cf..832f4bfa 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -42,7 +42,10 @@ overwrite_simnibs: 0 simulation_medium: layered usepseudoCT: 0 # [Optional] use (p)CT (if available) to inform skull medium properties -pseudoCT_variant: kosciessa # pCT mapping algorithms (yaakub/carpino/k-plan/marquet/kosciessa [default]) +pct_mapping_density: 'k-plan' # pCT mapping algorithm for density: k-plan/k-wave/marsac/aubry +pct_mapping_soundspeed: 'k-plan' # pCT mapping algorithm for sound speed: k-plan/marsac/aubry +pct_mapping_attenuation: 'k-plan' # pCT mapping algorithm for attenuation: k-plan/mueller + # Only run specific components of the pipeline? run_source_setup: 1 # Set up acoustic source? (switch off to look at initial segmentation results only) diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index f74fb085..d47d277f 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -69,6 +69,9 @@ The following documents the functions provided in PRESTUS. | `get_alpha_coeff` | MEDIUM | Computes the amplitude attenuation coefficient for a given medium and frequency. | | `medium_properties_nifti` | MEDIUM | Save NifTi image of the specified medium property map. | | `medium_setup` | MEDIUM | Set up medium | +| `medium_pct_density` | MEDIUM | Skull: pCT-informed density mapping | +| `medium_pct_soundspeed` | MEDIUM | Skull: pCT-informed sound speed mapping | +| `medium_pct_attenuation` | MEDIUM | Skull: pCT-informed attenuation mapping | | `neuronav_compute_series_statistics` | NEURONAV | Compute mean position and variability over stimulus train | | `neuronav_convert_MNI_to_native` | NEURONAV | Transform coordinates from MNI space to native subject space | | `neuronav_convert_native_to_MNI` | NEURONAV | Convert native RAS coordinates to MNI space | diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index f2f882e2..13c7f17b 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -5,7 +5,7 @@ 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 pseudoCTs from UTE scans @@ -51,10 +51,13 @@ The following steps are used to create the pseudoCT: - `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. + ### Using (pseudo-)CTs to inform acoustic properties @@ -62,55 +65,164 @@ Hounsfield Units can be used to inform acoustic properties in the bone layer of 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 optionally model density, speed of sounds, and attenuation in the skull bone. -[!warning] The most suitable model remains an active area of research. All mappings should be treated as explorative. See [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43). +>[!warning] The most suitable model remains an active area of research. All mappings should be treated as explorative. See [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43). Mappings will be applied to the uniform skull mask. +#### Mapping skull density -- `carpino` | (**default**) Algorithm described in Carpino et al. (2024).
+Mapping algorithm is goverened by `pct_mapping_density`. - All subsequent steps are only applied inside the skull mask. +- `k-plan` | (**default**) 4-part piecewise linear fit based on k-Plan defaults
+ **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}) $$ ``` - ρ_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] + HU = [-990, 60, 1000, 1950] + density = [1.2, 1060, 1530, 2150] ``` - - 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. + **Thermal simulations** + Overwrite bone density prior to the thermal simulation: + + $$ \rho_\text{skull} = 1850 \, \text{kg/m}^3 $$ - *Reference:* Adapted from Carpino et al. (2024). Transcranial ultrasonic stimulation of the human amygdala to modulate threat learning. MSc thesis. +
-- `yaakub` | Algorithm specified in Yaakub et al. (2023).
+- `k-wave` | 4-part piecewise linear fit based on Schneider et al. (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] - ``` + PseudoCT values are initially shifted by +1000 and thresholded at 300 to align with [hounsfield2density](http://www.k-wave.org/documentation/hounsfield2density.php). - *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). + $$ \rho_\text{skull} = \text{hounsfield2density}(\text{HU} + 1000) $$ -- `k-plan` | Algorithm described in Carpino et al. (2024) with more fixed skull properties
+ 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. - ``` - ρ_skull = hounsfield2density(HU+1000) - c_skull = 1.33 * ρ_skull + 167 - α_skull = alpha_0 - ``` + + **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). + +
+ +- `marsac` | Marsac et al. (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. + + $$ \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}} $$ + + 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: - 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. + $$ \phi_\text{skull} = 1 - \frac{\text{HU}}{\text{HU}_\text{max}} $$ + + 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 $$ + +- `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/functions/medium/medium_pct_attenuation.m b/functions/medium/medium_pct_attenuation.m new file mode 100644 index 00000000..129dd10a --- /dev/null +++ b/functions/medium/medium_pct_attenuation.m @@ -0,0 +1,52 @@ +function [alpha_coeff, alpha_power] = medium_pct_attenuation(parameters, medium, 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 medium.skull.alpha_coeff ~= kPlan_alpha || ... + medium.skull.alpha_power ~= kPlan_alpha_power + warning('Specified attenuation varies from k-Plan setup.') + end + alpha_coeff(skull_idx) = medium.skull.alpha_coeff; + alpha_power(skull_idx) = medium.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) = medium.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^medium.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) = medium.skull.alpha_power; + + case 'none' + + alpha_coeff(skull_idx) = medium.skull.alpha_coeff; + alpha_power(skull_idx) = medium.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..54519c3b --- /dev/null +++ b/functions/medium/medium_pct_density.m @@ -0,0 +1,91 @@ +function [density] = medium_pct_density(parameters, medium, 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.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 + + % 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 = medium.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 = medium.water.density; + rho_bone = medium.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) = medium.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..4e080371 --- /dev/null +++ b/functions/medium/medium_pct_soundspeed.m @@ -0,0 +1,35 @@ +function [sound_speed] = medium_pct_soundspeed(parameters, medium, sound_speed, pseudoCT, skull_idx, algorithm) + +switch algorithm + case 'k-plan' + + sound_speed(skull_idx) = 1.33.*density(skull_idx) + 167; + + case 'marsac' + + c_water = medium.water.sound_speed; % sound speed [m/s] + c_skull = 3360; % max. speed of sound in skull [m/s] + rho_water = medium.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 = medium.water.sound_speed; + c_bone = medium.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) = medium.skull.sound_speed; + + otherwise + error("Specified CT sound speed mapping is not supported.") +end diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index 91a762d9..c9ca880c 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -42,205 +42,51 @@ medium_i = find(strcmp(medium_labels, label_name)); if parameters.usepseudoCT == 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_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; + pct_mapping_density = 'none'; end + [density] = medium_pct_density(parameters, medium, density, pseudoCT, skull_idx, pct_mapping_density); - 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.") - end - - 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_mapping_soundspeed") + pct_mapping_soundspeed = parameters.pct_mapping_soundspeed; + else + pct_mapping_soundspeed = 'none'; end - - 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.") + [sound_speed] = medium_pct_soundspeed(parameters, medium, sound_speed, pseudoCT, skull_idx, pct_mapping_soundspeed); + + % map skull bone attenuation with the desired algorithm + if 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, medium, alpha_coeff, alpha_power, pseudoCT, skull_idx, pct_mapping_attenuation); % [DEBUG] save pCT mapping overview if parameters.debug == 1 diff --git a/functions/thermal/thermal_simulation.m b/functions/thermal/thermal_simulation.m index f8adcb2e..5d1e0444 100644 --- a/functions/thermal/thermal_simulation.m +++ b/functions/thermal/thermal_simulation.m @@ -61,14 +61,15 @@ 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.usepseudoCT ==1 && ... + (~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 From d6280383888e63f5ce9f31e5a4af882a7e1131fb Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 15:37:23 +0100 Subject: [PATCH 067/144] [pct] change use_pseudoCT param name, update doc --- configs/default_config.yaml | 2 +- documentation/doc_parameters.md | 6 ++++-- documentation/doc_pseudoCT.md | 6 +++--- functions/core/load_parameters.m | 4 ++-- functions/head/preproc_head.m | 4 ++-- functions/head/preproc_segmentation.m | 2 +- functions/head/skull_fill_holes.m | 2 +- functions/helper/check_layers.m | 2 +- functions/medium/medium_setup.m | 4 ++-- functions/single_subject_pipeline.m | 2 +- functions/thermal/thermal_simulation.m | 2 +- 11 files changed, 19 insertions(+), 17 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 832f4bfa..8ebac3a3 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -41,7 +41,7 @@ overwrite_simnibs: 0 # select which medium setup to use: 'water' or 'layered' (a layered medium consists of several layers defined below) simulation_medium: layered -usepseudoCT: 0 # [Optional] use (p)CT (if available) to inform skull medium properties +use_pseudoCT: 0 # [Optional] use (p)CT (if available) to inform skull medium properties pct_mapping_density: 'k-plan' # pCT mapping algorithm for density: k-plan/k-wave/marsac/aubry pct_mapping_soundspeed: 'k-plan' # pCT mapping algorithm for sound speed: k-plan/marsac/aubry pct_mapping_attenuation: 'k-plan' # pCT mapping algorithm for attenuation: k-plan/mueller diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index e701f593..f7639504 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -149,8 +149,10 @@ see doc_pseudoCT.md | **Parameter** | **Description** | **Comments** | |-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `usepseudoCT` | Use (pseudo-)CT based mapping? (`1 = yes, 0 = no`) | Maps (pseudo-)HU to tissue properties in the trabecular and cortical skull layer. | -| `pseudoCT_variant` | Mapping algorithm (`yaakub`/`carpino`/`k-plan`/`marquet`) | see doc_pseudoCT.md | +| `use_pseudoCT` | Use (pseudo-)CT based mapping? (`1` = yes | `0` = no (default)) | Map (pseudo-)HU to tissue properties in the skull layer? | +| `pct_mapping_density` | Density mapping algorithm (`k-plan`(default)| `k-wave` | `marsac` | `aubry`) | see [pCT documentation](doc_pseudoCT.md#mapping-density) | +| `pct_mapping_soundspeed` | Sound speed mapping algorithm (`k-plan` | `marsac` | `aubry`) | [pCT documentation](doc_pseudoCT.md#mapping-skull-density) | +| `pct_mapping_attenuation` | Attenuation mapping algorithm (`k-plan` | `mueller` | `aubry`) | [pCT documentation](doc_pseudoCT.md#mapping-attenuation) | ### Sequence timing and baseline temperature for heating simulations diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 13c7f17b..7a80febc 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -63,7 +63,7 @@ Carpino et al. (2024). Transcranial ultrasonic stimulation of the human amygdala 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. -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 optionally model density, speed of sounds, and attenuation in the skull bone. +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 `parameters.pseudoCT_variant`. The current code supports the following variants to use pCTs to optionally model density, speed of sounds, and attenuation in the skull bone. >[!warning] The most suitable model remains an active area of research. All mappings should be treated as explorative. See [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43). Mappings will be applied to the uniform skull mask. @@ -71,7 +71,7 @@ To inform skull properties by pCTs in simulations, set `parameters.usepseudoCT = Mapping algorithm is goverened by `pct_mapping_density`. -- `k-plan` | (**default**) 4-part piecewise linear fit based on k-Plan defaults
+- `k-plan` | 4-part piecewise linear fit based on k-Plan defaults (**default**)
**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): @@ -183,7 +183,7 @@ Mapping algorithm is goverened by `pct_mapping_soundspeed`. Mapping algorithm is goverened by `pct_mapping_attenuation`. -- `k-Plan` | Uniform fixed (**default**) +- `k-plan` | Uniform fixed (**default**) $$ \alpha_\text{skull} = \alpha_\text{coeff} $$ diff --git a/functions/core/load_parameters.m b/functions/core/load_parameters.m index ef9899e8..bf07f0a2 100644 --- a/functions/core/load_parameters.m +++ b/functions/core/load_parameters.m @@ -166,8 +166,8 @@ %% Default: deactivate pseudoCT unless specified - if ~isfield(parameters, 'usepseudoCT') - parameters.usepseudoCT = 0; + if ~isfield(parameters, 'use_pseudoCT') + parameters.use_pseudoCT = 0; end %% Convert additional paths into cell arrays for processing diff --git a/functions/head/preproc_head.m b/functions/head/preproc_head.m index 79e5195b..1845e369 100644 --- a/functions/head/preproc_head.m +++ b/functions/head/preproc_head.m @@ -59,7 +59,7 @@ t1_header = niftiinfo(filename_t1_simnibs); end - if parameters.usepseudoCT == 1 + if parameters.use_pseudoCT == 1 % Load pseudoCT filename_pseudoCT = fullfile(segmentation_folder,'pseudoCT.nii.gz'); pseudoCT_image = niftiread(filename_pseudoCT); @@ -179,7 +179,7 @@ %% [bone mask/pCT] - if parameters.usepseudoCT == 1 + if parameters.use_pseudoCT == 1 [bone_img_rr, ~, ~, ~, ~, ~, ~, bone_img_montage] = ... preproc_align_to_focal_axis(... pseudoCT_image, ... diff --git a/functions/head/preproc_segmentation.m b/functions/head/preproc_segmentation.m index e608476b..af893a35 100644 --- a/functions/head/preproc_segmentation.m +++ b/functions/head/preproc_segmentation.m @@ -33,7 +33,7 @@ % 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 + if parameters.use_pseudoCT == 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 diff --git a/functions/head/skull_fill_holes.m b/functions/head/skull_fill_holes.m index fdff1fcf..882b3312 100644 --- a/functions/head/skull_fill_holes.m +++ b/functions/head/skull_fill_holes.m @@ -38,7 +38,7 @@ % - 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')) + if parameters.use_pseudoCT == 0 && any(contains(labels, 'skull_cortical')) % treat cortical bone as the base layer skull_i = find(ismember(labels, {'skull_cortical'; 'skull_trabecular'})); else diff --git a/functions/helper/check_layers.m b/functions/helper/check_layers.m index c60ef3cf..4645c5c8 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.use_pseudoCT % Collect ALL skull layer indices skull_layers = []; if isfield(parameters.layers, 'skull') diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index c9ca880c..b75724a9 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -40,7 +40,7 @@ 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.use_pseudoCT == 1 && strcmp(label_name, 'skull') skull_idx = find(ismember(medium_masks,medium_i)); % set skull thermal conductivity @@ -233,7 +233,7 @@ end % save a pCT if used - if parameters.usepseudoCT == 1 && parameters.debug == 1 + if parameters.use_pseudoCT == 1 && parameters.debug == 1 filename_pct = fullfile(parameters.debug_dir, sprintf('pct%s', parameters.results_filename_affix)); niftiwrite(pseudoCT, filename_pct, 'Compressed',true); end diff --git a/functions/single_subject_pipeline.m b/functions/single_subject_pipeline.m index 3cb28195..6da9ae8c 100644 --- a/functions/single_subject_pipeline.m +++ b/functions/single_subject_pipeline.m @@ -107,7 +107,7 @@ log_timer('start','medium', parameters.output_dir); - if parameters.usepseudoCT == 1 + if parameters.use_pseudoCT == 1 kwave_medium = medium_setup(parameters, medium_masks, planimg, bone); else kwave_medium = medium_setup(parameters, medium_masks, planimg); diff --git a/functions/thermal/thermal_simulation.m b/functions/thermal/thermal_simulation.m index 5d1e0444..fe4cc742 100644 --- a/functions/thermal/thermal_simulation.m +++ b/functions/thermal/thermal_simulation.m @@ -65,7 +65,7 @@ % 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 && ... +if parameters.use_pseudoCT ==1 && ... (~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 From b7f5cb2723c128ba9136902e8e8059f083e2268f Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 15:41:06 +0100 Subject: [PATCH 068/144] [pct, doc] make a note w.r.t. defaults --- documentation/doc_pseudoCT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 7a80febc..dc559baf 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -63,7 +63,7 @@ Carpino et al. (2024). Transcranial ultrasonic stimulation of the human amygdala 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. -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 `parameters.pseudoCT_variant`. The current code supports the following variants to use pCTs to optionally model density, speed of sounds, and attenuation in the skull bone. +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`. >[!warning] The most suitable model remains an active area of research. All mappings should be treated as explorative. See [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43). Mappings will be applied to the uniform skull mask. From 505276b9dbb5d58184586d3b75d98a344c3e88b9 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 15:57:50 +0100 Subject: [PATCH 069/144] [doc] update parameters --- documentation/doc_parameters.md | 153 +++++++++++++++++--------------- documentation/doc_pseudoCT.md | 2 +- 2 files changed, 80 insertions(+), 75 deletions(-) diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index f7639504..161a643c 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -6,24 +6,25 @@ To set up a specific application, an additional `config_.yaml` should be ### I/O management -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `data_path` | Absolute path to structural input data location. | [string] Mandatory | -| `seg_path` | Absolute path to SimNIBS segmentations. | [string] Mandatory | -| `sim_path` | Absolute path to the simulation output. | [string] Mandatory | -| `simnibs_bin_path` | Absolute path to SimNIBS binaries. | [string] Mandatory | +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-------------------------------------------------------|----------------------| +| `data_path` | Absolute path to structural input data location. | [string] Mandatory | +| `seg_path` | Absolute path to SimNIBS segmentations. | [string] Mandatory | +| `sim_path` | Absolute path to the simulation output. | [string] Mandatory | +| `simnibs_bin_path` | Absolute path to SimNIBS binaries. | [string] Mandatory | | `paths_to_add` | Toolbox paths to add with addpath(). | [cell] e.g., `{"path/to/x", "path/to/Y"}` | | `subpaths_to_add` | Toolbox paths to add with addpath(genpath()). | [cell] e.g., `{"path/to/x", "path/to/Y"}` | | `subject_subfolder` | Manage outputs in subject-specific subdirectories? | (`1 = yes [default], 0 = no`) | | `results_filename_affix` | Affix for result file names | [string] Can be used to differentiate simulation outputs for the same subject-transducer combination(e.g., different intensities and/or targets.) | -| `interactive` | Interactive mode (`1 = yes, 0 = no`). | (`1 = yes, 0 = no`) Asks prior to overwriting or starting long computations. If set to non-interactive, see the flags `overwrite_files` and `overwrite_simnibs`. | -| `overwrite_files` | File overwrite behavior (`ask`, `never`, or `always`). | (`ask`, `never`, or `always`) This parameter does NOT apply to SimNIBS segmentations. | +| `interactive` | Interactive mode (`1 = yes, 0 = no`). | (`1 = yes, 0 = no`) Asks prior to overwriting or starting long computations. If set to non-interactive, see the flags `overwrite_files` and `overwrite_simnibs`. | +| `overwrite_files` | File overwrite behavior (`ask`, `never`, or `always`).| (`ask`, `never`, or `always`) This parameter does NOT apply to SimNIBS segmentations. | | `overwrite_simnibs` | Overwrite SimNIBS segmentation results? | (`1 = yes, 0 = no` [default]) | ### Simulation type +see [doc_preproc.md](doc_preproc.md) -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| | `simulation_medium` | Medium setup for simulation (`water`/`layered`/`phantom`). | Mandatory. | | `layers` | Labels for layered simulation, defining mask indices for different tissue types. | Mandatory. This parameter allows adding or removing layers of interest (if the corresponding segmenation is available and `parameters.medium` has a corresponding label containing the acoustic properties). | | `seg_labels` | Labels for segmentations, specifying indices for CSF, bone mask, and eye regions. | Mandatory. | @@ -32,18 +33,19 @@ To set up a specific application, an additional `config_.yaml` should be | `run_heating_sims` | Run heating simulations. | (`1 = yes, 0 = no`) | | `run_posthoc_water_sims` | Run water simulations following head simulations. | (`1 = yes, 0 = no`) | | `generate_report` | Generate a self-contained HTML simulation report at the end of the pipeline? | (`1 = yes, 0 = no`) | -| `n_sim_dims` | Simulation type (2D / 3D). | `2` = 2D (`kspaceFirstOrder2D`), `3` = 3D(`kspaceFirstOrder3D`). If not specified, it is inferred from `default_grid_dims`. For `axisymmetric` setups (see below), specify 2D. | -| `axisymmetric` | Run 2D simulations with axisymmetry (`kspaceFirstOrderAS `). | (`1 = yes, 0 = no`) see `doc_simulations-acoustic.md`. | +| `n_sim_dims` | Simulation type (2D / 3D). | `2` = 2D (`kspaceFirstOrder2D`), `3` = 3D(`kspaceFirstOrder3D`). If not specified, it is inferred from `default_grid_dims`. For `axisymmetric` setups (see below), specify 2D. | +| `axisymmetric` | Run 2D simulations with axisymmetry (`kspaceFirstOrderAS `). | (`1 = yes, 0 = no`) see [doc_simulations-acoustic.md](doc_simulations-acoustic.md). | | `savemat` | Save outputs of acoustic and/or heating simulations as .mat files? | (`1 = yes, 0 = no`) For many parallel simulations, setting this to 0 saves disk space. | -| `debug` | Activate a more verbose debug mode in which more intermediate output plots and files are provided? | (`1 = yes, 0 = no`) | +| `debug` | Activate a more verbose debug mode in which more intermediate output plots and files are provided? | (`1 = yes, 0 = no`) | ### Segmentation/Preprocessing +see [doc_preproc.md](doc_preproc.md) -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| | `segmentation_software` | Segmentation software used (`headreco` or `charm`). | Use of the former `headreco` may result in errors (e.g., when creating pseudoCTs) due to the assumption of charm-based tissue labels in parts of the codebase. | -| `csf_mask_expansion_factor` | Expansion factor for cerebrospinal fluid (CSF) brain mask; controls CSF mask dilation. [mm] | | -| `smooth_method` | Smoothing filter type ['gaussian'(default)|'box'] | | +| `csf_mask_expansion_factor` | Expansion factor for cerebrospinal fluid (CSF) brain mask; controls CSF mask dilation. [mm] | | +| `smooth_method` | Smoothing filter type [`gaussian`(default) / `box`] | | | `smooth_threshold_skull` | Threshold for smoothing the skull mask; higher values result in thinner masks. | | | `smooth_threshold_other` | Threshold for smoothing other masks; higher values result in thinner masks. | | | `smooth_window` | Number of voxels for smoothing (default: 3) | | @@ -51,36 +53,37 @@ To set up a specific application, an additional `config_.yaml` should be | `wrapradius` | Skull rubber wrap radius [grid voxels] (default: 10) | Larger = tighter rubber that ignores bigger dents; recommended: 2-10 | ### Transducer specification - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `transducer.source_freq_hz` | Central frequency of the acoustic source (in Hz). | | -| `transducer.n_elements` | Number of elements in the transducer. | | -| `transducer.Elements_ID_mm` | Inner diameter of each transducer element (in mm). | | -| `transducer.Elements_OD_mm` | Outer diameter of each transducer element (in mm). | | -| `transducer.curv_radius_mm` | Radius of curvature of the transducer bowl (in mm). | | -| `transducer.dist_to_plane_mm` | Distance from the geometric focus to the transducer plane (in mm). | | -| `transducer.source_amp` | Amplitude of the acoustic source (in Pa). | Must be calibrated. | -| `transducer.source_phase_deg` | Phase of the acoustic source (in degrees). | Must be calibrated. | -| `transducer.source_phase_rad` | Phase of the acoustic source (in radians). | Must be calibrated. | -| `transducer.trans_pos` | Position of transducer bowl (XYZ, T1 grid voxel space). | | -| `transducer.focus_pos` | Position of stimulation target (XYZ, T1 grid voxel space). | | -| `expected_focal_distance_ep` | Expected distance from the transducer exit plane to the stimulation focus (in mm). | Transducer depth setting [Either `expected_focal_distance_ep`, `expected_focal_distance_bowl`, or [`transducer.focus_pos` and `transducer.trans_pos`] have to be specified.] | -| `expected_focal_distance_bowl` | Expected distance from the transducer bowl to the stimulation focus (in mm). | | -| `transducer_from_localite` | Load transducer position from Localite files?. | (`1 = yes, 0 = no` [default]) | -| `reference_transducer_distance_mm` | Distance from tracker to transducer exit plane (in mm). | Allows to correct for varying distances between the infrared trackers attached to the transducer and the exit plane. Only applies when `transducer_from_localite=1`. | -| `use_kWaveArray` | Use the kWaveArray class for modeling transducers. | (`1 = yes, 0 = no`) see k-Wave documentation. | +see [doc_transducer.md](doc_transducer.md) + +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| +| `transducer.source_freq_hz` | Central frequency of the acoustic source (in Hz). | | +| `transducer.n_elements` | Number of elements in the transducer. | | +| `transducer.Elements_ID_mm` | Inner diameter of each transducer element (in mm). | | +| `transducer.Elements_OD_mm` | Outer diameter of each transducer element (in mm). | | +| `transducer.curv_radius_mm` | Radius of curvature of the transducer bowl (in mm). | | +| `transducer.dist_to_plane_mm` | Distance from the geometric focus to the transducer plane (in mm). | | +| `transducer.source_amp` | Amplitude of the acoustic source (in Pa). | Must be calibrated. | +| `transducer.source_phase_deg` | Phase of the acoustic source (in degrees). | Must be calibrated. | +| `transducer.source_phase_rad` | Phase of the acoustic source (in radians). | Must be calibrated. | +| `transducer.trans_pos` | Position of transducer bowl (XYZ, T1 grid voxel space). | | +| `transducer.focus_pos` | Position of stimulation target (XYZ, T1 grid voxel space). | | +| `expected_focal_distance_ep` | Expected distance from the transducer exit plane to the stimulation focus (in mm). | Transducer depth setting [Either `expected_focal_distance_ep`, `expected_focal_distance_bowl`, or [`transducer.focus_pos` and `transducer.trans_pos`] have to be specified.] | +| `expected_focal_distance_bowl` | Expected distance from the transducer bowl to the stimulation focus (in mm). | | +| `transducer_from_localite` | Load transducer position from Localite files?. | (`1 = yes, 0 = no` [default]) | +| `reference_transducer_distance_mm` | Distance from tracker to transducer exit plane (in mm). | Allows to correct for varying distances between the infrared trackers attached to the transducer and the exit plane. Only applies when `transducer_from_localite=1`. | +| `use_kWaveArray` | Use the kWaveArray class for modeling transducers. | (`1 = yes, 0 = no`) see k-Wave documentation. | ### Target specification -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `focus_area_radius` | Radius of the target area around the focus where ISPPA is averaged (in mm). | Default: 5 mm| +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| +| `focus_area_radius` | Radius of the target area around the focus where ISPPA is averaged (in mm). | Default: 5 mm | ### Simulation grid -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| | `grid_step_mm` | Resolution of the computational grid (must be isotropic, in mm). | | | `default_grid_dims` | Default dimensions of the simulation grid `[Nx, Ny, Nz]`. | | | `pml_size` | Size of the Perfectly Matched Layer (PML) used to absorb waves at the grid boundaries (default is 10 for 3D grids). | see k-Wave documentation.| @@ -88,12 +91,13 @@ To set up a specific application, an additional `config_.yaml` should be | `source_ppw` | Points Per Wavelength [default: calculated internally] | | | `source_cfl` | Courant-Friedrichs-Lewy fraction | | | `source_limit_fraction` | Fraction of the stability limit to use for time step (0 = do not use stability limit) | | -| `precision` | Computational precision for both acoustic and thermal simulations | 'single' (default) | 'double' (more precise, but more computationally demanding).| +| `precision` | Computational precision for both acoustic and thermal simulations | `single` (default) / `double` (more precise, but more computationally demanding).| ### Medium properties +see [doc_medium.md](doc_medium.md) -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| +| **Parameter** | **Description** | **Comments** | +|---------------------------------------------------|-----------------------------------------------------------------------------------|----------------------| | `medium.water.sound_speed` | Speed of sound in water (in m/s). | ITRUSST benchmarks. | | `medium.water.density` | Density of water (in kg/m³). | Tissue Properties DB.| | `medium.water.alpha_coeff` | Attenuation coefficient for water (in dB/cm/MHz). | k-Plan documentation.| @@ -145,31 +149,31 @@ To set up a specific application, an additional `config_.yaml` should be ### pseudoCT mapping to skull properties -see doc_pseudoCT.md +see [doc_pseudoCT.md](doc_pseudoCT.md) -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `use_pseudoCT` | Use (pseudo-)CT based mapping? (`1` = yes | `0` = no (default)) | Map (pseudo-)HU to tissue properties in the skull layer? | -| `pct_mapping_density` | Density mapping algorithm (`k-plan`(default)| `k-wave` | `marsac` | `aubry`) | see [pCT documentation](doc_pseudoCT.md#mapping-density) | -| `pct_mapping_soundspeed` | Sound speed mapping algorithm (`k-plan` | `marsac` | `aubry`) | [pCT documentation](doc_pseudoCT.md#mapping-skull-density) | -| `pct_mapping_attenuation` | Attenuation mapping algorithm (`k-plan` | `mueller` | `aubry`) | [pCT documentation](doc_pseudoCT.md#mapping-attenuation) | +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-------------------------------------------------------------------|----------------------| +| `use_pseudoCT` | Use (pseudo-)CT based mapping? (`1` = yes / `0` = no (default)) | Map (pseudo-)HU to tissue properties in the skull layer? | +| `pct_mapping_density` | Density mapping algorithm (`k-plan`(default)/ `k-wave` / `marsac` | `aubry`) | see [pCT documentation](doc_pseudoCT.md#mapping-density) | +| `pct_mapping_soundspeed` | Sound speed mapping algorithm (`k-plan` / `marsac` / `aubry`) | [pCT documentation](doc_pseudoCT.md#mapping-skull-density) | +| `pct_mapping_attenuation` | Attenuation mapping algorithm (`k-plan` / `mueller` / `aubry`) | [pCT documentation](doc_pseudoCT.md#mapping-attenuation) | ### Sequence timing and baseline temperature for heating simulations -see doc_thermal-simulations.md - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `thermal.pd` | Pulse Duration (PD) [seconds] | Duty cycle (DC) inside a Pulse Train [%] is internally calculated as `pd`/`pri`. | | -| `thermal.pri` | Pulse Repetition Interval (PRI) [seconds] (i.e., an on/off cycle during stimulation). | Pulse Repetition Frequency (PRF) is internally calculated as 1/`pri`. | -| `thermal.ptd` | Pulse Train Duration (PTD) [seconds] | | -| `thermal.pt_timestep` | Modeling time steps inside a PT [seconds] | | -| `thermal.ptri` | Pulse Train Repetition Interval (PTRI) [seconds] | Duration of OFF period within a PTRI is internally calculated as `ptri`-`ptd`. | -| `thermal.ptrd` | Pulse Train Repetition Duration (PTRD) [seconds] | | -| `thermal.post_ptri_dur` | Post-PTRI Steady-state Duration [seconds] | | -| `thermal.post_pt_timestep` | Modeling time steps following PT & PTRI [seconds] | | +see [doc_simulations-acoustic.md](doc_simulations-thermal.md) + +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| +| `thermal.pd` | Pulse Duration (PD) [seconds] | Duty cycle (DC) inside a Pulse Train [%] is internally calculated as `pd`/`pri`. | | +| `thermal.pri` | Pulse Repetition Interval (PRI) [seconds] (i.e., an on/off cycle during stimulation). | Pulse Repetition Frequency (PRF) is internally calculated as 1/`pri`.| +| `thermal.ptd` | Pulse Train Duration (PTD) [seconds] | | +| `thermal.pt_timestep` | Modeling time steps inside a PT [seconds] | | +| `thermal.ptri` | Pulse Train Repetition Interval (PTRI) [seconds] | Duration of OFF period within a PTRI is internally calculated as `ptri`-`ptd`. | +| `thermal.ptrd` | Pulse Train Repetition Duration (PTRD) [seconds] | | +| `thermal.post_ptri_dur` | Post-PTRI Steady-state Duration [seconds] | | +| `thermal.post_pt_timestep` | Modeling time steps following PT & PTRI [seconds] | | | `thermal.equal_step_duration` | Whether simulation step durations are equal for on and off cycles (`1 = yes, 0 = no`). | | -| `thermal.cem43_iso` | Calculate CEM43 according to ISO norm (`1`) or kWaveDiffusion (`0`). | | +| `thermal.cem43_iso` | Calculate CEM43 according to ISO norm (`1`) or kWaveDiffusion (`0`). | | | `thermal.temp_0.water` | Initial temperature of water medium before simulation (in °C). | | | `thermal.temp_0.skull` | Initial temperature of skull medium before simulation (in °C). | | | `thermal.temp_0.brain` | Initial temperature of brain medium before simulation (in °C). | | @@ -182,18 +186,19 @@ see doc_thermal-simulations.md ### GPU/HPC options -see doc_hpc.md +see [doc_backend.md](doc_backend.md) [doc_hpc.md](doc_hpc.md) -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| `code_type` | Type of k-Wave code to run (`matlab_cpu`, `matlab_gpu`, `cpp_cpu`, or `cpp_gpu`). | | -| `hpc_submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab`, `qsub` | -| `hpc_gpu` | Request a specific GPU. [Optional] | Not recommended by default, rely on automatic GPU detection instead. May be useful when benchmarking specific GPUs. E.g.,```"nvidia_a100-sxm4-40gb:1"```. ```scontrol show nodes \| egrep -o gres/gpu:.*=[0-9] \| egrep -o 'nvidia_.*=' \| sort \| uniq \| sed 's/=//'``` lists available GPU types. | -| `hpc_partition` | Request a dedicated GPU partition. [Optional] | The Donders HCP provides a ```gpu40g``` partition that consists of nodes with GPU with vRAM > 40 GB. This is the recommended default for thermal simulations of longer protocols. | -| `hpc_reservation` | Request a reserved cue. [Optional] | | -| `ld_library_path` | Path to LD_LIBRARY used during SimNIBS installation. [Optional] | If you experience an `undefined symbol` error in `create_mesh_surf.cpython-39-x86_64-linux-gnu.so`, specify the LD_LIBRARY location e.g., `/opt/gcc/7.2.0/lib64` [torque] or ```/home/'group'/'user'/.conda/envs/simnibs_env/lib/python3.9/site-packages/simnibs/mesh_tools/cgal/../../external/lib/linux``` [slurm] | +| **Parameter** | **Description** | **Comments** | +|-----------------------------------|-------------------------------------------------------------------------------------------|----------------------| +| `code_type` | Type of k-Wave code to run (`matlab_cpu`, `matlab_gpu`, `cpp_cpu`, or `cpp_gpu`). | See [doc_backend.md](doc_backend.md). | +| `hpc_submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab`, `qsub` | +| `hpc_gpu` | Request a specific GPU. [Optional] | Not recommended by default, rely on automatic GPU detection instead. May be useful when benchmarking specific GPUs. E.g.,```"nvidia_a100-sxm4-40gb:1"```. ```scontrol show nodes \| egrep -o gres/gpu:.*=[0-9] \| egrep -o 'nvidia_.*=' \| sort \| uniq \| sed 's/=//'``` lists available GPU types. | +| `hpc_partition` | Request a dedicated GPU partition. [Optional] | The Donders HCP provides a ```gpu40g``` partition that consists of nodes with GPU with vRAM > 40 GB. This is the recommended default for thermal simulations of longer protocols. | +| `hpc_reservation` | Request a reserved cue. [Optional] | | +| `ld_library_path` | Path to LD_LIBRARY used during SimNIBS installation. [Optional] | If you experience an `undefined symbol` error in `create_mesh_surf.cpython-39-x86_64-linux-gnu.so`, specify the LD_LIBRARY location e.g., `/opt/gcc/7.2.0/lib64` [torque] or ```/home/'group'/'user'/.conda/envs/simnibs_env/lib/python3.9/site-packages/simnibs/mesh_tools/cgal/../../external/lib/linux``` [slurm] | ### Transducer calibration +see [doc_calibration.md](doc_calibration.md) For transducer calibration, a separate `calibration_config.yaml` applies that should be loaded as `parameters.calibration`. @@ -213,7 +218,7 @@ For transducer calibration, a separate `calibration_config.yaml` applies that sh | `axisymmetric2D` | Overwrite default 3D simulation to perform axisymmetric 2D. | (`1 = yes, 0 = no`) | | `force_kwavearray` | Force to run free-water simulations with kwavearray? | If active, free-water simulations always use kwavearray. If set to `0`, simulations use the setting in the default or study-specific config. | | `opt_method` | `FEXminimize` (open source subtoolbox) or `GlobalSearch` (MATLAB's Global Optimization Toolbox) | | -| `opt_limits` | Distance limits for optimization [mm] | | +| `opt_limits` | Distance limits for optimization [mm] | | | `opt_weights` | Weighting of the original profile during fitting (1 = equal weighting, > 1 Gaussian weighting, increasingly narrow with larger weights) | | | `opt_seed` | Random seed for optimization | | | `skip_front_peak_mm` | Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts. | Used only for calculating the peak distance and FWHM. | \ No newline at end of file diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index dc559baf..170d2f63 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -65,7 +65,7 @@ Hounsfield Units can be used to inform acoustic properties in the bone layer of 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`. ->[!warning] The most suitable model remains an active area of research. All mappings should be treated as explorative. See [this issue](https://github.com/Donders-Institute/PRESTUS/issues/43). Mappings will be applied to the uniform skull mask. +>[!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. #### Mapping skull density From 359ceffa694ab606109e19e79cf5518f14f82f0b Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 16:01:10 +0100 Subject: [PATCH 070/144] [doc] update parameters --- documentation/doc_parameters.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 161a643c..d69dfc1b 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -154,9 +154,9 @@ see [doc_pseudoCT.md](doc_pseudoCT.md) | **Parameter** | **Description** | **Comments** | |-----------------------------------|-------------------------------------------------------------------|----------------------| | `use_pseudoCT` | Use (pseudo-)CT based mapping? (`1` = yes / `0` = no (default)) | Map (pseudo-)HU to tissue properties in the skull layer? | -| `pct_mapping_density` | Density mapping algorithm (`k-plan`(default)/ `k-wave` / `marsac` | `aubry`) | see [pCT documentation](doc_pseudoCT.md#mapping-density) | -| `pct_mapping_soundspeed` | Sound speed mapping algorithm (`k-plan` / `marsac` / `aubry`) | [pCT documentation](doc_pseudoCT.md#mapping-skull-density) | -| `pct_mapping_attenuation` | Attenuation mapping algorithm (`k-plan` / `mueller` / `aubry`) | [pCT documentation](doc_pseudoCT.md#mapping-attenuation) | +| `pct_mapping_density` | Density mapping algorithm (`k-plan`(default)/`k-wave`/`marsac`/`aubry`) | see [documentation](doc_pseudoCT.md#mapping-skull-density) | +| `pct_mapping_soundspeed` | Sound speed mapping algorithm (`k-plan`/`marsac`/`aubry`) | see [documentation](doc_pseudoCT.md#mapping-skull-sound-speed) | +| `pct_mapping_attenuation` | Attenuation mapping algorithm (`k-plan`/`mueller`/`aubry`) | see [documentation](doc_pseudoCT.md#mapping-skull-attenuation) | ### Sequence timing and baseline temperature for heating simulations From 7027738ea336f7c956429ab0790a28d893a954cc Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 16:45:11 +0100 Subject: [PATCH 071/144] [pipeline] add more parameters to turn off pipeline steps important: this will impact the default parameters...add new fields --- configs/default_config.yaml | 12 +-- documentation/doc_getting-started.md | 2 +- documentation/doc_parameters.md | 10 ++- functions/single_subject_pipeline.m | 117 ++++++++++++--------------- 4 files changed, 66 insertions(+), 75 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 8ebac3a3..ac03c160 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -46,12 +46,14 @@ pct_mapping_density: 'k-plan' # pCT mapping algorithm for density: k-plan/k- pct_mapping_soundspeed: 'k-plan' # pCT mapping algorithm for sound speed: k-plan/marsac/aubry pct_mapping_attenuation: 'k-plan' # pCT mapping algorithm for attenuation: k-plan/mueller - # Only run specific components of the pipeline? -run_source_setup: 1 # Set up acoustic source? (switch off to look at initial segmentation results only) -run_acoustic_sims: 1 # Run the acoustic simulations? -run_heating_sims: 0 # Run the heating simulations? Run once acoustic simulation provides sensible results. -run_posthoc_water_sims: 1 # Run the simulations in free water with exactly the same parameter? Verify that free-water intensity matches target. + +run_grid_setup: 1 # Setup grid and run head processing? Mandatory for simulations. +run_medium_setup: 1 # Map medium acoustic properties Mandatory for simulations. +run_source_setup: 1 # Set up acoustic source? Mandatory for simulations. +run_acoustic_sims: 1 # Run acoustic simulations? +run_heating_sims: 0 # Run heating simulations? Run once acoustic simulation provides sensible results. +run_posthoc_water_sims: 1 # Run simulations in free water with identical setup? generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? # Select which k-Wave code to run (see 'run_simulations' for more documentation) diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index 57ec7ff7..4b91af2c 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -64,7 +64,7 @@ See [Placement](doc_placement.md). It may be desirable to run an intial SimNIBS call prior to running the full pipeline incl. segmentation postprocessing, source setup, acoustic and thermal simulations. By default, existing segmentations will be reused and not overwritten unless explicitly requested with `overwrite_simnibs` (regardless of `overwrite_files`). -To this end, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, and `run_posthoc_water_sims` can be deactivated. This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). +To this end, `run_grid_setup`, `run_medium_setup`, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, and `run_posthoc_water_sims` can be deactivated (set to `0`). This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). > It is currently not possible to run SimNIBS in `matlab` submission mode. If this is chosen, MATLAB will print the terminal command to run manually. diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index d69dfc1b..8e5e0d97 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -28,7 +28,9 @@ see [doc_preproc.md](doc_preproc.md) | `simulation_medium` | Medium setup for simulation (`water`/`layered`/`phantom`). | Mandatory. | | `layers` | Labels for layered simulation, defining mask indices for different tissue types. | Mandatory. This parameter allows adding or removing layers of interest (if the corresponding segmenation is available and `parameters.medium` has a corresponding label containing the acoustic properties). | | `seg_labels` | Labels for segmentations, specifying indices for CSF, bone mask, and eye regions. | Mandatory. | -| `run_source_setup` | Set up acoustic sources. | (`1 = yes, 0 = no`) | +| `run_grid_setup` | Setup grid and run head processing? Mandatory for simulations. | (`1 = yes, 0 = no`) | +| `run_medium_setup` | Map medium acoustic properties? Mandatory for simulations. | (`1 = yes, 0 = no`) | +| `run_source_setup` | Set up acoustic source? Mandatory for simulations. | (`1 = yes, 0 = no`) | | `run_acoustic_sims` | Run acoustic simulations. | (`1 = yes, 0 = no`) | | `run_heating_sims` | Run heating simulations. | (`1 = yes, 0 = no`) | | `run_posthoc_water_sims` | Run water simulations following head simulations. | (`1 = yes, 0 = no`) | @@ -154,9 +156,9 @@ see [doc_pseudoCT.md](doc_pseudoCT.md) | **Parameter** | **Description** | **Comments** | |-----------------------------------|-------------------------------------------------------------------|----------------------| | `use_pseudoCT` | Use (pseudo-)CT based mapping? (`1` = yes / `0` = no (default)) | Map (pseudo-)HU to tissue properties in the skull layer? | -| `pct_mapping_density` | Density mapping algorithm (`k-plan`(default)/`k-wave`/`marsac`/`aubry`) | see [documentation](doc_pseudoCT.md#mapping-skull-density) | -| `pct_mapping_soundspeed` | Sound speed mapping algorithm (`k-plan`/`marsac`/`aubry`) | see [documentation](doc_pseudoCT.md#mapping-skull-sound-speed) | -| `pct_mapping_attenuation` | Attenuation mapping algorithm (`k-plan`/`mueller`/`aubry`) | see [documentation](doc_pseudoCT.md#mapping-skull-attenuation) | +| `pct_mapping_density` | Density mapping algorithm (`k-plan`(default)/`k-wave`/`marsac`/`aubry`/`none`) | see [documentation](doc_pseudoCT.md#mapping-skull-density) | +| `pct_mapping_soundspeed` | Sound speed mapping algorithm (`k-plan`(default)/`marsac`/`aubry`/`none`) | see [documentation](doc_pseudoCT.md#mapping-skull-sound-speed) | +| `pct_mapping_attenuation` | Attenuation mapping algorithm (`k-plan`(default)/`mueller`/`aubry`/`none`) | see [documentation](doc_pseudoCT.md#mapping-skull-attenuation) | ### Sequence timing and baseline temperature for heating simulations diff --git a/functions/single_subject_pipeline.m b/functions/single_subject_pipeline.m index 6da9ae8c..be0e5022 100644 --- a/functions/single_subject_pipeline.m +++ b/functions/single_subject_pipeline.m @@ -45,24 +45,14 @@ fprintf('========================================\n'); fprintf('SEGMENTATION \n'); fprintf('========================================\n\n'); + log_timer('start','segmentation', parameters.seg_path); if contains(parameters.simulation_medium, {'layered'}) - log_timer('start','segmentation', parameters.seg_path); parameters = preproc_segmentation(parameters); - log_timer('stop','segmentation'); else disp('No head segmentation necessary...') end - - % 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...') - return; - end + log_timer('stop','segmentation'); % ==================================================================== %% GRID: PREPROCESS structural MRI & POSITION transducer + target @@ -73,59 +63,63 @@ fprintf('========================================\n'); fprintf('GRID SETUP & HEAD PREPROC \n'); fprintf('========================================\n\n'); - 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, 'run_grid_setup') || parameters.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.output_dir); - if parameters.use_pseudoCT == 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, 'run_medium_setup') || parameters.run_medium_setup==1 + if parameters.use_pseudoCT == 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)); + 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 +131,9 @@ fprintf('========================================\n'); fprintf('K-WAVE SOURCE SETUP \n'); fprintf('========================================\n\n'); + log_timer('start','source', parameters.output_dir); - if parameters.run_source_setup - log_timer('start','source', parameters.output_dir); - + if ~isfield(parameters, 'run_source_setup') || parameters.run_source_setup==1 max_sound_speed = max(kwave_medium.sound_speed(:)); [kgrid, source, sensor, source_labels] = ... source_sensor_setup(... @@ -161,10 +154,10 @@ [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,7 +167,6 @@ fprintf('========================================\n'); fprintf('ACOUSTIC SIMULATION \n'); fprintf('========================================\n\n'); - log_timer('start','acoustic', parameters.output_dir); filename_sensor_data = fullfile(parameters.output_dir, ... @@ -207,7 +199,6 @@ else parameters.acoustics_available = 0; end - log_timer('stop', 'acoustic'); % ========================================================================= @@ -217,14 +208,11 @@ fprintf('========================================\n'); fprintf('ACOUSTIC ANALYSIS \n'); fprintf('========================================\n\n'); + log_timer('start','acoustic_analysis', parameters.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] = ... 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...') results_acoustic = []; @@ -233,6 +221,7 @@ acoustic_pressure = []; highlighted_pos = []; end + log_timer('stop', 'acoustic_analysis'); % ========================================================================= %% THERMAL SIMULATIONS @@ -241,7 +230,6 @@ fprintf('========================================\n'); fprintf('THERMAL SIMULATIONS \n'); fprintf('========================================\n\n'); - log_timer('start','thermal', parameters.output_dir); parameters.heating_available = 0; @@ -321,15 +309,15 @@ fprintf('========================================\n'); fprintf('THERMAL ANALYSIS \n'); fprintf('========================================\n\n'); + log_timer('start','thermal_analysis', parameters.output_dir); if parameters.heating_available == 1 - log_timer('start','thermal_analysis', parameters.output_dir); 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...') end + log_timer('stop','thermal_analysis'); % ================================================================ %% CREATE NIFTI IMAGES @@ -339,7 +327,6 @@ fprintf('========================================\n'); fprintf('NIFTI IMAGES \n'); fprintf('========================================\n\n'); - log_timer('start','nifti', parameters.output_dir); simulation_nifti(parameters, planimg, results_acoustic, ... From 9ae32156f05f6d60d42ffa4190428ab07b9f9ee9 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 17:25:31 +0100 Subject: [PATCH 072/144] [grid] optional grid padding to prevent transducer out-of-bounds --- configs/default_config.yaml | 10 +- documentation/doc_parameters.md | 17 +-- documentation/doc_preproc.md | 2 +- functions/head/preproc_crop_grid.m | 182 ++++++++++++++++------------- 4 files changed, 116 insertions(+), 95 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index ac03c160..8e20b0d2 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -231,9 +231,6 @@ default_grid_dims: [144, 144, 400] # [points per dimension] # http://www.k-wave.org/documentation/example_na_controlling_the_pml.php) pml_size: 10 -# To what extent the computational grid can be expanded in order to get lower prime numbers to (potentially) speed up computations -grid_max_expand: 40 - source_ppw: [] # Points Per Wavelength [default: calculated internally] source_cfl: 0.15 # Courant-Friedrichs-Lewy fraction source_limit_fraction: 0.9 # Fraction of the stability limit to use for time step (0 = do not use stability limit) @@ -242,10 +239,9 @@ precision: 'single' # Computational precision for both acoustic and thermal simu #------------ Segmentation / Head preprocessing -# Expansion factor for the cerebro-spinal fluid brain mask; -# if the mask ends up being too big, try reducing it -# (40 is the conservative default; the mask is dilated with csf_mask_expansion_factor/voxel_size_mm cubic kernel) -csf_mask_expansion_factor: 40 +pad_mm: 0 # Apply symmetric padding to grid prior to transducer + PML setup? +csf_mask_expansion_factor: 40 # Expansion factor for the cerebro-spinal fluid brain mask to capture head areas of interest in simulation +grid_max_expand: 40 # Prime number grid expansion size to (potentially) speed up computations # Thresholds for smoothing the skull and other masks smooth_method: 'gaussian' # Filter type ['gaussian' (default)|'box'] diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 8e5e0d97..c604cc3d 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -46,7 +46,9 @@ see [doc_preproc.md](doc_preproc.md) | **Parameter** | **Description** | **Comments** | |-----------------------------------|-----------------------------------------------------------------------------------|----------------------| | `segmentation_software` | Segmentation software used (`headreco` or `charm`). | Use of the former `headreco` may result in errors (e.g., when creating pseudoCTs) due to the assumption of charm-based tissue labels in parts of the codebase. | -| `csf_mask_expansion_factor` | Expansion factor for cerebrospinal fluid (CSF) brain mask; controls CSF mask dilation. [mm] | | +| `pad_mm` | Size of symmetric padding to apply to grid prior to transducer + PML setup [mm; default: `0`] | | +| `csf_mask_expansion_factor` | Expansion factor for cerebrospinal fluid (CSF) brain mask; controls CSF mask dilation. [grid voxels] | | +| `grid_max_expand` | Maximum expansion [mm] for computational grid to optimize prime numbers and speed up computations. | | | `smooth_method` | Smoothing filter type [`gaussian`(default) / `box`] | | | `smooth_threshold_skull` | Threshold for smoothing the skull mask; higher values result in thinner masks. | | | `smooth_threshold_other` | Threshold for smoothing other masks; higher values result in thinner masks. | | @@ -86,14 +88,13 @@ see [doc_transducer.md](doc_transducer.md) | **Parameter** | **Description** | **Comments** | |-----------------------------------|-----------------------------------------------------------------------------------|----------------------| -| `grid_step_mm` | Resolution of the computational grid (must be isotropic, in mm). | | -| `default_grid_dims` | Default dimensions of the simulation grid `[Nx, Ny, Nz]`. | | +| `grid_step_mm` | Resolution of the computational grid (must be isotropic, in mm). | | +| `default_grid_dims` | Default dimensions of the simulation grid for free-water simulations. | | | `pml_size` | Size of the Perfectly Matched Layer (PML) used to absorb waves at the grid boundaries (default is 10 for 3D grids). | see k-Wave documentation.| -| `grid_max_expand` | Maximum expansion [mm] for computational grid to optimize prime numbers and speed up computations. | | -| `source_ppw` | Points Per Wavelength [default: calculated internally] | | -| `source_cfl` | Courant-Friedrichs-Lewy fraction | | -| `source_limit_fraction` | Fraction of the stability limit to use for time step (0 = do not use stability limit) | | -| `precision` | Computational precision for both acoustic and thermal simulations | `single` (default) / `double` (more precise, but more computationally demanding).| +| `source_ppw` | Points Per Wavelength [default: calculated internally] | | +| `source_cfl` | Courant-Friedrichs-Lewy fraction | | +| `source_limit_fraction` | Fraction of the stability limit to use for time step | `0`: do not use stability limit | +| `precision` | Computational precision for both acoustic and thermal simulations | `single` (default) / `double` (more precise, but more computationally demanding).| ### Medium properties see [doc_medium.md](doc_medium.md) diff --git a/documentation/doc_preproc.md b/documentation/doc_preproc.md index 6b61678f..f3e7e9b1 100644 --- a/documentation/doc_preproc.md +++ b/documentation/doc_preproc.md @@ -28,7 +28,7 @@ PRESTUS attempts to enforce skull continuity (i.e., absence of holes) either by ### 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. The CSF segmentation (if available) will be expanded by `csf_mask_expansion_factor` mm to guide bounds of the layered medium. Water layer will be assigned outside of the crop mask. 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. +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. diff --git a/functions/head/preproc_crop_grid.m b/functions/head/preproc_crop_grid.m index 0b257954..24ab90a6 100644 --- a/functions/head/preproc_crop_grid.m +++ b/functions/head/preproc_crop_grid.m @@ -4,97 +4,121 @@ % 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 .pad_mm (symmetric padding mm), .grid_step_mm, .pml_size, +% .csf_mask_expansion_factor, .grid_max_expand, .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, 'pad_mm') || isempty(parameters.pad_mm) + parameters.pad_mm = 0; % mm +end +pad_voxels = round(parameters.pad_mm / parameters.grid_step_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 +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 (safe with padding) +i_water = find(strcmp(fieldnames(parameters.medium), 'water')); +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); +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.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 From b268bb194af9bde619543a23ad464f794e3eef58 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 17:28:47 +0100 Subject: [PATCH 073/144] [doc] update --- configs/default_config.yaml | 10 ++-------- documentation/doc_parameters.md | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 8e20b0d2..68f48177 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -222,14 +222,8 @@ medium: #------------ Simulation grid grid_step_mm: 0.5 # Grid resolution (must be isotropic) [mm] - -# These only need to be set manually if the simulations are done for water only -default_grid_dims: [144, 144, 400] # [points per dimension] - -# select PML size, the default one for 3D is 10 (PML is the layer used in -# kWaves to absorb waves reaching the end of the computational grid, -# http://www.k-wave.org/documentation/example_na_controlling_the_pml.php) -pml_size: 10 +default_grid_dims: [144, 144, 400] # [points per dimension] [used for setting up free-water grid] +pml_size: 10 # Perfectly Matched Layer (PML) size [3D recommendation: 10] source_ppw: [] # Points Per Wavelength [default: calculated internally] source_cfl: 0.15 # Courant-Friedrichs-Lewy fraction diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index c604cc3d..ef7707d9 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -90,7 +90,7 @@ see [doc_transducer.md](doc_transducer.md) |-----------------------------------|-----------------------------------------------------------------------------------|----------------------| | `grid_step_mm` | Resolution of the computational grid (must be isotropic, in mm). | | | `default_grid_dims` | Default dimensions of the simulation grid for free-water simulations. | | -| `pml_size` | Size of the Perfectly Matched Layer (PML) used to absorb waves at the grid boundaries (default is 10 for 3D grids). | see k-Wave documentation.| +| `pml_size` | Perfectly Matched Layer (PML) size to absorb waves at grid boundaries. | See [k-Wave documentation](http://www.k-wave.org/documentation/example_na_controlling_the_pml.php). Default: `10` (recommended for 3D grids)| | `source_ppw` | Points Per Wavelength [default: calculated internally] | | | `source_cfl` | Courant-Friedrichs-Lewy fraction | | | `source_limit_fraction` | Fraction of the stability limit to use for time step | `0`: do not use stability limit | From 36f734c61474426d0cc4fbdccc6d2a00c8dd7f92 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 17:54:12 +0100 Subject: [PATCH 074/144] [clean] medium mapping --- functions/medium/medium_setup.m | 75 +++++++++++++++------------------ 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index b75724a9..92ee7426 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -26,8 +26,8 @@ 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 temp_0 = empty_grid; @@ -116,8 +116,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; @@ -129,12 +129,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 @@ -177,10 +180,7 @@ 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] - - % specify the medium as a kWave-compatible structure + %% 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, ... @@ -192,50 +192,45 @@ 'absorption_fraction', absorption_fraction,... 'temp_0', temp_0); - % save images for debugging + %% [debug] save acoustic property images if parameters.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); + 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); + 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); + 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); + 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); + 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); + 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); + 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.use_pseudoCT == 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.use_pseudoCT == 1 + filename_pct = fullfile(parameters.debug_dir, sprintf('pct%s', parameters.results_filename_affix)); + niftiwrite(pseudoCT, filename_pct, 'Compressed',true); + end end end \ No newline at end of file From 970449b54a9de81bd2b8347a796fb3d8dfce8a8d Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 9 Mar 2026 19:16:16 +0100 Subject: [PATCH 075/144] [feature] implement smoothing of acoustic property maps, redefine smoothing kernels --- configs/default_config.yaml | 3 +- documentation/doc_parameters.md | 3 +- documentation/doc_preproc.md | 10 +++- functions/head/smooth_img.m | 95 +++++++++++++++++++++------------ functions/medium/medium_setup.m | 40 ++++++++++++++ 5 files changed, 113 insertions(+), 38 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 68f48177..c1b5a587 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -241,7 +241,8 @@ grid_max_expand: 40 # Prime number grid expansion size to (potentially) speed up smooth_method: 'gaussian' # Filter type ['gaussian' (default)|'box'] smooth_threshold_skull: 0.5 # (higher threshold -> thinner mask) smooth_threshold_other: 0.5 # (higher threshold -> thinner mask) -smooth_window: 5 # Number of voxels for smoothing +smooth_window: 2 # Smoothing FWHM [grid voxels] +smooth_properties: false # Apply smoothing with parameters above to acoustic property maps? # Settings for shrinkwrap (if requested) skullfillmethod: 'rubberwrap' # Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index ef7707d9..43a5d638 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -52,9 +52,10 @@ see [doc_preproc.md](doc_preproc.md) | `smooth_method` | Smoothing filter type [`gaussian`(default) / `box`] | | | `smooth_threshold_skull` | Threshold for smoothing the skull mask; higher values result in thinner masks. | | | `smooth_threshold_other` | Threshold for smoothing other masks; higher values result in thinner masks. | | -| `smooth_window` | Number of voxels for smoothing (default: 3) | | +| `smooth_window` | FWHM of smoothing `gaussian` or `box` with (default: 2) [voxel] | | | `skullfillmethod` | Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] | | | `wrapradius` | Skull rubber wrap radius [grid voxels] (default: 10) | Larger = tighter rubber that ignores bigger dents; recommended: 2-10 | +| `smooth_properties` | Apply smoothing with parameters above to acoustic property maps? | `false`(default)/`true` | ### Transducer specification see [doc_transducer.md](doc_transducer.md) diff --git a/documentation/doc_preproc.md b/documentation/doc_preproc.md index f3e7e9b1..700a6d2c 100644 --- a/documentation/doc_preproc.md +++ b/documentation/doc_preproc.md @@ -19,8 +19,8 @@ Segmentations are mapped onto the requested layer medium masks (to later assign 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: -- `box` applies a uniform convolution, which is fastest, but also blocky and prone to skull holes at low binarization thresholds (default: 5-voxel kernel) -- `gaussian` (default) uses smooth3 for isotropic blurring, yielding smooth gradients and moderate edge erosion (default: 5-voxel kernel) +- `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%). @@ -35,3 +35,9 @@ All image transformations are concatenated to update transducer and target posit ### 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/functions/head/smooth_img.m b/functions/head/smooth_img.m index 4d2b56c1..0eaf02a0 100644 --- a/functions/head/smooth_img.m +++ b/functions/head/smooth_img.m @@ -1,24 +1,29 @@ -function thresholded_img = smooth_img(unsmoothed_img, smooth_window, threshold, method) -%SMOOTH_IMG - Smooth tissue masks +function thresholded_img = smooth_img(unsmoothed_img, fwhm_voxels, threshold, method) +%SMOOTH_IMG - Smooth tissue masks with voxel-space FWHM % % SYNOPSIS: -% smoothed = smooth_img(binary_mask, smooth_window, threshold, 'anisotropic') +% smoothed = smooth_img(binary_mask, 2, 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_voxels - FWHM in grid voxels +% 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_voxels=2 (e.g., 1mm equivalent @ 0.5mm grid) +% +% HOW IT WORKS: +% GAUSSIAN: FWHM → σ = fwhm/2.35482 → imgaussfilt3(img, σ) +% Creates smooth tissue gradients (center-high, edge-low weights) +% BOX: Uniform average over round(fwhm_voxels)^3 cube +% Equal weights across kernel. + arguments unsmoothed_img {mustBeNumericOrLogical} - smooth_window (1,1) double {mustBePositive} = 4 + fwhm_voxels (1,1) double {mustBePositive} = 2.0 threshold (1,1) double {mustBeInRange(threshold, 0, 1)} = 0.5 method string {mustBeMember(method, ["gaussian", "box"])} = "gaussian" end @@ -26,30 +31,52 @@ 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'); + % Convert FWHM(voxels) → σ(voxels) for Gaussian + sigma_voxels = fwhm_voxels / 2.35482; % FWHM = 2.35482 × σ + + if fwhm_voxels > 0 + fprintf('Smoothing: FWHM=%.1f voxels → σ=%.2f voxels\n', fwhm_voxels, sigma_voxels); + + if ndims_img == 2 + switch method + case "gaussian" + % === GAUSSIAN: Weighted average, bell-shaped kernel === + % Weights: exp(-r²/(2σ²)). Center=1.0, edges~0.01 + smoothed_img = imgaussfilt(img, sigma_voxels); + + case "box" + % === BOX: Uniform average over cubic kernel === + % All voxels in kernel get equal weight (1/N_total) + kernel_size = max(3, round(fwhm_voxels)); % e.g. fwhm=4 → 4×4 + kernel = ones(kernel_size) / kernel_size^2; % Sum=1.0 + smoothed_img = imfilter(img, kernel, 'replicate'); + end + + elseif ndims_img == 3 + switch method + case "gaussian" + % === 3D GAUSSIAN: Rotational symmetric smoothing === + % Each voxel = weighted average of 6σ-radius neighborhood + smoothed_img = imgaussfilt3(img, sigma_voxels); + + case "box" + % === 3D BOX: Equal average over cubic volume === + % e.g. fwhm=4 → [4 4 4] cube = 64 voxels, each weight=1/64 + kernel_size = max(3, round(fwhm_voxels)); + kernel = ones([kernel_size kernel_size kernel_size]) / kernel_size^3; + smoothed_img = convn(img, kernel, 'same'); + 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 - thresholded_img = smoothed_img > threshold; + % Post-process binarization (optional) + if threshold > 0 + thresholded_img = smoothed_img > threshold; % Hard threshold + else + thresholded_img = smoothed_img; % Keep continuous probabilities + end end diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index 92ee7426..d6da6332 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -180,6 +180,46 @@ alpha_power_fixed = alpha_power; end + %% smooth medium masks + + if isfield(parameters, 'smooth_properties') && parameters.smooth_properties == true + disp("Smoothing acoustic proprty maps ..."); + + tmp_density = density; % keep unsmoothed image for figure + + sound_speed = smooth_img(sound_speed, parameters.smooth_window, 0, parameters.smooth_method); + density = smooth_img(density, parameters.smooth_window, 0, parameters.smooth_method); + alpha_coeff_fixed = smooth_img(alpha_coeff_fixed, parameters.smooth_window, 0, parameters.smooth_method); + thermal_conductivity = smooth_img(thermal_conductivity, parameters.smooth_window, 0, parameters.smooth_method); + specific_heat = smooth_img(specific_heat, parameters.smooth_window, 0, parameters.smooth_method); + perfusion_coeff = smooth_img(perfusion_coeff, parameters.smooth_window, 0, parameters.smooth_method); + absorption_fraction = smooth_img(absorption_fraction, parameters.smooth_window, 0, parameters.smooth_method); + temp_0 = smooth_img(temp_0, parameters.smooth_window, 0, parameters.smooth_method); + + % [DEBUG] Plot unsmoothed and smoothed density + if parameters.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.debug_dir, ... + sprintf('sub-%03d_%s_density_smoothing_changes%s.png', ... + parameters.subject_id, parameters.simulation_medium, parameters.results_filename_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, ... From 68cd43d29fbea06a874ead5eddbc2e3fff05df34 Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Tue, 10 Mar 2026 09:33:36 +0100 Subject: [PATCH 076/144] [fix] use profile_target.axial_distance_bowl as x-axis in compute_oneil_solution and recompute_oneil_solution. Fixes size mismatch error when plotting the desired profile: axial_position (from simulation grid) and profile_target.axial_intensity can have different lengths. --- functions/calibration/compute_oneil_solution.m | 2 +- functions/calibration/recompute_oneil_solution.m | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/calibration/compute_oneil_solution.m b/functions/calibration/compute_oneil_solution.m index 5f485070..0cad5066 100644 --- a/functions/calibration/compute_oneil_solution.m +++ b/functions/calibration/compute_oneil_solution.m @@ -45,7 +45,7 @@ hold on; plot(axial_position, profile_sim.axial_intensity, ... '--', 'LineWidth', 1.5, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Inital Simulated Intensity'); - plot(axial_position, profile_target.axial_intensity, ... + 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_bowl') xline(parameters.expected_focal_distance_bowl, '--', ... diff --git a/functions/calibration/recompute_oneil_solution.m b/functions/calibration/recompute_oneil_solution.m index 954d7d06..c37c74e8 100644 --- a/functions/calibration/recompute_oneil_solution.m +++ b/functions/calibration/recompute_oneil_solution.m @@ -41,7 +41,7 @@ hold on; plot(axial_position, i_axial_oneil_opt, ... 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'Optimized Profile (Analytical)'); - plot(axial_position, profile_target.axial_intensity, ... + 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'); From 07d93c822a7b4dca72e47aaf51cee88eef2fad5e Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Tue, 10 Mar 2026 10:15:55 +0100 Subject: [PATCH 077/144] fix the axial_profile issue in plot_sim as well --- functions/calibration/plot_opt_sim_results.m | 21 +++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/functions/calibration/plot_opt_sim_results.m b/functions/calibration/plot_opt_sim_results.m index 86650b40..3c430c67 100644 --- a/functions/calibration/plot_opt_sim_results.m +++ b/functions/calibration/plot_opt_sim_results.m @@ -22,22 +22,19 @@ function plot_opt_sim_results(parameters, profile_target, profile_oneil, profile % .axial_intensity: Optimized simulation intensity profile [W/cm^2]. % - min_err: Minimum optimization error. - % Get distance of profiles from bowl (shared between profiles) - axial_position = profile_target.axial_distance_bowl; - %% Plot comparison of profiles figure('Position', [10, 10, 900, 500]); hold on; - plot(axial_position, profile_target.axial_intensity, ... + plot(profile_target.axial_distance_bowl, profile_target.axial_intensity, ... 'LineWidth', 2, 'Color', 'r', 'DisplayName', 'Target Profile'); - plot(axial_position, profile_oneil.axial_intensity, ... + plot(profile_oneil.axial_distance_bowl, profile_oneil.axial_intensity, ... 'LineWidth', 1, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Original (Analytical))'); - plot(axial_position, profile_oneil_opt.axial_intensity, ... + plot(profile_oneil_opt.axial_distance_bowl, profile_oneil_opt.axial_intensity, ... 'LineWidth', 1, 'Color', [0 0 0], 'LineStyle', ':', 'DisplayName', 'Optimized (Analytical)'); - plot(axial_position, profile_sim.axial_intensity, ... + plot(profile_sim.axial_distance_bowl, profile_sim.axial_intensity, ... 'LineWidth', 1, 'Color', [0.75 0.75 0.75], 'DisplayName', 'Original (Simulated))'); - plot(axial_position, profile_sim_opt.axial_intensity, ... + plot(profile_sim_opt.axial_distance_bowl, profile_sim_opt.axial_intensity, ... 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'Optimized (Simulated)'); hold off; @@ -64,9 +61,9 @@ function plot_opt_sim_results(parameters, profile_target, profile_oneil, profile figure('Position', [10, 10, 900, 500]); hold on; - plot(axial_position, profile_sim_opt.axial_intensity, ... + plot(profile_sim_opt.axial_distance_bowl, profile_sim_opt.axial_intensity, ... 'LineWidth', 2, 'Color', [0 0 0], 'DisplayName', 'Optimized (Simulated)'); - plot(axial_position, profile_target.axial_intensity, ... + 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 @@ -102,12 +99,12 @@ function plot_opt_sim_results(parameters, profile_target, profile_oneil, profile 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', ... - axial_position(max_intensity_index)); + 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', ... - axial_position(max_intensity_index)-parameters.transducer.focal_distance_offset); + profile_sim_opt.axial_distance_bowl(max_intensity_index)-parameters.transducer.focal_distance_offset); end \ No newline at end of file From e388e59f366be2141be4815a535aa27b2aa6abbc Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 11:08:59 +0100 Subject: [PATCH 078/144] [calibration] encourage use of project-specific config folder Provide option to specify a config folder in example, if not provided this will default back to the PRESTUS config folder, but it is not recommended to edit files there. --- examples/calibration_standalone.m | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index 21d301d5..dd0596cc 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -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) From 6836dac78f820f7b95c077ec7c69e0231e803c53 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 11:24:43 +0100 Subject: [PATCH 079/144] [FD] throw warning not error if focal distance is missing (e.g., to allow only segmentation run) --- functions/transducer/focal_distance_calculation.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/transducer/focal_distance_calculation.m b/functions/transducer/focal_distance_calculation.m index 352598f1..e19c38ad 100644 --- a/functions/transducer/focal_distance_calculation.m +++ b/functions/transducer/focal_distance_calculation.m @@ -87,7 +87,7 @@ 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) - error('Transducer %d: trans_pos or focus_pos missing; cannot compute expected focal distance.', ti); + warning('Transducer %d: trans_pos or focus_pos missing; cannot compute expected focal distance.', ti); end % calculate grid distance between transducer bowl and focus focal_distance = norm(tr.focus_pos - tr.trans_pos); From 2ef1b7e3d03944169148b644c0962acce039b519 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 11:34:09 +0100 Subject: [PATCH 080/144] [calibration] fix distance encoding for opt oneil --- functions/calibration/recompute_oneil_solution.m | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/functions/calibration/recompute_oneil_solution.m b/functions/calibration/recompute_oneil_solution.m index c37c74e8..ad37572f 100644 --- a/functions/calibration/recompute_oneil_solution.m +++ b/functions/calibration/recompute_oneil_solution.m @@ -65,17 +65,18 @@ % 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 <= parameters.calibration.skip_front_peak_mm; - axial_position(i_remove) = []; + 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(i_axial_oneil_opt_summary == max(i_axial_oneil_opt_summary))) + 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, i_axial_oneil_opt_summary)) + 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 From 765821f2c9cc061acd5f43bf850a4ca8feaa44ff Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 12:10:17 +0100 Subject: [PATCH 081/144] [segmentation] add option to call segementation within matlab --- functions/head/segmentation_run.m | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/functions/head/segmentation_run.m b/functions/head/segmentation_run.m index 36d06134..4d7201d0 100644 --- a/functions/head/segmentation_run.m +++ b/functions/head/segmentation_run.m @@ -114,6 +114,34 @@ 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.hpc_submit_medium, 'matlab') + fprintf('Running segmentation locally:\n%s\n', segment_call); + + if ~isfield(parameters, 'simnibs_bin_path') || isempty(parameters.simnibs_bin_path) + error('simnibs_bin_path required for local execution'); + end + + % Use FULL PATH to charm/headreco (don't rely on PATH) + full_segment_call = sprintf('%s/%s', parameters.simnibs_bin_path, segment_call); + fprintf('Full command: %s\n', full_segment_call); + + orig_dir = pwd; + try + cd(parameters.seg_path); + [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.\n', segment_call) error('Submission medium %s is not available for automatic segmentation.', parameters.hpc_submit_medium); From f6dfbfa548c698166979d26ab0ce77f04f76f378 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 12:34:37 +0100 Subject: [PATCH 082/144] [calibration] example: force create output folder --- examples/calibration_standalone.m | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/calibration_standalone.m b/examples/calibration_standalone.m index dd0596cc..847049b6 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -52,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 From 8019263a3ebdec8f8557855bc3f673fa8d2cee76 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 12:35:22 +0100 Subject: [PATCH 083/144] [cpp] prevent string error for variable passing --- functions/acoustic/acoustic_simulation.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/functions/acoustic/acoustic_simulation.m b/functions/acoustic/acoustic_simulation.m index b0a085f0..143f1153 100644 --- a/functions/acoustic/acoustic_simulation.m +++ b/functions/acoustic/acoustic_simulation.m @@ -42,12 +42,12 @@ sensor = cast_struct(sensor, parameters.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.output_dir, ... sprintf('sub-%03d_%s_input%s.h5', parameters.subject_id, ... - parameters.simulation_medium, parameters.results_filename_affix)); + 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; + input_args.DataPath = char(parameters.output_dir); input_args.DeleteData = true; if parameters.n_sim_dims == 3 @@ -69,12 +69,12 @@ sensor = cast_struct(sensor, parameters.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.output_dir, ... sprintf('sub-%03d_%s_input%s.h5', parameters.subject_id, ... - parameters.simulation_medium, parameters.results_filename_affix)); + 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; + input_args.DataPath = char(parameters.output_dir); input_args.DeleteData = true; if parameters.n_sim_dims == 3 From 60252e231a330b5ca5fb486142b5174d1569e650 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 14:50:02 +0100 Subject: [PATCH 084/144] remove outdated examples --- documentation/PRESTUS_intro_tutorial.md | 496 ----------------------- documentation/img/figure_0.png | Bin 42586 -> 0 bytes documentation/img/figure_1.png | Bin 97344 -> 0 bytes documentation/img/figure_2.png | Bin 45929 -> 0 bytes documentation/img/figure_3.png | Bin 37307 -> 0 bytes documentation/img/figure_4.png | Bin 45160 -> 0 bytes documentation/img/figure_5.png | Bin 49597 -> 0 bytes documentation/img/figure_6.png | Bin 68114 -> 0 bytes documentation/img/figure_7.png | Bin 63377 -> 0 bytes examples/Imasonic_test_ISPPA_50mm.csv | 280 ------------- examples/Imasonic_test_ISPPA_65mm.csv | 280 ------------- examples/Imasonic_test_pressure_50mm.csv | 282 ------------- examples/Imasonic_test_pressure_65mm.csv | 282 ------------- examples/README.md | 15 +- examples/acoustic_profile_tutorial.csv | 61 --- examples/four_channel_example_49mm.csv | 61 --- examples/four_channel_example_60mm.csv | 61 --- 17 files changed, 11 insertions(+), 1807 deletions(-) delete mode 100644 documentation/PRESTUS_intro_tutorial.md delete mode 100644 documentation/img/figure_0.png delete mode 100644 documentation/img/figure_1.png delete mode 100644 documentation/img/figure_2.png delete mode 100644 documentation/img/figure_3.png delete mode 100644 documentation/img/figure_4.png delete mode 100644 documentation/img/figure_5.png delete mode 100644 documentation/img/figure_6.png delete mode 100644 documentation/img/figure_7.png delete mode 100755 examples/Imasonic_test_ISPPA_50mm.csv delete mode 100755 examples/Imasonic_test_ISPPA_65mm.csv delete mode 100755 examples/Imasonic_test_pressure_50mm.csv delete mode 100755 examples/Imasonic_test_pressure_65mm.csv delete mode 100755 examples/acoustic_profile_tutorial.csv delete mode 100755 examples/four_channel_example_49mm.csv delete mode 100755 examples/four_channel_example_60mm.csv diff --git a/documentation/PRESTUS_intro_tutorial.md b/documentation/PRESTUS_intro_tutorial.md deleted file mode 100644 index 2c786911..00000000 --- a/documentation/PRESTUS_intro_tutorial.md +++ /dev/null @@ -1,496 +0,0 @@ -# Table of contents -- [Simulations in free water](#simulations-in-free-water) -- [Optimize for a given distance and pressure](#optimize-for-a-given-distance-and-pressure) -- [Simulate again in water to check the optimization results](#simulate-again-in-water-to-check-the-optimization-results) -- [Simulations using skull and brain](#simulations-using-skull-and-brain) - -# A short introduction to PRESTUS - -PRESTUS is a package for acoustic and heating simulations built to simplify ultrasound neuromodulation studies, a wrapper for SimNIBS and k-Wave. This short demo shows how to use the package with the example subject data from SimNIBS. - -The example dataset that is referred to are the T1 and T2 images of 'Ernie' (m2m_Ernie > T1.nii.gz \& T2_reg.nii.gz) which can be found on the SimNIBS website ([https://simnibs.github.io/simnibs/build/html/dataset.html](https://simnibs.github.io/simnibs/build/html/dataset.html)). This tutorial can access them by specifying it's location, leaving them zipped and renaming it to sub-001_T1 and T2 respectively. - -Let's start with setting the environment. You'll need to change the path here to the directory you have PRESTUS in, and **also don't forget to change the paths in the config file (****`tutorial_config.yaml`****)**. - -```matlab:Code -clear -cd /home/neuromod/marcorn/Documents/PRESTUS % change path to demo data here - -submit_medium = 'slurm'; % run scripts via 'matlab' (debugging) or via a job using 'slurm' (recommended) or 'qsub' - -% add paths -addpath('functions') -addpath(genpath('toolboxes')) - -if strcmp(submit_medium, 'qsub') - addpath('/home/common/matlab/fieldtrip/qsub') % uncomment if you are using Donders HPC -end - -use_cpu = false; -if strcmp(submit_medium, 'matlab') - use_cpu = true; -end - -if gpuDeviceCount==0 && ~exist('/home/common/matlab/fieldtrip/qsub','dir') - warning('Many of the examples in this tutorial assume that you have a GPU available for computations or that you''re using the Donders HPC cluster. It looks like this is not the case. You can still run the tutorial but you''ll be automatically switched to using CPU which result in slow simulations.') - use_cpu = true; -end -``` - -# Simulations in free water - -Now, we start with the actual simulations. The first thing with the simulations is to ensure that you have the right settings. For the real US transducers, the settings are calibrated by the manufacturer. The company we buy the transducers from calibrates them to have a given maximum intensity and a given location of the acoustic peak (specifically, the center of its half-maximum range). - -Below, we read in two columns from the calibration sheet provided by the manufacturer. Specifically, we take the distance from the transducer axis and the intensity measured at each of these points. When using transducers with multiple elements, the depth of the acoustic peak (and thus the focus) can be steered by changing the set of phases for the individual transducer elements with respect to one-another. In this example, the intensity is taken from the column that set the phase angles to achieve a focus of 53mm in free water. - -```matlab:Code -real_profile = readmatrix('examples/acoustic_profile_tutorial.csv'); -desired_intensity = 30; - -dist_to_exit_plane = round(63.2-52.38); % from the transducer specifications - -figure('Position', [10 10 900 500]); -real_profile(:,1) = dist_to_exit_plane + real_profile(:,1); -plot(real_profile(:,1),real_profile(:,2)) -``` - -```text:Output -Warning: MATLAB has disabled some advanced graphics rendering features by switching to software OpenGL. For more information, click here. -``` - -```matlab:Code - -halfMax = (min(real_profile(:,2)) + max(real_profile(:,2))) / 2; -% Find where the data first drops below half the max. -index1 = find(real_profile(:,2) >= halfMax, 1, 'first'); -% Find where the data last rises above half the max. -index2 = find(real_profile(:,2) >= halfMax, 1, 'last'); - -flhm = real_profile(index2,1) - real_profile(index1,1); -flhm_center_x = (real_profile(index2,1) - real_profile(index1,1))/2+real_profile(index1,1); -flhm_center_intensity = real_profile(real_profile(:,1)==flhm_center_x, 2); -xlabel('Axial Position [mm]'); -ylabel('Intensity [W/cm^2]'); -yline(desired_intensity, '--'); -yline(halfMax, '--'); -xline(real_profile(index1,1), '--'); -xline(real_profile(index2,1), '--'); -xline(flhm_center_x,'r--'); -text(flhm_center_x+0.5, flhm_center_intensity+3, sprintf('FLHM center intensity %.2f [W/cm^2] at %i mm',flhm_center_intensity,flhm_center_x), "Color",'r'); -expected_focus = 60; -intensity_at_expected_focus = mean(real_profile(real_profile(:,1)>=59&real_profile(:,1)<=61,2)); -xline(expected_focus,'b--'); -text(expected_focus+0.5, intensity_at_expected_focus+4, sprintf('Expected focus intensity %.2f [W/cm^2] at %i mm',intensity_at_expected_focus,expected_focus),"Color",'b'); -[max_intensity, max_x] = max(real_profile(:,2)); -xline(real_profile(max_x,1),'--','Color','#7E2F8E'); -text(real_profile(max_x,1)+0.5, max_intensity+4, sprintf('Max intensity %.2f [W/cm^2] at %i mm',max_intensity,real_profile(max_x,1)),"Color",'#7E2F8E'); -``` - -![image](img/figure_0.png) - - -These settings are transformed into a set of phases for the individual transducer elements. Unfortunately, each transducer is unique, so the manufacturer's settings are not necessarily the ones you want to use in your simulations. The approach used here is to have an acoustic profile (i.e., the intensity along the beam axis of the transducer) so that the maximum point is at the expected distance and has the expected pressure. To have that, we start with the simulations in the water medium to find the phase-angles for the transducer elements needed for the simulation. - -The first step is to load the parameters. PRESTUS aims to separate the analysis and the configuration as much as possible, so most settings are kept in YAML format (https://en.wikipedia.org/wiki/YAML), a very simple markup language. The default settings are loaded from the `default_config.yaml` (all configs are kept in the `configs` folder), on top of which you need to load your own study-specific settings. For this tutorial, the settings are kept in `tutorial_config.yaml`. You also need to set a subject number, and for this tutorial there is only one subject. And that's it! You can then start the simulations by running the `single_subject_pipeline()` function.` ` - -```matlab:Code -parameters = load_parameters('tutorial_config.yaml'); % load the configuration file - -parameters.simulation_medium = 'water'; % indicate that we only want the simulation in the water medium for now -if use_cpu - parameters.code_type = 'matlab_cpu'; -end - -subject_id = 1; % subject id doesn't matter here as we use the brain of Ernie from SimNIBS example dataset and the paths to T1/T2 files are hardcoded in the tutorial config; for the real analysis one usually uses templates based on subject ID, see example in the default config. -% Start the simulations - uncomment the next line if you haven't run them yet - -if strcmp(submit_medium, 'qsub') - % If you are using the Donders HPC cluster, you can do the simulations in - % a non-interactive session with a qsub. To do so, set the interactive flag - % to zero, set overwrite_files to 'always' (if you already have the results and want to recompute them), and run single_subject_pipeline_with_qsub. - - parameters.interactive = 0; - parameters.overwrite_files = 'always'; - - single_subject_pipeline_with_qsub(subject_id, parameters); -elseif strcmp(submit_medium, 'slurm') - parameters.interactive = 0; - parameters.overwrite_files = 'always'; - - single_subject_pipeline_with_slurm(subject_id, parameters); -else - single_subject_pipeline(subject_id, parameters); -end -``` - -```text:Output -Submitted the job to the cluster with a command -cd /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs; sbatch /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs/tp29b25b1c_d955_4269_a33a_dfd1b0f9c0d8.sh -See logs in /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs in case there are errors. -Job name: PRESTUS_sub-001; job ID: 46914976 -Job submitted successfully -Continuing with the MATLAB script... -``` - -```matlab:Code - -``` - -After the simulations have finished, you can load the results. The results are saved under `sim_outputs` subdirectory of the data path that is defined in the config file along with some plots and summary statistics. - -The data on pressure is saved in the `sensor_data `structure. First, we will plot a 2d pressure map by slicing the 3d pressure matrix at the focal plane. - -```matlab:Code -% load results -outputs_folder = sprintf('%s/sim_outputs/sub-%03d', parameters.data_path, subject_id); - -load(sprintf('%s/sub-%03d_water_results%s.mat', outputs_folder, subject_id, parameters.results_filename_affix),'sensor_data','parameters'); -``` - -```text:Output -Warning: Unable to load gpuArray data onto a GPU. Computations cannot be performed on this limited gpuArray. Use GATHER to retrieve the contents. -Warning: Unable to load gpuArray data onto a GPU. Computations cannot be performed on this limited gpuArray. Use GATHER to retrieve the contents. -``` - -```matlab:Code - -% get maximum pressure -p_max = gather(sensor_data.p_max_all); % transform from GPU array to normal array - -% plot 2d intensity map -imagesc((1:size(p_max,1))*parameters.grid_step_mm, ... - (1:size(p_max,3))*parameters.grid_step_mm , ... - squeeze(p_max(:,parameters.transducer.trans_pos(2),:))') -axis image; -colormap(getColorMap); -xlabel('Lateral Position [mm]'); -ylabel('Axial Position [mm]'); -axis image; -cb = colorbar; -title('Pressure for the focal plane') -``` - -![image](img/figure_1.png) - -Then, we can compare the simulated pressure along the focal axis and the pressure estimated with an analytic solution based on the equations provided by O'Neil (O'Neil, H. Theory of focusing radiators. J. Acoust. Soc. Am., 21(5), 516-526, 1949) and implemented in k-wave `focusedAnnulusONeil()` function. - -```matlab:Code -% simulated pressure along the focal axis -pred_axial_pressure = squeeze(p_max(parameters.transducer.trans_pos(1),parameters.transducer.trans_pos(2),:)); % get the values at the focal axis - -% compute O'Neil solution and plot it along with comparisons -% define transducer parameters - -velocity = parameters.transducer.source_amp(1)/(parameters.medium.water.density*parameters.medium.water.sound_speed); % [m/s] - -% define position vectors -axial_position = (1:parameters.default_grid_dims(3))*0.5; % [mm] - -% evaluate pressure analytically -% focusedAnnulusONeil provides an analytic solution for the pressure at the -% focal (beam) axis -[profile_oneil.axial_intensity] = focusedAnnulusONeil(parameters.transducer.curv_radius_mm/1e3, ... - [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm]/1e3, repmat(velocity,1,parameters.transducer.n_elements), ... - parameters.transducer.source_phase_rad, parameters.transducer.source_freq_hz, parameters.medium.water.sound_speed, ... - parameters.medium.water.density, (axial_position-0.5)*1e-3); - -% plot focal axis pressure -figure('Position', [10 10 900 500]); - -plot(axial_position, profile_oneil.axial_intensity .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -xlabel('Axial Position [mm]'); -ylabel('Intensity [W/cm^2]'); -hold on -plot(axial_position-(parameters.transducer.trans_pos(3)-1)*0.5, pred_axial_pressure.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4,'--'); -plot(real_profile(:,1),real_profile(:,2)) -hold off -xline(parameters.expected_focal_distance_bowl, '--'); -legend('Analytic solution','Simulated results','Real profile') -title('Pressure along the beam axis') -``` - -![image](img/figure_2.png) - -```matlab:Code -% what is distance to the maximum pressure? -fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n',axial_position(profile_oneil.axial_intensity==max(profile_oneil.axial_intensity))) -``` - -```text:Output -Estimated distance to the point of maximum pressure: 66.50 mm -``` - -We want the simulated results to match the real profile as closely as possible, but as you can see that the two lines do not match exactly. There are two ways to deal with it, either you can use a newer kwaveArray class from k-wave (by setting `use_kWaveArray` flag in the config to 1) or you can introduce a correction factor as we do here. The kwaveArray solution is in theory better, but in practice it requires more time and memory. So for this tutorial, we will use a correction factor. - -```matlab:Code -% compute the approximate adjustment from simulated (on a grid) to analytic solution -simulated_oneil_scaling = max(pred_axial_pressure(:))/max(profile_oneil.axial_intensity(:)); -``` - -# Optimize for a given distance and pressure - -So how to find the settings for the simulations that match the desired pressure and distance? It is easy to do, given that there is an analytic solution. For our subject, we assume that we know where the transducer is positioned and where we want to have the maximum pressure, so we know the distance at which the pressure should be maximal. We need to find the set of phases for transducer elements that would give the maximum pressure at the distance given in the manufacturers calibration sheet. We do so by searching through the parameter space (that is, varying the phases) as to minimize the error in distance to maximum pressure point. - -```matlab:Code -gs = GlobalSearch; -%opt_velocity = desired_pressure/max_pressure*velocity; - -%optimize_phases = @(phases) phase_optimization_annulus(phases, parameters, velocity, axial_position, parameters.expected_focal_distance_bowl); -optimize_phases = @(phases_and_velocity) phase_optimization_annulus_full_curve(phases_and_velocity(1:(parameters.transducer.n_elements - 1)), parameters, phases_and_velocity(parameters.transducer.n_elements),... - real_profile(:,1), real_profile(:,2)); - -rng(195,'twister') % setting seed for consistency -problem = createOptimProblem('fmincon','x0', [randi(360, [1 parameters.transducer.n_elements - 1])/180*pi velocity],... - 'objective',optimize_phases,'lb',zeros(1,parameters.transducer.n_elements),'ub',[2*pi*ones(1,parameters.transducer.n_elements - 1) 0.2], 'options', optimoptions('fmincon','OptimalityTolerance', 1e-8)); - -[opt_phases_and_velocity, min_err] = run(gs,problem); -``` - -```text:Output -GlobalSearch stopped because it analyzed all the trial points. - -The local solver ran once and it converged with a positive local solver exitflag. -``` - -```matlab:Code -% plot optimization results -phase_optimization_annulus_full_curve(opt_phases_and_velocity(1:(parameters.transducer.n_elements - 1)), parameters, opt_phases_and_velocity(parameters.transducer.n_elements),... - real_profile(:,1), real_profile(:,2), 1); -``` - -```text:Output -Warning: Ignoring extra legend entries. -``` - -![image](img/figure_3.png) - - -```matlab:Code - -fprintf('Optimal phases: %s deg.; velocity: %.2f; optimization error: %.2f', mat2str(round(opt_phases_and_velocity(1:(parameters.transducer.n_elements - 1))/pi*180)), opt_phases_and_velocity(parameters.transducer.n_elements), min_err) -``` - -```text:Output -Optimal phases: [293 259 251 312 0 360 0 227 151] deg.; velocity: 0.08; optimization error: 0.04 -``` - -The left plot above shows the real and the fitted profiles along with the cost function used for fitting, while the right shows the error in fitting (the squared difference between the real and the fitted profile weighted by the cost function). - -The phase-angles given above should be copied into the config under the parameter "source_phase_deg". Keep in mind that different settings should be calculated and used for every transducer and every focus distance. - -We also need to know the pressure for the simulated transducer so that in the water, the intensity (ISPPA) would be 30 W/cm^2. To do so, we first compute the analytic solution with the new phases but the original pressure, and then use it to adjust the simulated pressure and recompute the analytic solution (the latter is just for plotting). - -```matlab:Code -opt_phases = opt_phases_and_velocity(1:(parameters.transducer.n_elements - 1)); -opt_velocity = opt_phases_and_velocity(parameters.transducer.n_elements); - -[p_axial_oneil_opt] = focusedAnnulusONeil(parameters.transducer.curv_radius_mm/1e3, ... - [parameters.transducer.Elements_ID_mm; parameters.transducer.Elements_OD_mm]/1e3, repmat(opt_velocity,1,parameters.transducer.n_elements), ... - [0 opt_phases], parameters.transducer.source_freq_hz, parameters.medium.water.sound_speed, ... - parameters.medium.water.density, (axial_position-0.5)*1e-3); - -figure('Position', [10 10 900 500]); -plot(axial_position, profile_oneil.axial_intensity.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -xlabel('Axial Position [mm]'); -ylabel('Intensity [W/cm^2]'); -hold on -plot(axial_position, p_axial_oneil_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -plot(real_profile(:,1),real_profile(:,2)) -hold off -xline(parameters.expected_focal_distance_bowl, '--'); -yline(30, '--'); -legend('Original simulation', sprintf('Optimized to match the real profile'),'Real profile') -title('Pressure along the beam axis') -``` - -![image](img/figure_4.png) - - -```matlab:Code - -fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n',axial_position(p_axial_oneil_opt==max(p_axial_oneil_opt))) -``` - -```text:Output -Estimated distance to the point of maximum pressure: 56.00 mm -``` - -```matlab:Code -fprintf('Estimated distance to the center of half-maximum range: %.2f mm\n', get_flhm_center_position(axial_position, p_axial_oneil_opt)) -``` - -```text:Output -Estimated distance to the center of half-maximum range: 68.25 mm -``` - -# Simulate again in water to check the optimization results - -Now we will redo the simulations with new parameters. We copy the configuration, update the settings with the optimized parameters, and rerun the simulations. - -```matlab:Code -opt_source_amp = round(opt_velocity/velocity*parameters.transducer.source_amp/simulated_oneil_scaling); - -opt_parameters = load_parameters('tutorial_config.yaml'); -opt_parameters.transducer.source_amp = opt_source_amp; -opt_parameters.transducer.source_phase_rad = [0 opt_phases]; -opt_parameters.transducer.source_phase_deg = [0 opt_phases]/pi*180; -opt_parameters.results_filename_affix = '_optimized'; -opt_parameters.simulation_medium = 'water'; - -if use_cpu - opt_parameters.code_type = 'matlab_cpu' -end - -if strcmp(submit_medium, 'qsub') - % If you are using the Donders HPC cluster, you can do the simulations in - % a non-interactive session with a qsub. To do so, set the interactive flag - % to zero, set overwrite_files to 'always' (if you already have the results and want to recompute them), and run single_subject_pipeline_with_qsub. - opt_parameters.interactive = 0; - opt_parameters.overwrite_files = 'always'; - single_subject_pipeline_with_qsub(subject_id, opt_parameters); -elseif strcmp(submit_medium, 'slurm') - opt_parameters.interactive = 0; - opt_parameters.overwrite_files = 'always'; - single_subject_pipeline_with_slurm(subject_id, opt_parameters); -else - single_subject_pipeline(subject_id, opt_parameters) -end -``` - -```text:Output -Submitted the job to the cluster with a command -cd /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs; sbatch /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs/tpad6599f1_223c_486a_ba8e_813abe67d692.sh -See logs in /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs in case there are errors. -Job name: PRESTUS_sub-001; job ID: 46914978 -Job submitted successfully -Continuing with the MATLAB script... -``` - -After the simulations are complete, we plot them alongside the analytic solution. - -```matlab:Code -opt_res = load(sprintf('%s/sub-%03d_water_results%s.mat', outputs_folder, subject_id, opt_parameters.results_filename_affix),'sensor_data','parameters'); -``` - -```text:Output -Warning: Unable to load gpuArray data onto a GPU. Computations cannot be performed on this limited gpuArray. Use GATHER to retrieve the contents. -Warning: Unable to load gpuArray data onto a GPU. Computations cannot be performed on this limited gpuArray. Use GATHER to retrieve the contents. -``` - -```matlab:Code - -% get maximum pressure -p_max = gather(opt_res.sensor_data.p_max_all); -pred_axial_pressure_opt = squeeze(p_max(opt_res.parameters.transducer.trans_pos(1), opt_res.parameters.transducer.trans_pos(2),:)); - -figure('Position', [10 10 900 500]); -hold on -plot(axial_position, profile_oneil.axial_intensity.^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -xlabel('Axial Position [mm]'); -ylabel('Intensity [W/cm^2]'); -plot(axial_position, p_axial_oneil_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); - -sim_res_axial_position = axial_position-(opt_res.parameters.transducer.trans_pos(3)-1)*0.5; % axial position for the simulated results, relative to transducer position -plot(sim_res_axial_position, ... - pred_axial_pressure_opt .^2/(2*parameters.medium.water.sound_speed*parameters.medium.water.density) .* 1e-4); -plot(real_profile(:,1),real_profile(:,2)) -hold off -xline(opt_res.parameters.expected_focal_distance_bowl, '--'); -yline(desired_intensity, '--'); -legend('Original simulation', sprintf('Optimized for %2.f mm distance, analytical', opt_res.parameters.expected_focal_distance_bowl), ... - sprintf('Optimized for %2.f mm distance, simulated', opt_res.parameters.expected_focal_distance_bowl),'Real profile','Location', 'best') -``` - -![image](img/figure_5.png) - - -```matlab:Code -fprintf('Estimated distance to the point of maximum pressure: %.2f mm\n',sim_res_axial_position(pred_axial_pressure_opt==max(pred_axial_pressure_opt))) -``` - -```text:Output -Estimated distance to the point of maximum pressure: 67.00 mm -``` - -As you can see, now the simulated acoustic profile has the desired distance to maximum and intensity, which means we can start with the simulations for the actual brain. - -# Simulations using skull and brain - -The first part repeats what we have done before: the parameters are loaded and the settings are updated. - -```matlab:Code -opt_parameters = load_parameters('tutorial_config.yaml'); - -opt_parameters.transducer.source_amp = opt_source_amp; -opt_parameters.transducer.source_phase_rad = [0 opt_phases]; -opt_parameters.transducer.source_phase_deg = [0 opt_phases]/pi*180; -opt_parameters.results_filename_affix = '_optimized'; -``` - -Now, at the next step we set the simulation medium to `"layered"` to indicate that we would use the actual brain image and we want to have different layers, such as skin, skull, and brain. These layers will be extracted from the T1 and T2 scans by SimNIBS during the initial processing, and then they will be used to create a heterogeneous simulations medium. - -```matlab:Code -opt_parameters.simulation_medium = 'layered'; % see default config for the list of mediums possible - -opt_parameters.run_heating_sims = 1; % this indicates that we want the heating simulations as well - -if use_cpu - opt_parameters.code_type = 'matlab_cpu' -end - -% Again, if you want to rerun the simulations, you can do it in an -% interactive mode (useful when running on your laptop) or -% non-interactively (useful at the cluster) with - -if strcmp(submit_medium, 'qsub') - opt_parameters.interactive = 0; - opt_parameters.overwrite_files = 'always'; - - single_subject_pipeline_with_qsub(subject_id, opt_parameters); -elseif strcmp(submit_medium, 'slurm') - opt_parameters.interactive = 0; - opt_parameters.overwrite_files = 'always'; - single_subject_pipeline_with_slurm(subject_id, opt_parameters); -else - % or with - single_subject_pipeline(subject_id, opt_parameters); -end -``` - -```text:Output -Submitted the job to the cluster with a command -cd /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs; sbatch /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs/tp78ca0690_6e35_47f1_a777_9a01e40991b3.sh -See logs in /home/neuromod/marcorn/Documents/simnibs4_examples/m2m_ernie/sim_outputs/sub-001/batch_job_logs in case there are errors. -Job name: PRESTUS_sub-001; job ID: 46915015 -Job submitted successfully -Continuing with the MATLAB script... -``` - -The simulations will take a long time. As a result, there will be some plots and matlab outputs in the `sim_outputs` folder. Let's look at some of them. - -This is the original T1 file superimposed with the predicted intensity. The transducer is shown in green and the intended stimulation location in red. Note that this is the slice in the plane along the focal axis. We are slightly undershooting the target here. - -```matlab:Code -imshow(imread(sprintf('%s/sub-%03d/sub-%03d_layered_isppa%s.png', opt_parameters.temp_output_dir , subject_id, subject_id, '_optimized'))) -``` - -![image](img/figure_6.png) - -A slight undershoot is also visible in free water. In this image, the yellow outline shows the transducer, the red point is the expected focus position and the blue one is the maximum focus position in the simulation. - -```matlab:Code -imshow(imread(sprintf('%s/sub-%03d/sub-%03d_water_isppa%s.png', opt_parameters.temp_output_dir , subject_id, subject_id, '_optimized'))) -``` - -![image](img/figure_7.png) - - -You can also look at the heating simulations results: - -```matlab:Code -imshow(imread(sprintf('%s/sub-%03d/sub-%03d_layered_heating_by_time%s.png',opt_parameters.temp_output_dir , subject_id, subject_id, '_optimized'))) -``` - -![image](img/figure_8.png) - -Here the lines show different voxels along the focal axis. Importantly, we see that with the current design, the temperature rises continuously for some voxels (check the video file in the `sim_outputs` folder as well), suggesting that one might need to introduce longer breaks to avoid excessive heating. diff --git a/documentation/img/figure_0.png b/documentation/img/figure_0.png deleted file mode 100644 index bd789fd88f4b8c49aaec19cd18bd664bcce98c9e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42586 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
', color)]; html = [html sprintf('
%s
', html_escape(info.label))]; - if isnan(value) - html = [html '
N/A
']; - html = [html '
No data
']; - else - html = [html sprintf('
%.3g
', value)]; - if isinf(info.limit) - html = [html sprintf('
%s (informational)
', display_unit)]; - else - html = [html sprintf('
Limit: %.3g %s
', info.limit, display_unit)]; + if isnan(value) + html = [html '
N/A
']; + html = [html '
No data
']; + else + html = [html sprintf('
%.3g
', value)]; + if isinf(info.limit) + html = [html sprintf('
%s (informational)
', display_unit)]; + else + 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)]; @@ -349,7 +351,9 @@ end html = [html '']; html = [html sprintf('', subject_id)]; - if is_layered + if is_layered && isfield(parameters, 'use_pseudoCT') && parameters.use_pseudoCT + html = [html sprintf('', html_escape(medium))]; + elseif is_layered html = [html sprintf('', html_escape(medium))]; else html = [html sprintf('', html_escape(medium))]; @@ -609,27 +613,27 @@ limits = get_safety_limits(); acoustic_cols = get_acoustic_columns(); html = [html table2html(csv_table, limits, acoustic_cols)]; - else - % Water: filter to water-relevant columns only, no safety color coding - water_cols = get_acoustic_columns_water(); - 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('max_pressure_Pa', sub_table.Properties.VariableNames) - pressure_vals = sub_table{:, 'max_pressure_Pa'}; - if ~all(isnan(pressure_vals)) - [scaled_vals, display_unit] = scale_pressure(pressure_vals); - sub_table{:, 'max_pressure_Pa'} = scaled_vals; - % Rename column header to reflect actual unit - sub_table.Properties.VariableNames{'max_pressure_Pa'} = ['max_pressure_' display_unit]; - end - end - html = [html table2html(sub_table, struct(), {})]; - else - html = [html '

No acoustic columns found in CSV.

']; - end - end + else + % Water: filter to water-relevant columns only, no safety color coding + water_cols = get_acoustic_columns_water(); + 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('max_pressure_Pa', sub_table.Properties.VariableNames) + pressure_vals = sub_table{:, 'max_pressure_Pa'}; + if ~all(isnan(pressure_vals)) + [scaled_vals, display_unit] = scale_pressure(pressure_vals); + sub_table{:, 'max_pressure_Pa'} = scaled_vals; + % Rename column header to reflect actual unit + sub_table.Properties.VariableNames{'max_pressure_Pa'} = ['max_pressure_' display_unit]; + end + end + html = [html table2html(sub_table, struct(), {})]; + else + html = [html '

No acoustic columns found in CSV.

']; + end + end else html = [html '

No acoustic CSV data found.

']; end @@ -843,6 +847,84 @@ html = [html '
' html_escape(log_text) '
']; end +function html = build_pseudoCT_section(parameters) + html = ''; + + % Extract from parameters + debug_dir = parameters.output_dir; + affix = ''; + if isfield(parameters, 'results_filename_affix') + affix = parameters.results_filename_affix; + end + debug_path = fullfile(debug_dir, 'debug'); + + % 1. MAPPING ALGORITHMS TABLE FIRST + html = [html, '
']; + fields = {'pct_mapping_density', 'pct_mapping_soundspeed', 'pct_mapping_attenuation'}; + labels = {'Density', 'Sound speed', 'Attenuation'}; + + for i = 1:3 + if isfield(parameters, fields{i}) + val = html_escape(char(parameters.(fields{i}))); + html = [html, sprintf('
%s mapping: %s
', labels{i}, val)]; + end + end + html = [html, '
']; + + % NEW LINE BEFORE IMAGES + html = [html, '
']; + + % 2. pCT histograms + pct_hist_path = fullfile(debug_path, sprintf('pCT_histograms%s.png', affix)); + if isfile(pct_hist_path) + rel_path = sprintf('debug/pCT_histograms%s.png', affix); + html = [html, sprintf('
')]; + html = [html, sprintf('pCT histograms', 1)]; + html = [html, sprintf('
pCT histograms [%s]
', affix)]; + + % NEW LINE AFTER histogram image + html = [html, '
']; + end + + % 3. KPLAN mapping - ONLY when pct_mapping_density == 'k-plan' + lightbox_idx = 2; + if isfield(parameters, 'pct_mapping_density') && strcmp(parameters.pct_mapping_density, 'k-plan') + kplan_path = fullfile(debug_path, 'pCT_hounsfield-density_kplan.png'); + if isfile(kplan_path) + html = [html, sprintf('
')]; + html = [html, sprintf('KPLAN mapping', lightbox_idx)]; + html = [html, sprintf('
Hounsfield → Density (KPLAN)
')]; + lightbox_idx = lightbox_idx + 1; + + % NEW LINE AFTER KPLAN image + html = [html, '
']; + end + end + + % 4. k-Wave mapping - ONLY when pct_mapping_density == 'k-wave' + if isfield(parameters, 'pct_mapping_density') && strcmp(parameters.pct_mapping_density, 'k-wave') + kwave_path = fullfile(debug_path, 'pCT_hounsfield-density_kwave.png'); + if isfile(kwave_path) + html = [html, sprintf('
')]; + html = [html, sprintf('k-Wave mapping', lightbox_idx)]; + html = [html, sprintf('
Hounsfield → Density (k-Wave)
')]; + + % NEW LINE AFTER k-Wave image + html = [html, '
']; + end + end + + % Fallback if no images + if isempty(strtrim(html)) && ~any(ismember(fields, fieldnames(parameters))) + html = '

No pCT configuration or images found

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

pCT: Hounsfield → acoustic properties via mappings

']; +end + %% ======================================================================== % IMAGE ENCODING % ======================================================================== diff --git a/functions/plot/plot_overlay.m b/functions/plot/plot_overlay.m index 004920be..1f848c35 100644 --- a/functions/plot/plot_overlay.m +++ b/functions/plot/plot_overlay.m @@ -186,10 +186,10 @@ axis image; axis off; - % draw transducer - if ~isempty(trans_pos) + % draw transducer (if grid was properly set up) + if ~isempty(trans_pos) && parameters.run_grid_setup == 1 options.grid_step = parameters.grid_step_mm; - plot_transducer_overlay(trans_pos, focus_pos, max_data_pos, parameters, options, 0.3, [0.2 0.6 1]) + plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, options, 0.3, [0.2 0.6 1]) end if options.overlay_segmented diff --git a/functions/plot/plot_transducer_overlay.m b/functions/plot/plot_transducer_overlay.m index 7b84d029..f57dc8c6 100644 --- a/functions/plot/plot_transducer_overlay.m +++ b/functions/plot/plot_transducer_overlay.m @@ -51,12 +51,16 @@ function plot_transducer_overlay(trans_pos, focus_pos, max_data_pos, parameters, 'Color', 'w', 'FontSize', 10, 'FontWeight', 'bold'); % GRID ONSET MARKERS - 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', ':'); + 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, ... diff --git a/functions/single_subject_pipeline.m b/functions/single_subject_pipeline.m index be0e5022..8c04ed82 100644 --- a/functions/single_subject_pipeline.m +++ b/functions/single_subject_pipeline.m @@ -329,9 +329,13 @@ fprintf('========================================\n\n'); log_timer('start','nifti', parameters.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, 'run_nifti_creation') || parameters.run_nifti_creation==1 + simulation_nifti(parameters, planimg, results_acoustic, ... + acoustic_isppa, acoustic_MI, acoustic_pressure, ... + medium_masks, results_heating, kwave_medium, highlighted_pos) + else + disp('No nifti creation requested...') + end log_timer('stop','nifti'); @@ -354,7 +358,7 @@ % 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); + generate_simulation_report(parameters); end % end logging From 2ff8725613ce3148a98e0974649a013dda880076 Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Wed, 11 Mar 2026 16:36:51 +0100 Subject: [PATCH 097/144] [fix] adjust the velocity to match the max_isppa --- .../calibration/calibration_transducer.m | 2 +- .../calibration/fit_velocity_to_intensity.m | 32 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index c92556d5..5b60a46e 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -141,7 +141,7 @@ parameters.calibration.fit_velocity_to_intensity [opt_velocity, ~, ~] = fit_velocity_to_intensity(... initial_params, profile_oneil, opt_phases, opt_velocity, ... - parameters.calibration.desired_intensity); + parameters.calibration.desired_intensity, simulated_analytical_scaling); end % Recalculate analytical solution with optimized phases and velocity diff --git a/functions/calibration/fit_velocity_to_intensity.m b/functions/calibration/fit_velocity_to_intensity.m index 608ef8b6..6507d239 100644 --- a/functions/calibration/fit_velocity_to_intensity.m +++ b/functions/calibration/fit_velocity_to_intensity.m @@ -1,17 +1,22 @@ function [corrected_velocity, I_peak_before, I_peak_after] = fit_velocity_to_intensity(... - parameters, profile_oneil, opt_phases, opt_velocity, desired_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 max_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]. +% 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]. @@ -54,10 +59,14 @@ return; end - % Correct velocity analytically: I ∝ v² => v_new = v_old * sqrt(I_desired / I_peak) - correction_factor = sqrt(desired_intensity / I_peak_before); + % 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 = desired_intensity; + I_peak_after = analytical_target; % Warn if corrected velocity exceeds upper bound if isfield(parameters.calibration, 'opt_upper_velocity') && ... @@ -68,7 +77,8 @@ fprintf('Velocity correction: %.4f -> %.4f m/s (factor: %.4f)\n', ... opt_velocity, corrected_velocity, correction_factor); - fprintf('Peak intensity: %.2f -> %.2f W/cm^2 (desired: %.2f)\n', ... - I_peak_before, I_peak_after, desired_intensity); + fprintf('Analytical target: %.2f W/cm^2 (desired_intensity * scaling = %.2f * %.4f)\n', ... + analytical_target, desired_intensity, simulated_analytical_scaling); + fprintf('Expected max_Isppa in simulation: %.2f W/cm^2\n', desired_intensity); end From 25b9293a16197e7cf1fcb73644c7292737eceaf4 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 17:18:36 +0100 Subject: [PATCH 098/144] [pct] add header to html --- functions/core/generate_simulation_report.m | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index 55c7fc97..c53374b7 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -1186,6 +1186,11 @@ html = [html 'Medium']; end + % pCT (pCT only) + if isfield(parameters, 'use_pseudoCT') && parameters.use_pseudoCT + html = [html 'pseudo-CT']; + end + html = [html 'Positioning']; html = [html 'Acoustic']; From ea492c76481ba31b36d079befc7690fc9f9f1ad5 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 17:19:14 +0100 Subject: [PATCH 099/144] [pipeline] allow to turn off individual modules, comment on run_acoustic --- configs/default_config.yaml | 6 ++++-- documentation/doc_parameters.md | 6 ++++-- documentation/doc_simulations-acoustic.md | 2 ++ functions/single_subject_pipeline.m | 21 +++++++++++++-------- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index f3dd471b..9d2049a3 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -46,13 +46,15 @@ pct_mapping_density: 'k-plan' # pCT mapping algorithm for density: k-plan/k- pct_mapping_soundspeed: 'k-plan' # pCT mapping algorithm for sound speed: k-plan/marsac/aubry pct_mapping_attenuation: 'k-plan' # pCT mapping algorithm for attenuation: k-plan/mueller -# Only run specific components of the pipeline? +# Run specific modules? run_grid_setup: 1 # Setup grid and run head processing? Mandatory for simulations. run_medium_setup: 1 # Map medium acoustic properties Mandatory for simulations. run_source_setup: 1 # Set up acoustic source? Mandatory for simulations. run_acoustic_sims: 1 # Run acoustic simulations? -run_heating_sims: 0 # Run heating simulations? Run once acoustic simulation provides sensible results. +run_acoustic_analysis: 1 # Run acoustic analysis? +run_heating_sims: 0 # Run thermal simulations? Default: off; run once acoustic simulation provides sensible results. +run_thermal_analysis: 1 # Run thermal analysis? run_nifti_creation: 1 # Run nifti creation? run_posthoc_water_sims: 1 # Run simulations in free water with identical setup? generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 258b2e41..f1e2bf2a 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -32,8 +32,10 @@ see [doc_preproc.md](doc_preproc.md) | `run_medium_setup` | Map medium acoustic properties? Mandatory for simulations. | (`1 = yes, 0 = no`) | | `run_source_setup` | Set up acoustic source? Mandatory for simulations. | (`1 = yes, 0 = no`) | | `run_acoustic_sims` | Run acoustic simulations? | (`1 = yes, 0 = no`) | -| `run_heating_sims` | Run heating simulations? | (`1 = yes, 0 = no`) | -| `run_nifti_creation` | Run Nifti creation? | (`1 = yes, 0 = no`) | +| `run_acoustic_analysis` | Run acoustic analysis? | (`1 = yes, 0 = no`) | +| `run_heating_sims` | Run thermal simulations? | (`1 = yes, 0 = no`) | +| `run_thermal_analysis` | Run thermal analysis? | (`1 = yes, 0 = no`) | +| `run_nifti_creation` | Run Nifti creation? | (`1 = yes, 0 = no`) | | `run_posthoc_water_sims` | Run water simulations following head simulations. | (`1 = yes, 0 = no`) | | `generate_report` | Generate a self-contained HTML simulation report at the end of the pipeline? | (`1 = yes, 0 = no`) | | `n_sim_dims` | Simulation type (2D / 3D). | `2` = 2D (`kspaceFirstOrder2D`), `3` = 3D(`kspaceFirstOrder3D`). If not specified, it is inferred from `default_grid_dims`. For `axisymmetric` setups (see below), specify 2D. | diff --git a/documentation/doc_simulations-acoustic.md b/documentation/doc_simulations-acoustic.md index 73914a30..f164a76c 100644 --- a/documentation/doc_simulations-acoustic.md +++ b/documentation/doc_simulations-acoustic.md @@ -6,6 +6,8 @@ PRESTUS by default supports 3D simulations using `kspaceFirstOrder3D`. 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. diff --git a/functions/single_subject_pipeline.m b/functions/single_subject_pipeline.m index 8c04ed82..2c0b3ea1 100644 --- a/functions/single_subject_pipeline.m +++ b/functions/single_subject_pipeline.m @@ -174,10 +174,10 @@ 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')) + if isfield(parameters, 'run_acoustic_sims') && 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')) [sensor_data, parameters, segmentation, medium_masks, kwave_medium, kgrid, source, source_labels] = ... acoustic_wrapper(... @@ -192,12 +192,15 @@ source_labels); parameters.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; else + disp('No acoustic simulation available or requested ... skipping analysis') parameters.acoustics_available = 0; + parameters.run_acoustic_analysis = 0; end log_timer('stop', 'acoustic'); @@ -210,11 +213,11 @@ fprintf('========================================\n\n'); log_timer('start','acoustic_analysis', parameters.output_dir); - if parameters.acoustics_available == 1 + if (~isfield(parameters, 'run_acoustic_analysis') || parameters.run_acoustic_analysis) [results_acoustic, acoustic_isppa, acoustic_MI, acoustic_pressure, highlighted_pos] = ... acoustic_analysis(parameters, kwave_medium, medium_masks, sensor_data, segmentation, source_labels); else - disp('No acoustic simulation results available. Skipping analysis...') + disp('No acoustic simulation results available (or requested). Skipping analysis...') results_acoustic = []; acoustic_isppa = []; acoustic_MI = []; @@ -295,6 +298,7 @@ else warning('Heating simulations requested, but no acoustic results available. Other misspecification is possible.') parameters.heating_available = 0; + parameters.run_thermal_analysis = 0; end else parameters.heating_available = 0; @@ -311,11 +315,12 @@ fprintf('========================================\n\n'); log_timer('start','thermal_analysis', parameters.output_dir); - if parameters.heating_available == 1 + if parameters.heating_available == 1 && ... + (~isfield(parameters, 'run_thermal_analysis') || parameters.run_thermal_analysis) thermal_analysis(parameters, results_heating, time_status_seq, ... medium_masks, highlighted_pos, segmentation); 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'); From 8aea92721b26882d5046a1333a6007d58239749f Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 17:25:33 +0100 Subject: [PATCH 100/144] [hotfix] --- functions/plot/plot_transducer_overlay.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/plot/plot_transducer_overlay.m b/functions/plot/plot_transducer_overlay.m index f57dc8c6..6b3a3c50 100644 --- a/functions/plot/plot_transducer_overlay.m +++ b/functions/plot/plot_transducer_overlay.m @@ -1,11 +1,11 @@ -function plot_transducer_overlay(trans_pos, focus_pos, max_data_pos, parameters, options, overlay_weight, overlay_color) +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_step_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 -% parameters - Struct with .transducer(1).{curv_radius_mm, dist_to_plane_mm, -% Elements_OD_mm, grid_step_mm, trans_pos, grid_dims} % 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 From 79f375a31a553da7675d5db0de1d6718692f86a1 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 20:31:43 +0100 Subject: [PATCH 101/144] [refactor] pipeline renamed, hpc submission refactored --- configs/default_config.yaml | 5 +- documentation/doc_advanced.md | 8 +- documentation/doc_functions.md | 2 - documentation/doc_getting-started.md | 4 +- documentation/doc_parameters.md | 2 +- .../calibration/calibration_transducer.m | 24 +- functions/core/path_log_setup.m | 2 +- functions/core/prestus_pipeline_start.m | 78 +++++++ functions/hpc/hpc_detect_system.m | 19 ++ functions/hpc/hpc_job_info.m | 51 +++++ functions/hpc/hpc_job_name.m | 27 +++ functions/hpc/hpc_matlab_pipeline.m | 26 +++ functions/hpc/hpc_setup_temp_files.m | 37 +++ functions/hpc/hpc_submit_job.m | 119 ++++++++++ functions/hpc/hpc_validate_parameters.m | 21 ++ functions/hpc/hpc_wait_for_completion.m | 56 +++++ .../hpc/single_subject_pipeline_with_qsub.m | 137 ----------- .../hpc/single_subject_pipeline_with_slurm.m | 214 ------------------ ..._subject_pipeline.m => prestus_pipeline.m} | 29 ++- simple_main.m | 25 +- 20 files changed, 470 insertions(+), 416 deletions(-) create mode 100644 functions/core/prestus_pipeline_start.m create mode 100644 functions/hpc/hpc_detect_system.m create mode 100644 functions/hpc/hpc_job_info.m create mode 100644 functions/hpc/hpc_job_name.m create mode 100644 functions/hpc/hpc_matlab_pipeline.m create mode 100644 functions/hpc/hpc_setup_temp_files.m create mode 100644 functions/hpc/hpc_submit_job.m create mode 100644 functions/hpc/hpc_validate_parameters.m create mode 100644 functions/hpc/hpc_wait_for_completion.m delete mode 100644 functions/hpc/single_subject_pipeline_with_qsub.m delete mode 100644 functions/hpc/single_subject_pipeline_with_slurm.m rename functions/{single_subject_pipeline.m => prestus_pipeline.m} (95%) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 9d2049a3..8c4fb52d 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -71,10 +71,13 @@ debug: 1 # Activate a debug mode in which more intermediate outputs are provided #------------ High-performance computing -hpc_submit_medium: 'matlab' # slurm (recommended) | qsub | matlab (default) +hpc_submit_medium: 'auto' # auto (recommended) | slurm | qsub | matlab (recommended for debugging) hpc_gpu: '' hpc_partition: '' hpc_reservation: '' +hpc_wait_for_job: false +hpc_timelimit: '04:00:00' +hpc_memorylimit: 20 # An optional path to LD_LIBRARY used during SimNIBS installation # If you experience an `undefined symbol` error in `create_mesh_surf.cpython-39-x86_64-linux-gnu.so`, set either of the next lines to your LD_LIBRARY location diff --git a/documentation/doc_advanced.md b/documentation/doc_advanced.md index 1721dd29..1177a234 100644 --- a/documentation/doc_advanced.md +++ b/documentation/doc_advanced.md @@ -17,14 +17,14 @@ Since each config is a structure, you can easily place multiple configs in one s Let's say you have config_1, config_2 and config_3 and you want to run them in sequence. If you want the heatmaps to not carry over, you would run your pipeline like this: -`single_subject_pipeline(subject_id, config_1)` -`single_subject_pipeline(subject_id, config_2)` -`single_subject_pipeline(subject_id, config_3)` +`prestus_pipeline_start(subject_id, config_1)` +`prestus_pipeline_start(subject_id, config_2)` +`prestus_pipeline_start(subject_id, config_3)` But now, you will also feed it the configs for each subsequent simulation: `sequential_configs.config_2 = config_2` `sequential_configs.config_3 = config_3` -`single_subject_pipeline(subject_id, config_1, 'sequential_configs', sequential_configs)` +`prestus_pipeline_start(subject_id, config_1, 'sequential_configs', sequential_configs)` Please note that you have to use the names `config_x` in the sequential_configs, and that you have to use integers. So names like `config_-5`, `config_0`, `config_1234` and `config_007`. diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index e6e62f8b..cc3cb45d 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -61,8 +61,6 @@ The following documents the functions provided in PRESTUS. | `tissuemask_binary` | HELPER | Extract binary tissue segmentation masks (for indexing) | | `upsample_to_grid` | HELPER | Upsamples a 3D image to a higher resolution grid. | | `zip_fields` | HELPER | Convert a structure's fields and values into a cell array. | -| `single_subject_pipeline_with_qsub` | HPC | HPC call of pipeline with QSUB. | -| `single_subject_pipeline_with_slurm` | HPC | HPC call of pipeline with SLURM. | | `transducer_positioning_with_qsub` | HPC | Submits transducer positioning jobs to Qsub cluster using batch scripts. | | `transducer_positioning_with_slurm` | HPC | Submits transducer positioning jobs to SLURM cluster using batch scripts. | | `fitPowerLawParamsMulti` | MEDIUM | Fit power law absorption parameters for highly absorbing media. | diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index faf3b38d..9485910d 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -66,8 +66,8 @@ To this end, `run_grid_setup`, `run_medium_setup`, `run_source_setup`, `run_acou See [pseudoCT](doc_pseudoCT.md). -### Run the single_subject_pipeline | [Optional] iterate across parameters +### Run the prestus_pipeline | [Optional] iterate across parameters -When the configuration is fully specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `single_subject_pipeline` can be submitted using high performance computing jobs (see [HPC documentation](doc_hpc.md)). +When the configuration is fully specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `prestus_pipeline` can be submitted using high performance computing jobs as a part of `prestus_pipeline_start` (see [HPC documentation](doc_hpc.md)). PRESTUS can be parallelized across-subject and within-subject across treatment setups, e.g., by iterating across different transducers, transducer placement and target coordinates, or amplitudes and/or focal depth settings (requiring either dynamic [recalibration](doc_calibration.md) using `transducer_calibration` or a precomputed lookup table) via custom MATLAB scripts. diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index f1e2bf2a..5baf2be6 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -2,7 +2,7 @@ The following documents the parameters used in PRESTUS. The specification philosophy is the following: `default_config.yaml` provides a list of all parameters with default settings and will be read in first by the function `load_parameters`. This default configuration file should not be changed in standard applications to ensure that necessary fields are provided. -To set up a specific application, an additional `config_.yaml` should be provided. This file should contain exclusively the fields where defaults should be overwritten (e.g., to provide specific transducer settings). Alternatively, parameters can be specified prior to calling the `single_subject_pipeline` in MATLAB. This allows dynamic iterations over parameters of interest. +To set up a specific application, an additional `config_.yaml` should be provided. This file should contain exclusively the fields where defaults should be overwritten (e.g., to provide specific transducer settings). Alternatively, parameters can be specified prior to calling the `prestus_pipeline` in MATLAB. This allows dynamic iterations over parameters of interest. ### I/O management diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index 9f94e305..6bca8363 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -97,16 +97,8 @@ sim_param.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.hpc_wait_for_completion = true; + prestus_pipeline_start(sim_id, sim_param, true); %% Load initial results @@ -164,16 +156,8 @@ 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 + sim_param.hpc_wait_for_completion = true; + prestus_pipeline_start(sim_id, sim_param, true); %% Load optimized simulation results opt_res = load(sprintf('%s/sub-%03d_water_results%s.mat', ... diff --git a/functions/core/path_log_setup.m b/functions/core/path_log_setup.m index 7c931b30..603a5180 100644 --- a/functions/core/path_log_setup.m +++ b/functions/core/path_log_setup.m @@ -111,4 +111,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.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..4b70ebf4 --- /dev/null +++ b/functions/core/prestus_pipeline_start.m @@ -0,0 +1,78 @@ +function prestus_pipeline_start(subject_id, parameters, options) +%% PRESTUS_PIPELINE_START Universal PRESTUS pipeline launcher +% +% prestus_pipeline_start(subject_id, parameters, options) +% +% Auto-detects platform and handles direct MATLAB, SLURM, or qsub execution. +% +% Inputs: +% subject_id - Subject number (double) +% parameters - Struct with sim_path, submit_medium, hpc_* settings +% options - Struct with sequential_configs (default: empty) + + arguments + subject_id double + parameters struct + options struct = struct() + end + + % Ensure helper functions are accessible + helpers_path = fileparts(mfilename('fullpath')); + if ~contains(path, helpers_path) + addpath(helpers_path); + end + + % ========== STEP 1: PLATFORM AUTO-DETECTION ========== + if ~isfield(parameters, 'submit_medium') || strcmp(parameters.submit_medium, 'auto') + submit_medium = hpc_detect_system(); + parameters.submit_medium = submit_medium; + fprintf('➤ auto-detected: %s\n', upper(submit_medium)); + else + submit_medium = parameters.submit_medium; + fprintf('➤ deploying: %s\n', upper(submit_medium)); + end + + % ========== DISPATCH EXECUTION ========== + switch parameters.submit_medium + case 'matlab' + fprintf('🖥️ Running in MATLAB\n\n'); + prestus_pipeline(subject_id, parameters, options); + + case {'slurm', 'qsub'} + % ========== HPC EXECUTION ========== + hpc_validate_parameters(parameters, submit_medium); + [log_dir, path_to_pipeline, temp_data_path, temp_m_path, temp_m_file] = ... + hpc_setup_temp_files(parameters, subject_id); + + % Create job files + save(temp_data_path, 'subject_id', 'parameters'); + hpc_matlab_pipeline(temp_m_path, temp_data_path, path_to_pipeline, options); + + % Job name + job_name = hpc_job_name(submit_medium, parameters, subject_id); + + % Submit job + job_id = hpc_submit_job(submit_medium, temp_m_file, parameters, subject_id, log_dir); + + % Display job info + job_info = hpc_job_info(submit_medium, job_id, job_name, subject_id, ... + 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, submit_medium); + fprintf('✅ Job %s completed\n\n', job_id_display); + else + fprintf('➡️ Continuing in MATLAB ...\n\n'); + end + + % Save job ID for chaining + parameters.job_id = job_id; + + otherwise + error('Unknown submit_medium: %s. Use ''matlab'', ''slurm'', ''qsub'', or ''auto''.', ... + parameters.submit_medium); + end +end \ No newline at end of file diff --git a/functions/hpc/hpc_detect_system.m b/functions/hpc/hpc_detect_system.m new file mode 100644 index 00000000..226b4244 --- /dev/null +++ b/functions/hpc/hpc_detect_system.m @@ -0,0 +1,19 @@ +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. +% +% See also HPC_SUBMIT_JOB, HPC_WAIT_FOR_COMPLETION. + +[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..2b350258 --- /dev/null +++ b/functions/hpc/hpc_job_info.m @@ -0,0 +1,51 @@ +function display_info = hpc_job_info(submit_medium, job_id, job_name, subject_id, ... + memory_gb, timelimit, log_dir, visualize) +%% HPC_JOB_INFO Generate formatted job display information +% +% Creates structured display info for job submission feedback. +% +% Inputs: +% submit_medium - 'slurm' or 'qsub' +% job_id - Raw job ID from submission +% job_name - Job name string +% subject_id - Subject number +% 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(submit_medium, '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.subject_id = subject_id; + 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: sub-%03d\n', display_info.subject_id); + 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..087bfc54 --- /dev/null +++ b/functions/hpc/hpc_job_name.m @@ -0,0 +1,27 @@ +function job_name = hpc_job_name(submit_medium, parameters, subject_id) +%% HPC_JOB_NAME Generate standardized HPC job name +% +% job_name = hpc_job_name(submit_medium, parameters, subject_id) +% +% Creates job name in format: PREFIX_sub-XXX where PREFIX is +% {slurm|qsub}_job_prefix from parameters (default: PRESTUS). +% +% Inputs: +% submit_medium - 'slurm' or 'qsub' +% parameters - Struct (may contain slurm_job_prefix/qsub_job_prefix) +% subject_id - Subject number (double) +% +% 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', subject_id); + prefix_field = sprintf('%s_job_prefix', submit_medium); + + if ~isfield(parameters, prefix_field) + parameters.(prefix_field) = 'PRESTUS'; + end + + job_name = [parameters.(prefix_field) '_' subj_id_string]; +end diff --git a/functions/hpc/hpc_matlab_pipeline.m b/functions/hpc/hpc_matlab_pipeline.m new file mode 100644 index 00000000..bf22fec9 --- /dev/null +++ b/functions/hpc/hpc_matlab_pipeline.m @@ -0,0 +1,26 @@ +function hpc_matlab_pipeline(temp_m_path, temp_data_path, path_to_pipeline, options) +%% HPC_MATLAB_PIPELINE Generate temporary MATLAB batch script +% +% Creates self-deleting MATLAB batch script that loads parameters and runs +% prestus_pipeline. Handles optional sequential_configs. +% +% Inputs: +% temp_m_path - Path for output .m script +% temp_data_path - Path to .mat parameter file +% path_to_pipeline - Directory containing prestus_pipeline.m +% options - Optional struct with sequential_configs field +% +% See also HPC_SETUP_TEMP_FILES, HPC_SUBMIT_JOB. + +fid = fopen(temp_m_path, 'w+'); +if ismember(fieldnames(options), 'sequential_configs') + sequential_configs = options.sequential_configs; + save(temp_data_path, 'sequential_configs', '-append'); + fprintf(fid, "load '%s'; cd '%s'; prestus_pipeline(subject_id, parameters, options); delete '%s'; delete '%s';", ... + temp_data_path, path_to_pipeline, temp_data_path, temp_m_path); +else + fprintf(fid, "load '%s'; cd '%s'; prestus_pipeline(subject_id, parameters); delete '%s'; delete '%s';", ... + temp_data_path, path_to_pipeline, temp_data_path, temp_m_path); +end +fclose(fid); +end \ No newline at end of file diff --git a/functions/hpc/hpc_setup_temp_files.m b/functions/hpc/hpc_setup_temp_files.m new file mode 100644 index 00000000..65f47af8 --- /dev/null +++ b/functions/hpc/hpc_setup_temp_files.m @@ -0,0 +1,37 @@ +function [log_dir, path_to_pipeline, temp_data_path, temp_m_path, temp_m_file] = ... + hpc_setup_temp_files(parameters, subject_id) +%% HPC_SETUP_TEMP_FILES Setup directories and generate temporary files +% +% Creates output directory, log directory, and timestamped temporary files +% for MATLAB data and script. +% +% Outputs: +% log_dir - Path to batch_job_logs directory +% path_to_pipeline - Directory containing prestus_pipeline.m +% 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_WRITE_MATLAB_SCRIPT, HPC_SUBMIT_JOB. + +% Setup output directory +if isfield(parameters, 'subject_subfolder') && parameters.subject_subfolder + output_dir = fullfile(parameters.sim_path, sprintf('sub-%03d', subject_id)); +else + output_dir = parameters.sim_path; +end +if ~isfolder(output_dir), mkdir(output_dir); end +log_dir = fullfile(output_dir, 'batch_job_logs'); +if ~isfolder(log_dir), mkdir(log_dir); end +[path_to_pipeline, ~, ~] = fileparts(which('prestus_pipeline')); + +% 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..1f68d336 --- /dev/null +++ b/functions/hpc/hpc_submit_job.m @@ -0,0 +1,119 @@ +function job_id = hpc_submit_job(hpc_type, temp_m_file, parameters, subject_id, 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. +% +% Inputs: +% hpc_type - 'slurm' or 'qsub' +% temp_m_file - MATLAB script basename +% parameters - Job parameters (hpc_partition, hpc_gpu, etc.) +% subject_id - Subject number for naming +% parameters.hpc_timelimit - Walltime in format 'HH:MM:SS' or minutes +% parameters.hpc_memorylimit - Memory in GB +% 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', 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, subject_id, 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, subject_id, 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(hpc_type, parameters, subject_id), sprintf('%d', job_id)); + +% ========== LOCAL FUNCTIONS ========== +function write_slurm_script(temp_slurm_path, parameters, subject_id, temp_m_file, log_dir) + subj_id_string = sprintf('sub-%03d', subject_id); + job_name = hpc_job_name('slurm', parameters, subject_id); + + 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, 'hpc_partition') || ... + (isfield(parameters, 'code_type') && any(strcmp(parameters.code_type, {'matlab_gpu', 'cpp_gpu'}))); + + if isfield(parameters, 'hpc_partition') && ~isempty(parameters.hpc_partition) + fprintf(fid, '#SBATCH --partition=%s\n', parameters.hpc_partition); + elseif needs_gpu + fprintf(fid, '#SBATCH --partition=gpu\n'); + fprintf(fid, '#SBATCH --gres=gpu:1\n'); + end + + if isfield(parameters, 'hpc_gpu') && ~isempty(parameters.hpc_gpu) + fprintf(fid, '#SBATCH --gres=%s\n', parameters.hpc_gpu); + end + + if isfield(parameters, 'hpc_reservation') && ~isempty(parameters.hpc_reservation) + fprintf(fid, '#SBATCH --reservation=%s\n', 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, subject_id, temp_m_file, log_dir) + subj_id_string = sprintf('sub-%03d', subject_id); + job_name = hpc_job_name('qsub', parameters, subject_id); + + 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..6b888ed6 --- /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.interactive + warning('Interactive mode disabled for %s jobs.', upper(hpc_type)); + parameters.interactive = false; +end +assert(matches(parameters.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..60a287c1 --- /dev/null +++ b/functions/hpc/hpc_wait_for_completion.m @@ -0,0 +1,56 @@ +function hpc_wait_for_completion(job_id, hpc_type) +%% HPC_WAIT_FOR_COMPLETION Monitor HPC job until completion +% +% hpc_wait_for_completion(job_id, hpc_type) +% +% Polls job status every 20s until COMPLETED/FAILED (SLURM) or C/no-longer-listed (qsub). +% +% Inputs: +% job_id - Job ID (numeric string for SLURM, string for qsub) +% hpc_type - 'slurm' or 'qsub' +% +% See also HPC_SUBMIT_JOB. + +disp('User has chosen to wait until job is finished...'); +job_completed = false; + +while ~job_completed + switch hpc_type + case 'slurm' + [status, out] = system(sprintf('sacct -j %s -o State --noheader | tail -n 1', job_id)); + if status == 0 + job_state = strtrim(out); + fprintf('SLURM Job status: %s\n', job_state); + if any(strcmp(job_state, {'COMPLETED', 'FAILED'})) + job_completed = true; + else + pause(20); + end + else + pause(20); + end + + case 'qsub' + [status, out] = system(sprintf('qstat -f %s | grep job_state', job_id)); + if status == 0 + parts = strsplit(out, '='); + if numel(parts) == 2 + job_state = strtrim(parts{2}); + fprintf('qsub Job status: %s\n', job_state); + if strcmp(job_state, 'C') + disp('Job completed successfully.'); + job_completed = true; + else + pause(20); + end + end + else + [status_check, ~] = system(sprintf('qstat %s', job_id)); + if status_check ~= 0 + disp('Job no longer listed. Assuming completed.'); + job_completed = true; + end + end + end +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 00634e4f..00000000 --- a/functions/hpc/single_subject_pipeline_with_slurm.m +++ /dev/null @@ -1,214 +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 - timelimit string = "04:00:00" - memorylimit (1,1) double = 40 - options.sequential_configs struct = struct() - end - - % Setup and validation - parameters.submit_medium = 'slurm'; - validate_slurm_parameters(parameters); - [log_dir, path_to_pipeline] = setup_output_directory(parameters, subject_id); - - % Generate consistent temp filenames - [temp_data_path, temp_m_path, temp_slurm_path, temp_m_file] = generate_temp_files(log_dir); - - % Create data file and MATLAB script - save(temp_data_path, 'subject_id', 'parameters'); - write_matlab_script(temp_m_path, temp_data_path, path_to_pipeline, options.sequential_configs); - - % Create and submit SLURM job - write_slurm_script(temp_slurm_path, parameters, subject_id, timelimit, memorylimit, temp_m_file, log_dir); - job_id = submit_slurm_job(temp_slurm_path, log_dir, parameters, subject_id); - - % Wait for completion if requested - if wait_for_job - wait_for_job_completion(job_id); - end - - disp('Continuing with the MATLAB script...'); -end - -%% Helper functions - -function validate_slurm_parameters(parameters) - if parameters.interactive - warning('Interactive mode disabled for SLURM jobs.'); - parameters.interactive = false; - end - assert(matches(parameters.overwrite_files, ["always", "never"]), ... - 'overwrite_files must be "always" or "never" for SLURM jobs.'); -end - -function [log_dir, path_to_pipeline] = setup_output_directory(parameters, subject_id) - if isfield(parameters, 'subject_subfolder') && parameters.subject_subfolder - output_dir = fullfile(parameters.sim_path, sprintf('sub-%03d', subject_id)); - else - output_dir = parameters.sim_path; - end - - if ~isfolder(output_dir), mkdir(output_dir); end - log_dir = fullfile(output_dir, 'batch_job_logs'); - if ~isfolder(log_dir), mkdir(log_dir); end - - [path_to_pipeline, ~, ~] = fileparts(which('single_subject_pipeline')); -end - -function [temp_data_path, temp_m_path, temp_slurm_path, temp_m_file] = generate_temp_files(log_dir) - % get timestamp - timestamp = datestr(now, 'yyyymmdd_HHMMSS'); - temp_base = tempname(log_dir); - - % get temporary id (to avoid competing parallel calls) - [~, temp_base_name] = fileparts(temp_base); - temp_base_name = temp_base_name(end-7:end); % Last 8 chars only - - % .mat - temp_data_path = fullfile(log_dir, sprintf('temp_data_%s_%s.mat', timestamp, temp_base_name)); - % .m - temp_m_file = sprintf('temp_matlab_%s_%s', timestamp, temp_base_name); - temp_m_path = fullfile(log_dir, [temp_m_file, '.m']); - % .sh - temp_slurm_file = sprintf('temp_slurm_%s_%s.sh', timestamp, temp_base_name); - temp_slurm_path = fullfile(log_dir, temp_slurm_file); -end - -function write_matlab_script(temp_m_path, temp_data_path, path_to_pipeline, sequential_configs) - fid = fopen(temp_m_path, 'w+'); - - if ~isempty(fieldnames(sequential_configs)) - save(temp_data_path, 'sequential_configs', '-append'); - 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_path); - else - 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_path); - end - fclose(fid); -end - -function write_slurm_script(temp_slurm_path, parameters, subject_id, timelimit, memorylimit, temp_m_file, log_dir) - subj_id_string = sprintf('sub-%03d', subject_id); - job_name = get_job_name(parameters, subj_id_string); - - fid = fopen(temp_slurm_path, 'w+'); - fprintf_slurm_header(fid, job_name, parameters, subj_id_string, log_dir, timelimit, memorylimit); - if get_gpu_request(parameters) - 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, parameters, subject_id) - sbatch_call = sprintf('sbatch %s', temp_slurm_path); - full_cmd = sprintf('cd %s; %s', log_dir, sbatch_call); - - subj_id_string = sprintf('sub-%03d', subject_id); - job_name = get_job_name(parameters, subj_id_string); - - fprintf('Submitted the job to the cluster with a command \n%s \nSee logs in %s\n', full_cmd, log_dir); - [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) - disp(out); - error('No job ID returned from SLURM'); - end - job_id = str2double(job_ids{1}); - fprintf('Job "%s" (ID: %i) submitted successfully\n', job_name, job_id); -end - -function wait_for_job_completion(job_id) - disp('User has chosen to wait until job is finished...'); - job_completed = false; - - while ~job_completed - check_cmd = sprintf('sacct -j %i -o State --noheader | tail -n 1', job_id); - [status_check, out] = system(check_cmd); - - n_sec = 20; - if status_check == 0 - job_state = strtrim(out); - disp(['Job status: ', job_state]); - - if strcmp(job_state, 'RUNNING') - pause(n_sec); - elseif strcmp(job_state, 'PENDING') - pause(n_sec); - elseif strcmp(job_state, 'COMPLETED') - disp('Job completed successfully.'); - job_completed = true; - else - pause(n_sec); - end - else - disp('Failed to check job status.'); - disp(out); - check_cmd = sprintf('scontrol show job %s', job_id); - [status_check, ~] = system(check_cmd); - if status_check ~= 0 - disp('Job is no longer listed. Assuming it completed.'); - job_completed = true; - else - break; - end - end - end -end - -function job_name = get_job_name(parameters, subj_id_string) - if ~isfield(parameters, 'slurm_job_prefix') - parameters.slurm_job_prefix = 'PRESTUS'; - end - job_name = [parameters.slurm_job_prefix '_' subj_id_string]; -end - -function request_gpu = get_gpu_request(parameters) - if isfield(parameters, 'hpc_partition') && ~isempty(parameters.hpc_partition) && ~strcmp(parameters.hpc_partition, '') - request_gpu = true; - elseif strcmp(parameters.code_type, 'matlab_gpu') || strcmp(parameters.code_type, 'cpp_gpu') - request_gpu = true; - else - request_gpu = false; - end -end - -function fprintf_slurm_header(fid, job_name, parameters, subj_id_string, log_dir, timelimit, memorylimit) - fprintf(fid, '#!/bin/bash\n'); - fprintf(fid, '#SBATCH --job-name=%s\n', job_name); - - % Partition - if isfield(parameters, 'hpc_partition') && ~isempty(parameters.hpc_partition) && ~strcmp(parameters.hpc_partition, '') - fprintf(fid, '#SBATCH --partition=%s\n', parameters.hpc_partition); - elseif get_gpu_request(parameters) - fprintf(fid, '#SBATCH --partition=gpu\n'); - end - - % GPU resources - if isfield(parameters, 'hpc_gpu') && ~isempty(parameters.hpc_gpu) && ~strcmp(parameters.hpc_gpu, '') - fprintf(fid, '#SBATCH --gres=%s\n', parameters.hpc_gpu); - elseif get_gpu_request(parameters) - fprintf(fid, '#SBATCH --gres=gpu:1\n'); - end - - % Reservation - if isfield(parameters, 'hpc_reservation') && ~isempty(parameters.hpc_reservation) && ~strcmp(parameters.hpc_reservation, '') - fprintf(fid, '#SBATCH --reservation=%s\n', parameters.hpc_reservation); - end - - % Resources - fprintf(fid, '#SBATCH --mem=%iG\n', memorylimit); - fprintf(fid, '#SBATCH --time=%s\n', 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); -end \ No newline at end of file diff --git a/functions/single_subject_pipeline.m b/functions/prestus_pipeline.m similarity index 95% rename from functions/single_subject_pipeline.m rename to functions/prestus_pipeline.m index 2c0b3ea1..125f3f08 100644 --- a/functions/single_subject_pipeline.m +++ b/functions/prestus_pipeline.m @@ -1,13 +1,12 @@ -function [parameters] = single_subject_pipeline(subject_id, parameters, options) +function [parameters] = prestus_pipeline(subject_id, 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 +19,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) % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % @@ -356,7 +355,7 @@ 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'); @@ -396,12 +395,10 @@ water_parameters.output_dir = fileparts(water_parameters.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(parameters.subject_id, water_parameters); clear water_parameters; end @@ -409,7 +406,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'); @@ -427,12 +424,12 @@ 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)); 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(subject_id, parameters, options) end end \ No newline at end of file diff --git a/simple_main.m b/simple_main.m index bf5a4c15..1e5c462f 100644 --- a/simple_main.m +++ b/simple_main.m @@ -5,8 +5,7 @@ % 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 @@ -15,20 +14,10 @@ parameters = load_parameters('tutorial_config.yaml'); % load the configuration file -parameters.simulation_medium = medium; -parameters.hpc_submit_medium = submit; +parameters.simulation_medium = 'layered'; % water or layered +parameters.code_type = 'matlab_cpu'; +parameters.hpc_submit_medium = 'matlab'; % or 'auto' +parameters.interactive = 1; % for interactive debugging +parameters.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(subject_id, parameters) \ No newline at end of file From 609bb9874cf0a7d8a2c4414a1e8e56ade11025ef Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 20:55:53 +0100 Subject: [PATCH 102/144] [platform] rename submit medium to platform, remove special case for calibration --- configs/calibration_config.yaml | 1 - configs/default_config.yaml | 2 +- documentation/doc_calibration.md | 2 -- documentation/doc_parameters.md | 3 +- .../calibration/calibration_transducer.m | 4 --- functions/core/prestus_pipeline_start.m | 30 +++++++++---------- functions/head/segmentation_run.m | 8 ++--- functions/hpc/hpc_job_info.m | 6 ++-- functions/hpc/hpc_job_name.m | 8 ++--- 9 files changed, 28 insertions(+), 36 deletions(-) diff --git a/configs/calibration_config.yaml b/configs/calibration_config.yaml index be8cf2ae..9d918af7 100644 --- a/configs/calibration_config.yaml +++ b/configs/calibration_config.yaml @@ -33,7 +33,6 @@ filename_calibrated_CSV: '' # Filename of calibrated CSV data (default: standalone script generates name based on equipment) # Simulation submission overrrides - submit_medium: 'slurm' # Simulation submit mode: matlab (debug; doesn't overwrite; force cpu mode), slurm (recommended), or qsub force_kwavearray: 1 # Force to run free-water simulations with kwavearray? axisymmetric2D: 0 # Convert (default; depends on study config) 3D free-water simulation setup to 2D axisymmetry for efficiency diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 8c4fb52d..7d8e9db1 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -71,7 +71,7 @@ debug: 1 # Activate a debug mode in which more intermediate outputs are provided #------------ High-performance computing -hpc_submit_medium: 'auto' # auto (recommended) | slurm | qsub | matlab (recommended for debugging) +platform: 'auto' # auto (recommended) | slurm | qsub | matlab (recommended for debugging) hpc_gpu: '' hpc_partition: '' hpc_reservation: '' diff --git a/documentation/doc_calibration.md b/documentation/doc_calibration.md index 4c179d44..1b59a968 100644 --- a/documentation/doc_calibration.md +++ b/documentation/doc_calibration.md @@ -84,8 +84,6 @@ List of intensities (in free-water W/cm2) to be characterized: How simulations will be run will largely be determined by the main `parameters` (e.g., `parameters.code_type`). However, additional settings in `parameters.calibration` can overwrite default behaviour: - - `submit_medium` - Simulation submit mode: `slurm` (recommended), `matlab`, `qsub` - `axisymmetric2D` Overwrite default 3D simulation to perform axisymmetric 2D water simulations (`1` = yes, `0` = no (default)). - `force_kwavearray` diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 5baf2be6..0355b785 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -198,7 +198,7 @@ see [doc_backend.md](doc_backend.md) [doc_hpc.md](doc_hpc.md) | **Parameter** | **Description** | **Comments** | |-----------------------------------|-------------------------------------------------------------------------------------------|----------------------| | `code_type` | Type of k-Wave code to run (`matlab_cpu`, `matlab_gpu`, `cpp_cpu`, or `cpp_gpu`). | See [doc_backend.md](doc_backend.md). | -| `hpc_submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab`, `qsub` | +| `platform` | Simulation submit mode | `slurm` (recommended), `matlab`, `qsub` | | `hpc_gpu` | Request a specific GPU. [Optional] | Not recommended by default, rely on automatic GPU detection instead. May be useful when benchmarking specific GPUs. E.g.,```"nvidia_a100-sxm4-40gb:1"```. ```scontrol show nodes \| egrep -o gres/gpu:.*=[0-9] \| egrep -o 'nvidia_.*=' \| sort \| uniq \| sed 's/=//'``` lists available GPU types. | | `hpc_partition` | Request a dedicated GPU partition. [Optional] | The Donders HCP provides a ```gpu40g``` partition that consists of nodes with GPU with vRAM > 40 GB. This is the recommended default for thermal simulations of longer protocols. | | `hpc_reservation` | Request a reserved cue. [Optional] | | @@ -221,7 +221,6 @@ For transducer calibration, a separate `calibration_config.yaml` applies that sh | `focal_depths_wrt_exit_plane` | List of focal depths (in mm) to be characterized. | Multiple combinations can be specified. | | `desired_intensities` | Desired free-water intensities [W/cm^2] | Multiple combinations can be specified. | | `add_FDO` | Append Focal Distance Offset (distance from transducer bowl to exit plane). Set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl. | The profile is padded (between bowl and exit plane) with zero. This can stabilize the fitting procedure in the near field. | -| `submit_medium` | Simulation submit mode | `slurm` (recommended), `matlab` (debug; doesn't overwrite), `qsub` | | `axisymmetric2D` | Overwrite default 3D simulation to perform axisymmetric 2D. | (`1 = yes, 0 = no`) | | `force_kwavearray` | Force to run free-water simulations with kwavearray? | If active, free-water simulations always use kwavearray. If set to `0`, simulations use the setting in the default or study-specific config. | | `opt_method` | `FEXminimize` (open source subtoolbox) or `GlobalSearch` (MATLAB's Global Optimization Toolbox) | | diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index 6bca8363..a28585f9 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -71,10 +71,6 @@ % Copy calibration settings to relevant entries in simulation config sim_param = parameters; - % Overwrite calibration submission medium (if specified) - if isfield(parameters.calibration, 'submit_medium') - sim_param.submit_medium = parameters.calibration.submit_medium; - end % Force water medium sim_param.simulation_medium = 'water'; % Force save result matrices diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index 4b70ebf4..25c644d2 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -7,7 +7,7 @@ function prestus_pipeline_start(subject_id, parameters, options) % % Inputs: % subject_id - Subject number (double) -% parameters - Struct with sim_path, submit_medium, hpc_* settings +% parameters - Struct with sim_path, platform, hpc_* settings % options - Struct with sequential_configs (default: empty) arguments @@ -23,24 +23,24 @@ function prestus_pipeline_start(subject_id, parameters, options) end % ========== STEP 1: PLATFORM AUTO-DETECTION ========== - if ~isfield(parameters, 'submit_medium') || strcmp(parameters.submit_medium, 'auto') - submit_medium = hpc_detect_system(); - parameters.submit_medium = submit_medium; - fprintf('➤ auto-detected: %s\n', upper(submit_medium)); + if ~isfield(parameters, 'platform') || strcmp(parameters.platform, 'auto') + platform = hpc_detect_system(); + parameters.platform = platform; + fprintf('➤ auto-detected: %s\n', upper(platform)); else - submit_medium = parameters.submit_medium; - fprintf('➤ deploying: %s\n', upper(submit_medium)); + platform = parameters.platform; + fprintf('➤ deploying: %s\n', upper(platform)); end % ========== DISPATCH EXECUTION ========== - switch parameters.submit_medium + switch parameters.platform case 'matlab' fprintf('🖥️ Running in MATLAB\n\n'); prestus_pipeline(subject_id, parameters, options); case {'slurm', 'qsub'} % ========== HPC EXECUTION ========== - hpc_validate_parameters(parameters, submit_medium); + hpc_validate_parameters(parameters, platform); [log_dir, path_to_pipeline, temp_data_path, temp_m_path, temp_m_file] = ... hpc_setup_temp_files(parameters, subject_id); @@ -49,20 +49,20 @@ function prestus_pipeline_start(subject_id, parameters, options) hpc_matlab_pipeline(temp_m_path, temp_data_path, path_to_pipeline, options); % Job name - job_name = hpc_job_name(submit_medium, parameters, subject_id); + job_name = hpc_job_name(platform, parameters, subject_id); % Submit job - job_id = hpc_submit_job(submit_medium, temp_m_file, parameters, subject_id, log_dir); + job_id = hpc_submit_job(platform, temp_m_file, parameters, subject_id, log_dir); % Display job info - job_info = hpc_job_info(submit_medium, job_id, job_name, subject_id, ... + job_info = hpc_job_info(platform, job_id, job_name, subject_id, ... 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, submit_medium); + hpc_wait_for_completion(job_id, platform); fprintf('✅ Job %s completed\n\n', job_id_display); else fprintf('➡️ Continuing in MATLAB ...\n\n'); @@ -72,7 +72,7 @@ function prestus_pipeline_start(subject_id, parameters, options) parameters.job_id = job_id; otherwise - error('Unknown submit_medium: %s. Use ''matlab'', ''slurm'', ''qsub'', or ''auto''.', ... - parameters.submit_medium); + error('Unknown platform: %s. Use ''matlab'', ''slurm'', ''qsub'', or ''auto''.', ... + parameters.platform); end end \ No newline at end of file diff --git a/functions/head/segmentation_run.m b/functions/head/segmentation_run.m index 4d7201d0..a7238970 100644 --- a/functions/head/segmentation_run.m +++ b/functions/head/segmentation_run.m @@ -54,7 +54,7 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param end % if not running on a qsub or slurm HPC, the job will stop to run the segmentation manually - if strcmp(parameters.hpc_submit_medium, 'qsub') + 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], ... fullfile(log_dir, sprintf('%s_qsub_segment_output_$timestamp.log', subj_id_string)),... @@ -74,7 +74,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.hpc_submit_medium, 'slurm') + elseif strcmp(parameters.platform, 'slurm') % Create a temporary SLURM batch script file temp_slurm_file = tempname(log_dir); @@ -115,7 +115,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.hpc_submit_medium, 'matlab') + elseif strcmp(parameters.platform, 'matlab') fprintf('Running segmentation locally:\n%s\n', segment_call); if ~isfield(parameters, 'simnibs_bin_path') || isempty(parameters.simnibs_bin_path) @@ -144,7 +144,7 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param 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.\n', segment_call) - error('Submission medium %s is not available for automatic segmentation.', parameters.hpc_submit_medium); + error('Submission medium %s is not available for automatic segmentation.', parameters.platform); end end diff --git a/functions/hpc/hpc_job_info.m b/functions/hpc/hpc_job_info.m index 2b350258..8d34f6cf 100644 --- a/functions/hpc/hpc_job_info.m +++ b/functions/hpc/hpc_job_info.m @@ -1,11 +1,11 @@ -function display_info = hpc_job_info(submit_medium, job_id, job_name, subject_id, ... +function display_info = hpc_job_info(platform, job_id, job_name, subject_id, ... memory_gb, timelimit, log_dir, visualize) %% HPC_JOB_INFO Generate formatted job display information % % Creates structured display info for job submission feedback. % % Inputs: -% submit_medium - 'slurm' or 'qsub' +% platform - 'slurm' or 'qsub' % job_id - Raw job ID from submission % job_name - Job name string % subject_id - Subject number @@ -18,7 +18,7 @@ % display_info - Struct with all formatted display fields % Format job ID - if strcmp(submit_medium, 'slurm') && isnumeric(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); diff --git a/functions/hpc/hpc_job_name.m b/functions/hpc/hpc_job_name.m index 087bfc54..5e469689 100644 --- a/functions/hpc/hpc_job_name.m +++ b/functions/hpc/hpc_job_name.m @@ -1,13 +1,13 @@ -function job_name = hpc_job_name(submit_medium, parameters, subject_id) +function job_name = hpc_job_name(platform, parameters, subject_id) %% HPC_JOB_NAME Generate standardized HPC job name % -% job_name = hpc_job_name(submit_medium, parameters, subject_id) +% job_name = hpc_job_name(platform, parameters, subject_id) % % Creates job name in format: PREFIX_sub-XXX where PREFIX is % {slurm|qsub}_job_prefix from parameters (default: PRESTUS). % % Inputs: -% submit_medium - 'slurm' or 'qsub' +% platform - 'slurm' or 'qsub' % parameters - Struct (may contain slurm_job_prefix/qsub_job_prefix) % subject_id - Subject number (double) % @@ -17,7 +17,7 @@ % See also PRESTUS_PIPELINE_START, HPC_SUBMIT_JOB. subj_id_string = sprintf('sub-%03d', subject_id); - prefix_field = sprintf('%s_job_prefix', submit_medium); + prefix_field = sprintf('%s_job_prefix', platform); if ~isfield(parameters, prefix_field) parameters.(prefix_field) = 'PRESTUS'; From 121513059333ca06fc480eccceda30b175f02d09 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 21:28:11 +0100 Subject: [PATCH 103/144] [hotfix] --- functions/prestus_pipeline.m | 1 + 1 file changed, 1 insertion(+) diff --git a/functions/prestus_pipeline.m b/functions/prestus_pipeline.m index 125f3f08..c5815df8 100644 --- a/functions/prestus_pipeline.m +++ b/functions/prestus_pipeline.m @@ -298,6 +298,7 @@ warning('Heating simulations requested, but no acoustic results available. Other misspecification is possible.') parameters.heating_available = 0; parameters.run_thermal_analysis = 0; + results_heating = []; end else parameters.heating_available = 0; From 31887963b3ad59a637cac12e7d4edb1c4d295532 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 21:50:17 +0100 Subject: [PATCH 104/144] [beauty] simplify variable passing --- functions/medium/medium_pct_attenuation.m | 20 ++++++++++---------- functions/medium/medium_pct_density.m | 12 ++++++------ functions/medium/medium_pct_soundspeed.m | 12 ++++++------ functions/medium/medium_setup.m | 21 ++++++++++++--------- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/functions/medium/medium_pct_attenuation.m b/functions/medium/medium_pct_attenuation.m index 129dd10a..211a7a43 100644 --- a/functions/medium/medium_pct_attenuation.m +++ b/functions/medium/medium_pct_attenuation.m @@ -1,4 +1,4 @@ -function [alpha_coeff, alpha_power] = medium_pct_attenuation(parameters, medium, alpha_coeff, alpha_power, pseudoCT, skull_idx, algorithm) +function [alpha_coeff, alpha_power] = medium_pct_attenuation(parameters, alpha_coeff, alpha_power, pseudoCT, skull_idx, algorithm) switch algorithm case 'k-plan' @@ -8,12 +8,12 @@ % 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.skull.alpha_coeff ~= kPlan_alpha || ... - medium.skull.alpha_power ~= kPlan_alpha_power + if parameters.medium.skull.alpha_coeff ~= kPlan_alpha || ... + parameters.medium.skull.alpha_power ~= kPlan_alpha_power warning('Specified attenuation varies from k-Plan setup.') end - alpha_coeff(skull_idx) = medium.skull.alpha_coeff; - alpha_power(skull_idx) = medium.skull.alpha_power; + alpha_coeff(skull_idx) = parameters.medium.skull.alpha_coeff; + alpha_power(skull_idx) = parameters.medium.skull.alpha_power; case 'mueller' @@ -27,10 +27,10 @@ % 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.skull.alpha_power; + alpha_power(skull_idx) = parameters.medium.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^medium.skull.alpha_power); + alpha_coeff(skull_idx) = alpha_pseudoCT(skull_idx)./(0.5^parameters.medium.skull.alpha_power); case 'aubry' @@ -40,12 +40,12 @@ 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) = medium.skull.alpha_power; + alpha_power(skull_idx) = parameters.medium.skull.alpha_power; case 'none' - alpha_coeff(skull_idx) = medium.skull.alpha_coeff; - alpha_power(skull_idx) = medium.skull.alpha_power; + alpha_coeff(skull_idx) = parameters.medium.skull.alpha_coeff; + alpha_power(skull_idx) = parameters.medium.skull.alpha_power; otherwise error("Specified pCT attenuation mapping is not supported.") diff --git a/functions/medium/medium_pct_density.m b/functions/medium/medium_pct_density.m index 54519c3b..8df61d09 100644 --- a/functions/medium/medium_pct_density.m +++ b/functions/medium/medium_pct_density.m @@ -1,4 +1,4 @@ -function [density] = medium_pct_density(parameters, medium, density, pseudoCT, skull_idx, algorithm) +function [density] = medium_pct_density(parameters, density, pseudoCT, skull_idx, algorithm) switch algorithm case 'k-plan' @@ -42,7 +42,7 @@ close(gcf); % regularize minimum density to water density - density(skull_idx) = max(density(skull_idx),medium.water.density); + density(skull_idx) = max(density(skull_idx),parameters.medium.water.density); % regularize maximum density to rho_max density(skull_idx) = min(density(skull_idx),rho_max); @@ -65,7 +65,7 @@ % regularize maximum pHU to pHU_max pseudoCT(skull_idx) = min(pseudoCT(skull_idx),HU_max); - rho_water = medium.water.density; % density [kg/m^3] + rho_water = parameters.medium.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 @@ -75,8 +75,8 @@ case 'aubry' - rho_water = medium.water.density; - rho_bone = medium.skull.density; + rho_water = parameters.medium.water.density; + rho_bone = parameters.medium.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) + ... @@ -84,7 +84,7 @@ case 'none' - density(skull_idx) = medium.skull.density; + density(skull_idx) = parameters.medium.skull.density; otherwise error("Specified CT density mapping is not supported.") diff --git a/functions/medium/medium_pct_soundspeed.m b/functions/medium/medium_pct_soundspeed.m index d21be28b..386089f8 100644 --- a/functions/medium/medium_pct_soundspeed.m +++ b/functions/medium/medium_pct_soundspeed.m @@ -1,4 +1,4 @@ -function [sound_speed] = medium_pct_soundspeed(parameters, medium, sound_speed, density, pseudoCT, skull_idx, algorithm) +function [sound_speed] = medium_pct_soundspeed(parameters, sound_speed, density, pseudoCT, skull_idx, algorithm) switch algorithm case 'k-plan' @@ -7,9 +7,9 @@ case 'marsac' - c_water = medium.water.sound_speed; % sound speed [m/s] + c_water = parameters.medium.water.sound_speed; % sound speed [m/s] c_skull = 3360; % max. speed of sound in skull [m/s] - rho_water = medium.water.density; % density [kg/m^3] + rho_water = parameters.medium.water.density; % density [kg/m^3] rho_bone = 2100; % max. skull density [kg/m3] sound_speed(skull_idx) = c_water + (c_skull - c_water) * ... @@ -17,8 +17,8 @@ case 'aubry' - c_water = medium.water.sound_speed; - c_bone = medium.skull.sound_speed; + c_water = parameters.medium.water.sound_speed; + c_bone = parameters.medium.skull.sound_speed; phi(skull_idx) = 1-(pseudoCT(skull_idx)/max(pseudoCT(skull_idx))); sound_speed(skull_idx) = c_water * phi(skull_idx) + ... @@ -28,7 +28,7 @@ case 'none' - sound_speed(skull_idx) = medium.skull.sound_speed; + sound_speed(skull_idx) = parameters.medium.skull.sound_speed; otherwise error("Specified CT sound speed mapping is not supported.") diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index 157d289d..cf61d980 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -70,7 +70,7 @@ else pct_mapping_density = 'none'; end - [density] = medium_pct_density(parameters, medium, density, pseudoCT, skull_idx, pct_mapping_density); + [density] = medium_pct_density(parameters, density, pseudoCT, skull_idx, pct_mapping_density); % map skull bone sound speed with the desired algorithm if isfield(parameters, "pct_mapping_soundspeed") @@ -78,7 +78,7 @@ else pct_mapping_soundspeed = 'none'; end - [sound_speed] = medium_pct_soundspeed(parameters, medium, sound_speed, density, pseudoCT, skull_idx, pct_mapping_soundspeed); + [sound_speed] = medium_pct_soundspeed(parameters, sound_speed, density, pseudoCT, skull_idx, pct_mapping_soundspeed); % map skull bone attenuation with the desired algorithm if isfield(parameters, "pct_mapping_attenuation") @@ -86,7 +86,7 @@ else pct_mapping_attenuation = 'none'; end - [alpha_coeff, alpha_power] = medium_pct_attenuation(parameters, medium, alpha_coeff, alpha_power, pseudoCT, skull_idx, pct_mapping_attenuation); + [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 @@ -97,18 +97,21 @@ 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); + 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.debug_dir, ... sprintf('pCT_histograms%s.png',parameters.results_filename_affix)); From a7b607dade2f54e853e8ad1a57ba4e2907ef4a57 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 12 Mar 2026 10:57:43 +0100 Subject: [PATCH 105/144] [transducer positioning] draft refactoring, hpc integration --- functions/core/prestus_pipeline_start.m | 2 +- functions/hpc/hpc_setup_temp_files.m | 4 + .../hpc/transducer_positioning_with_qsub.m | 87 --- .../hpc/transducer_positioning_with_slurm.m | 105 ---- .../tp_evaluate_candidate_positions.m | 55 ++ .../transducer/tp_find_candidate_positions.m | 42 ++ .../transducer/tp_plot_geometry_overlay.m | 26 + .../transducer/tp_plot_initial_candidate.m | 47 ++ .../tp_visualize_optimal_transducer.m | 180 ++++++ functions/transducer/transducer_positioning.m | 545 ++---------------- .../transducer/transducer_positioning_start.m | 65 +++ 11 files changed, 481 insertions(+), 677 deletions(-) delete mode 100644 functions/hpc/transducer_positioning_with_qsub.m delete mode 100644 functions/hpc/transducer_positioning_with_slurm.m create mode 100644 functions/transducer/tp_evaluate_candidate_positions.m create mode 100644 functions/transducer/tp_find_candidate_positions.m create mode 100644 functions/transducer/tp_plot_geometry_overlay.m create mode 100644 functions/transducer/tp_plot_initial_candidate.m create mode 100644 functions/transducer/tp_visualize_optimal_transducer.m create mode 100644 functions/transducer/transducer_positioning_start.m diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index 25c644d2..fae5c24f 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -22,7 +22,7 @@ function prestus_pipeline_start(subject_id, parameters, options) addpath(helpers_path); end - % ========== STEP 1: PLATFORM AUTO-DETECTION ========== + % ========== PLATFORM SELECTION ========== if ~isfield(parameters, 'platform') || strcmp(parameters.platform, 'auto') platform = hpc_detect_system(); parameters.platform = platform; diff --git a/functions/hpc/hpc_setup_temp_files.m b/functions/hpc/hpc_setup_temp_files.m index 65f47af8..610ffd3b 100644 --- a/functions/hpc/hpc_setup_temp_files.m +++ b/functions/hpc/hpc_setup_temp_files.m @@ -21,8 +21,11 @@ output_dir = parameters.sim_path; 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 + [path_to_pipeline, ~, ~] = fileparts(which('prestus_pipeline')); % Generate temp files @@ -34,4 +37,5 @@ 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/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/transducer/tp_evaluate_candidate_positions.m b/functions/transducer/tp_evaluate_candidate_positions.m new file mode 100644 index 00000000..0e686121 --- /dev/null +++ b/functions/transducer/tp_evaluate_candidate_positions.m @@ -0,0 +1,55 @@ +function tpos_pars = tp_evaluate_candidate_positions(img, target, parameters, pixel_size) +% TP_EVALUATE_CANDIDATE_POSITIONS Evaluation of all candidate positions +% +% 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 + +[t1_x,t1_y,t1_z] = ndgrid(1:size(img)); +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)]); + +% Compute tissue boundaries +all_masks = img>0; outer_boundary = imdilate(all_masks, strel('sphere',1)) - all_masks; +skin_boundary = all_masks - imerode(all_masks, strel('sphere',1)); +img_cp = img; img_cp(img_cp==7|img_cp==8)=4; 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; + +skin_coords = gpuArray(coord_mesh.xyz(find(skin_boundary),:)); +skull_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)); +close_enough_idx = outer_idx(distances_to_target<(parameters.tp_dist_close/pixel_size)); + +% GPU position evaluation pipeline +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; +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(img>0)); 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"]); +end diff --git a/functions/transducer/tp_find_candidate_positions.m b/functions/transducer/tp_find_candidate_positions.m new file mode 100644 index 00000000..44d5bda9 --- /dev/null +++ b/functions/transducer/tp_find_candidate_positions.m @@ -0,0 +1,42 @@ +function [outer_sphere_3d, segm_img_slice, trans_pos, geom_focus_pos, ex_plane_pos, norm_v] = ... + tp_find_candidate_positions(img, target, pixel_size, parameters, subject_id, target_name) + +% TP_FIND_CANDIDATE_POSITIONS 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.) +% subject_id - Scalar subject ID for plotting +% target_name - String target name (e.g. 'motor_cortex') for plotting +% +% OUTPUT +% outer_sphere_3d - 3D logical mask of valid transducer positions on skull surface +% segm_img_slice - RGB slice through target(y) with search sphere highlighted +% trans_pos - 1x3 initial random transducer position [x,y,z] +% geom_focus_pos - 1x3 geometric focus position +% ex_plane_pos - 1x3 exit plane position +% norm_v - 1x3 unit normal vector from transducer to target + +[t1_x, t1_y, t1_z] = ndgrid(1:size(img)); + +% Expand sphere until skull intersection found +if ~isfield(parameters, 'min_focal_distance_mm') + parameters.min_focal_distance_mm = parameters.expected_focal_distance_bowl; +end +outer_sphere_3d = []; outer_sphere = []; +while numel(find(outer_sphere)) < 1 + grid_dist = sqrt((t1_x-target(1)).^2+(t1_y-target(2)).^2+(t1_z-target(3)).^2); + dist_sphere = abs(grid_dist-parameters.min_focal_distance_mm/pixel_size)<0.5; + outer_sphere_3d = dist_sphere&img==0; + segm_img_slice = ind2rgb(squeeze(img(:,target(2),:)), viridis(max(img(:))+1)); + outer_sphere = squeeze(dist_sphere(:,target(2),:))&squeeze(img(:,target(2),:))==0; + segm_img_slice(outer_sphere) = 1; + parameters.min_focal_distance_mm = parameters.min_focal_distance_mm + 3; +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..4a986c1a --- /dev/null +++ b/functions/transducer/tp_plot_geometry_overlay.m @@ -0,0 +1,26 @@ +function tp_plot_geometry_overlay(img, target, trans_pos, pixel_size, parameters, subject_id, target_name, t1_x, t1_y, t1_z) + max_od_mm = max(parameters.transducer.Elements_OD_mm); % Largest element diameter (mm) - defines transducer aperture + % Sagitta/2: distance from geometric focus to exit plane + % Derivation: h = R - sqrt(R^2-(D/2)^2) where R=curv_radius, D=max_od_mm + % Exit plane = h/2 from sphere center = 0.5*sqrt(4R^2-D^2) + dist_gf_to_ep_mm = 0.5*sqrt(4*parameters.transducer.curv_radius_mm^2-max_od_mm^2); + norm_v = (trans_pos-target)/norm(target-trans_pos); % Unit normal vector pointing from transducer → target + 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_od_mm/pixel_size; + 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; + + skin_only = uint8(img==5); skin_only(orth_plane_disk)=2; skin_only(find(outer_sphere_3d))=3; + + h = figure; imagesc(squeeze(skin_only(:,target(2),:))); hold on; + trans_xz = trans_pos([1,3]); target_xz = target([1,3]); + rectangle('Position',[flip(trans_xz)-2, 4, 4],'Curvature',[0,0],'EdgeColor','b','LineWidth',2); + rectangle('Position',[flip(geom_focus_pos([1,3]))-2, 4, 4],'Curvature',[0,0],'EdgeColor','yellow','LineWidth',2); + rectangle('Position',[flip(ex_plane_pos([1,3]))-2, 4, 4],'Curvature',[0,0],'EdgeColor','white','LineWidth',2); + rectangle('Position',[flip(target_xz)-2, 4, 4],'Curvature',[0,0],'EdgeColor','r','LineWidth',2); + 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); + saveas(h, fullfile(parameters.output_dir,sprintf('sub-%03d_geometry_%s.png', subject_id, target_name)), 'png'); close(h); +end \ No newline at end of file diff --git a/functions/transducer/tp_plot_initial_candidate.m b/functions/transducer/tp_plot_initial_candidate.m new file mode 100644 index 00000000..213f0385 --- /dev/null +++ b/functions/transducer/tp_plot_initial_candidate.m @@ -0,0 +1,47 @@ +function tp_plot_initial_candidate_positions(segm_img_slice, target, outer_sphere_3d, t1_x, t1_y, t1_z, ... + pixel_size, parameters, subject_id, target_name) + % TP_PLOT_INITIAL_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 + % segm_img_slice - RGB slice through target(y) with sphere highlighted + % target - 1x3 target coordinates [x,y,z] + % outer_sphere_3d - 3D logical mask of skull surface candidates + % t1_x/y/z - ndgrid coordinate arrays + % 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 + + h = figure; + colormap([0.3 0.3 0.3; lines(12)]) + imagesc(segm_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','-'); + + % 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]); + + % 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.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_visualize_optimal_transducer.m b/functions/transducer/tp_visualize_optimal_transducer.m new file mode 100644 index 00000000..021efe72 --- /dev/null +++ b/functions/transducer/tp_visualize_optimal_transducer.m @@ -0,0 +1,180 @@ +function tp_visualize_optimal_transducer(tpos_pars, img_orig, img_info, parameters, pixel_size, target_name, subject_id) +% TP_VISUALIZE_OPTIMAL_TRANSDUCER Create 6-panel visualization of optimal transducer placement +% +% Selects optimal position based on intersection, skull/skin distance criteria. Creates comprehensive +% 6-panel figure showing aligned anatomy, geometric properties, 3D rendering, and validation plots. +% +% INPUT +% tpos_pars - Table from tp_evaluate_candidate_positions_gpu() +% img_orig - Original segmented head image +% img_info - NIfTI header info +% parameters - Parameters struct +% pixel_size - Scalar voxel size in mm +% target_name - String target identifier +% subject_id - Scalar subject ID +% +% OUTPUT +% Saves: sub-XXX_optimal_TARGET.png (6-panel figure) + +% Select optimal position (original multi-step criteria) +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 = 1; % First optimal candidate +target_xyz = [tppf.targ_x(i), tppf.targ_y(i), tppf.targ_z(i)]; +trans_xyz = [tppf.trans_x(i), tppf.trans_y(i), tppf.trans_z(i)]; + +% Align to focal axis (original preprocessing) +[rotated_img, trans_xyz, target_xyz, transformation_matrix, ~, ~, ~, ~] = ... + preproc_align_to_focal_axis(img_orig, img_info, round(trans_xyz)', target_xyz', 1, parameters); +TF = maketform('affine', transformation_matrix); + +% 6-panel comprehensive visualization +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); diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index 171d9d8b..85f3368e 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -1,485 +1,62 @@ 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 - - if ~any(ismember(toolboxesLoc,allPaths)) - addpath(genpath(toolboxesLoc)); - disp(['Adding ', toolboxesLoc, 'and subfolders']); - else - 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}]); - 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']); - end - end - - % test that kwave is added - if ~exist('makeBowl','file') - error('kwave not added'); - end - - headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); - filename_segmented_headreco = fullfile(headreco_folder,'final_tissues.nii.gz'); - - 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); - - % original target loop - - 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 the segmented image - - 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_bowl; - 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 - - 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); - - max_od_mm = max(parameters.transducer.Elements_OD_mm); - - % normal vector - dist_gf_to_ep_mm = 0.5*sqrt(4*parameters.transducer.curv_radius_mm^2-max_od_mm^2); - - 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; - - 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; - - skin_only = uint8(segmented_img_orig==5); - - skin_only(orth_plane_disk)=2; - skin_only(outer_sphere_3d) = 3; - - 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); - - 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 +% TRANSDUCER_POSITIONING Heuristic transducer placement for MNI targets + +arguments + parameters struct + pn struct + subject_id double + 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); + +%% 2. LOAD SEGMENTATION DATA +headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); +filename = fullfile(headreco_folder, 'final_tissues.nii.gz'); +segmented_img_orig = niftiread(filename); +segmented_img_head = niftiinfo(filename); +pixel_size = mean(segmented_img_head.PixelDimensions); +im_center = round(size(segmented_img_orig)/2); + +h = figure; +montage({rot90(squeeze(img(center(1),:,:))), rot90(squeeze(img(:,center(2),:))), ... + squeeze(img(:,:,center(3)))}, viridis(8), 'Size', [1 3]); +saveas(h, fullfile(parameters.output_dir, sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); +close(h); + +%% 3. TARGET LOCALIZATION +fprintf('➤ Target: %s\n', target_name); +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)); + +%% 5. Find candidate positions + +[outer_sphere_3d, segm_img_slice, t1_x, t1_y, t1_z] = tp_find_candidate_positions(img_orig, target, pixel_size, parameters, subject_id, target_name); + +%% 6. Plot: Initial candidate positions +tp_plot_initial_candidate_positions(segm_img_slice, target, outer_sphere_3d, t1_x, t1_y, t1_z, ... + pixel_size, parameters, subject_id, target_name); + +%% 7. Plot geometry + +tp_plot_geometry_overlay(img_orig, target, outer_sphere_3d, t1_x, t1_y, t1_z, pixel_size, parameters, subject_id, target_name); + +%% 8. Candidate EVALUATION +tpos_pars = tp_evaluate_candidate_positions(segmented_img_orig, target, parameters, pixel_size); + +%% 9. FINAL VISUALIZATION +tp_visualize_optimal_transducer(tpos_pars, segmented_img_orig, segmented_img_head, ... + parameters, pixel_size, target_name, subject_id); + +%% 10. SAVE RESULTS +tpos_output_file = fullfile(parameters.output_dir, sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); +writetable(tpos_pars, tpos_output_file, 'Delimiter', ','); +fprintf('Heuristic transducer placement: %s (sub-%03d) → %s\n', target_name, subject_id, tpos_output_file); + +end diff --git a/functions/transducer/transducer_positioning_start.m b/functions/transducer/transducer_positioning_start.m new file mode 100644 index 00000000..e813e048 --- /dev/null +++ b/functions/transducer/transducer_positioning_start.m @@ -0,0 +1,65 @@ +function transducer_positioning_start(subject_id, parameters, pn, target_name, mni_targets) + arguments + subject_id double + parameters struct + pn struct + target_name string + mni_targets struct + end + + % ========== PLATFORM SELECTION ========== + if ~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, subject_id, target_name, mni_targets); + + case {'slurm', 'qsub'} + % ========== HPC EXECUTION ========== + hpc_validate_parameters(parameters, platform) + + [log_dir, path_to_pipeline, temp_data_path, temp_m_path, temp_m_file] = ... + hpc_setup_temp_files(parameters, subject_id); + + % populate temp data + save(temp_data_path, "subject_id", "parameters", "pn", "target_name", "mni_targets"); + + fid = fopen(temp_m_path, 'w'); + fprintf(fid, 'load(''%s'');\n', temp_data.file_data); + fprintf(fid, 'cd(''%s'');\n', path_to_pipeline); + fprintf(fid, 'transducer_positioning(parameters, pn, subject_id, target_name, mni_targets);\n'); + fprintf(fid, 'delete(''%s'');\n', temp_data.file_data); + fprintf(fid, 'delete(''%s'');\n', temp_script.file_data); + fclose(fid); + + job_name = hpc_job_name(parameters, 'tusim_tp', subject_id); + job_id = hpc_submit_job(platform, temp_m_file, parameters, subject_id, log_dir); + job_info = hpc_job_info(platform, job_id, job_name, subject_id, ... + 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); + fprintf('✅ Job %s completed\n\n', job_id_display); + end + + % Save job ID for chaining + parameters.job_id = job_id; + + otherwise + error('Unknown platform: %s. Use ''matlab'', ''slurm'', ''qsub'', or ''auto''.', ... + parameters.platform); + end + + +end From 5559d5021d4741cb82bfa12ab67a2dbfeda73c0b Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 12 Mar 2026 14:02:27 +0100 Subject: [PATCH 106/144] [map] central function for coordinate mapping --- functions/transducer/transducer_positioning.m | 51 +++++++++------ functions/transform/map_coordsystems.m | 64 +++++++++++++++++++ functions/transform/ras_to_grid.m | 6 +- 3 files changed, 101 insertions(+), 20 deletions(-) create mode 100644 functions/transform/map_coordsystems.m diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index 85f3368e..a9f07177 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -18,43 +18,56 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar %% 2. LOAD SEGMENTATION DATA headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); filename = fullfile(headreco_folder, 'final_tissues.nii.gz'); -segmented_img_orig = niftiread(filename); +seg_img = niftiread(filename); segmented_img_head = niftiinfo(filename); pixel_size = mean(segmented_img_head.PixelDimensions); -im_center = round(size(segmented_img_orig)/2); +im_center = round(size(seg_img)/2); -h = figure; -montage({rot90(squeeze(img(center(1),:,:))), rot90(squeeze(img(:,center(2),:))), ... - squeeze(img(:,:,center(3)))}, viridis(8), 'Size', [1 3]); -saveas(h, fullfile(parameters.output_dir, sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); -close(h); +% [DEBUG] plot the segmentation +if parameters.debug + h = figure; + montage({rot90(squeeze(seg_img(im_center(1),:,:))), ... + rot90(squeeze(seg_img(:,im_center(2),:))), ... + squeeze(seg_img(:,:,im_center(3)))}, ... + viridis(8), 'Size', [1 3]); + saveas(h, fullfile(parameters.debug_dir, ... + sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); + close(h); +end + +%% 3. Convert target_vox from MNI to subject space -%% 3. TARGET LOCALIZATION fprintf('➤ Target: %s\n', target_name); -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)); +target_mni = mni_targets.(target_name); + +target_vox = map_coordsystems(parameters, target_mni, 'mni', 'grid', segmented_img_head); %% 5. Find candidate positions -[outer_sphere_3d, segm_img_slice, t1_x, t1_y, t1_z] = tp_find_candidate_positions(img_orig, target, pixel_size, parameters, subject_id, target_name); +[outer_sphere_3d, segm_img_slice, t1_x, t1_y, t1_z] = ... + tp_find_candidate_positions(img_orig, target_vox, pixel_size, parameters, subject_id, target_name); + +%% 6. Plot initial candidate positions -%% 6. Plot: Initial candidate positions -tp_plot_initial_candidate_positions(segm_img_slice, target, outer_sphere_3d, t1_x, t1_y, t1_z, ... +tp_plot_initial_candidate_positions(segm_img_slice, target_vox, outer_sphere_3d, t1_x, t1_y, t1_z, ... pixel_size, parameters, subject_id, target_name); %% 7. Plot geometry -tp_plot_geometry_overlay(img_orig, target, outer_sphere_3d, t1_x, t1_y, t1_z, pixel_size, parameters, subject_id, target_name); +tp_plot_geometry_overlay(img_orig, target_vox, outer_sphere_3d, ... + t1_x, t1_y, t1_z, pixel_size, parameters, subject_id, target_name); %% 8. Candidate EVALUATION -tpos_pars = tp_evaluate_candidate_positions(segmented_img_orig, target, parameters, pixel_size); -%% 9. FINAL VISUALIZATION -tp_visualize_optimal_transducer(tpos_pars, segmented_img_orig, segmented_img_head, ... +tpos_pars = tp_evaluate_candidate_positions(seg_img, target_vox, parameters, pixel_size); + +%% 9. Optimal transducer VISUALIZATION + +tp_visualize_optimal_transducer(tpos_pars, seg_img, segmented_img_head, ... parameters, pixel_size, target_name, subject_id); -%% 10. SAVE RESULTS +%% 10. Save Results table + tpos_output_file = fullfile(parameters.output_dir, sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); writetable(tpos_pars, tpos_output_file, 'Delimiter', ','); fprintf('Heuristic transducer placement: %s (sub-%03d) → %s\n', target_name, subject_id, tpos_output_file); diff --git a/functions/transform/map_coordsystems.m b/functions/transform/map_coordsystems.m new file mode 100644 index 00000000..dff8c8c3 --- /dev/null +++ b/functions/transform/map_coordsystems.m @@ -0,0 +1,64 @@ +function output_pos = map_coordsystems(parameters, input_pos, input_cs, output_cs, nii_hdr) +% MAP_COORDSYSTEMS Coord mapper with explicit varargin requirements. +% +% 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.seg_path, 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 = map_coordsystems(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 = ras_to_grid(input_pos, nii_hdr); + % alternative: + % output_pos = round(transformPointsInverse(nii_hdr.Transform, input_pos)); + 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+...") + 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/functions/transform/ras_to_grid.m b/functions/transform/ras_to_grid.m index 002a596a..643cbb4f 100644 --- a/functions/transform/ras_to_grid.m +++ b/functions/transform/ras_to_grid.m @@ -6,12 +6,16 @@ % 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] 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]); From bcc5955b9c198056b3773cf8df6c05f396b37ae8 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 12 Mar 2026 16:29:03 +0100 Subject: [PATCH 107/144] [transducer placement] continue refactoring --- .../tp_evaluate_candidate_positions.m | 86 ++++++++++++------ .../transducer/tp_find_candidate_positions.m | 42 --------- .../transducer/tp_find_initial_candidate.m | 63 +++++++++++++ ...didate.m => tp_plot_candidate_positions.m} | 20 ++-- .../transducer/tp_plot_geometry_overlay.m | 91 +++++++++++++------ ...ducer.m => tp_plot_heuristic_transducer.m} | 8 +- functions/transducer/transducer_positioning.m | 53 +++++++---- functions/transform/ras_to_grid.m | 4 +- 8 files changed, 234 insertions(+), 133 deletions(-) delete mode 100644 functions/transducer/tp_find_candidate_positions.m create mode 100644 functions/transducer/tp_find_initial_candidate.m rename functions/transducer/{tp_plot_initial_candidate.m => tp_plot_candidate_positions.m} (64%) rename functions/transducer/{tp_visualize_optimal_transducer.m => tp_plot_heuristic_transducer.m} (95%) diff --git a/functions/transducer/tp_evaluate_candidate_positions.m b/functions/transducer/tp_evaluate_candidate_positions.m index 0e686121..05175aec 100644 --- a/functions/transducer/tp_evaluate_candidate_positions.m +++ b/functions/transducer/tp_evaluate_candidate_positions.m @@ -1,8 +1,13 @@ function tpos_pars = tp_evaluate_candidate_positions(img, target, parameters, pixel_size) -% TP_EVALUATE_CANDIDATE_POSITIONS Evaluation of all candidate positions +%% TP_EVALUATE_CANDIDATE_POSITIONS Evaluate candidate transducer positions +% SEARCH CRITERIA: +% 1. Skull exterior surface points (img==0 → imdilate gradient) +% 2. Within parameters.tp_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. +% 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) @@ -14,42 +19,67 @@ % 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 -[t1_x,t1_y,t1_z] = ndgrid(1:size(img)); -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)]); +disp("[TP] Evaluating candidate positions (fraction, skin/skull distances, and variance) ...") -% Compute tissue boundaries -all_masks = img>0; outer_boundary = imdilate(all_masks, strel('sphere',1)) - all_masks; -skin_boundary = all_masks - imerode(all_masks, strel('sphere',1)); -img_cp = img; img_cp(img_cp==7|img_cp==8)=4; 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; +sz = size(img); +[t1_x, t1_y, t1_z] = ndgrid(1:sz(1), 1:sz(2), 1:sz(3)); % Full volume voxel coordinates -skin_coords = gpuArray(coord_mesh.xyz(find(skin_boundary),:)); +% GPU coordinate mesh (vectorized for parallel evaluation) +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 + +%% Extract tissue boundaries for distance calculations +all_masks = img > 0; % Brain+skull+skin +outer_boundary = imdilate(all_masks, strel('sphere',1)) - all_masks; % Exterior surface (air interface) +skin_boundary = all_masks - imerode(all_masks, strel('sphere',1)); % Skin surface gradient +skin_coords = gpuArray(coord_mesh.xyz(find(skin_boundary),:)); % Skin surface points + +% Skull boundary (compact bone exterior) +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'); % Fill inner skull cavity +skull_fill = imerode(skull_fill, strel('sphere',1)); +skull_boundary = skull & ~skull_fill; % Outer compact bone surface skull_coords = gpuArray(coord_mesh.xyz(find(skull_boundary),:)); + +%% Filter candidate positions: outer surface within tp_dist_close of target + outer_idx = find(outer_boundary); -coord_rel_to_targ = coord_mesh.xyz - target; -distances_to_target = sqrt(sum(coord_rel_to_targ.^2,2)); -close_enough_idx = outer_idx(distances_to_target<(parameters.tp_dist_close/pixel_size)); +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.tp_dist_close/pixel_size)); -% GPU position evaluation pipeline -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])); +%% GPU vectorized transducer geometry for all candidates +trans_pos_coords = coord_mesh.xyz(close_enough_idx,:); % N_candidates x 3 +norm_v = gpuArray((trans_pos_coords-target) ./ repmat(sqrt(sum((trans_pos_coords-target).^2,2)), [1, 3])); % Unit vectors + +% Physical → voxel geometry conversion 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 = gpuArray(geom_focus_pos_all+norm_v*dist_gf_to_ep_mm/pixel_size); -all_masks_indx_gpu = gpuArray(find(img>0)); max_od_grid = max_od_mm / pixel_size; +dist_gf_to_ep_mm = 0.5*sqrt(4*parameters.transducer.curv_radius_mm^2 - max_od_mm^2); % Sagitta/2 +dist_tp_to_ep_mm = parameters.transducer.curv_radius_mm - dist_gf_to_ep_mm; % Transducer-to-exit distance + +% Shift positions forward along ray by 5mm + transducer-to-exit distance +pos_shift_mm = 5 + dist_tp_to_ep_mm; +shifted_trans_pos_coords = trans_pos_coords + norm_v*(pos_shift_mm/pixel_size); +% Compute geometric focus and exit plane for all candidates +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(img>0)); +max_od_grid = max_od_mm / pixel_size; + +%% Parallel evaluation of all candidate positions [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)); + all_masks_indx_gpu, skin_coords, skull_coords, max_od_grid), 1:length(close_enough_idx)); +%% Assemble results table (CPU) 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",... + '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 diff --git a/functions/transducer/tp_find_candidate_positions.m b/functions/transducer/tp_find_candidate_positions.m deleted file mode 100644 index 44d5bda9..00000000 --- a/functions/transducer/tp_find_candidate_positions.m +++ /dev/null @@ -1,42 +0,0 @@ -function [outer_sphere_3d, segm_img_slice, trans_pos, geom_focus_pos, ex_plane_pos, norm_v] = ... - tp_find_candidate_positions(img, target, pixel_size, parameters, subject_id, target_name) - -% TP_FIND_CANDIDATE_POSITIONS 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.) -% subject_id - Scalar subject ID for plotting -% target_name - String target name (e.g. 'motor_cortex') for plotting -% -% OUTPUT -% outer_sphere_3d - 3D logical mask of valid transducer positions on skull surface -% segm_img_slice - RGB slice through target(y) with search sphere highlighted -% trans_pos - 1x3 initial random transducer position [x,y,z] -% geom_focus_pos - 1x3 geometric focus position -% ex_plane_pos - 1x3 exit plane position -% norm_v - 1x3 unit normal vector from transducer to target - -[t1_x, t1_y, t1_z] = ndgrid(1:size(img)); - -% Expand sphere until skull intersection found -if ~isfield(parameters, 'min_focal_distance_mm') - parameters.min_focal_distance_mm = parameters.expected_focal_distance_bowl; -end -outer_sphere_3d = []; outer_sphere = []; -while numel(find(outer_sphere)) < 1 - grid_dist = sqrt((t1_x-target(1)).^2+(t1_y-target(2)).^2+(t1_z-target(3)).^2); - dist_sphere = abs(grid_dist-parameters.min_focal_distance_mm/pixel_size)<0.5; - outer_sphere_3d = dist_sphere&img==0; - segm_img_slice = ind2rgb(squeeze(img(:,target(2),:)), viridis(max(img(:))+1)); - outer_sphere = squeeze(dist_sphere(:,target(2),:))&squeeze(img(:,target(2),:))==0; - segm_img_slice(outer_sphere) = 1; - parameters.min_focal_distance_mm = parameters.min_focal_distance_mm + 3; -end - diff --git a/functions/transducer/tp_find_initial_candidate.m b/functions/transducer/tp_find_initial_candidate.m new file mode 100644 index 00000000..1af3b83f --- /dev/null +++ b/functions/transducer/tp_find_initial_candidate.m @@ -0,0 +1,63 @@ +function [trans_candidate, outer_sphere_3d, img_slice, 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 +% img_slice - RGB slice through target(y) with search sphere highlighted + +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_initial_candidate.m b/functions/transducer/tp_plot_candidate_positions.m similarity index 64% rename from functions/transducer/tp_plot_initial_candidate.m rename to functions/transducer/tp_plot_candidate_positions.m index 213f0385..16cc0947 100644 --- a/functions/transducer/tp_plot_initial_candidate.m +++ b/functions/transducer/tp_plot_candidate_positions.m @@ -1,6 +1,6 @@ -function tp_plot_initial_candidate_positions(segm_img_slice, target, outer_sphere_3d, t1_x, t1_y, t1_z, ... +function tp_plot_candidate_positions(segm_img_slice, target, trans_candidate, ... pixel_size, parameters, subject_id, target_name) - % TP_PLOT_INITIAL_CANDIDATE_POSITIONS Plot transducer candidate positions on skull surface slice + % 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. @@ -8,8 +8,7 @@ function tp_plot_initial_candidate_positions(segm_img_slice, target, outer_spher % INPUT % segm_img_slice - RGB slice through target(y) with sphere highlighted % target - 1x3 target coordinates [x,y,z] - % outer_sphere_3d - 3D logical mask of skull surface candidates - % t1_x/y/z - ndgrid coordinate arrays + % 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 @@ -18,6 +17,10 @@ function tp_plot_initial_candidate_positions(segm_img_slice, target, outer_spher % OUTPUT % Saves: sub-XXX_bounds_TARGET.png + disp("[TP] Plotting initial candidate position ...") + + trans_xz = trans_candidate.trans_xz; + h = figure; colormap([0.3 0.3 0.3; lines(12)]) imagesc(segm_img_slice); axis image; hold on; @@ -27,12 +30,6 @@ function tp_plot_initial_candidate_positions(segm_img_slice, target, outer_spher rectangle('Position',[flip(target_xz)-2, 4, 4], 'Curvature',[0,0], ... 'EdgeColor','r', 'LineWidth',2, 'LineStyle','-'); - % 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]); - % Candidate position (blue rectangle) rectangle('Position',[flip(trans_xz)-2, 4, 4], 'Curvature',[0,0], ... 'EdgeColor','b', 'LineWidth',2, 'LineStyle','-'); @@ -41,7 +38,8 @@ function tp_plot_initial_candidate_positions(segm_img_slice, target, outer_spher get_transducer_box(trans_xz, target_xz, pixel_size, parameters); % Save - output_plot = fullfile(parameters.output_dir, sprintf('sub-%03d_bounds_%s.png', subject_id, target_name)); + output_plot = fullfile(parameters.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 index 4a986c1a..10b7519c 100644 --- a/functions/transducer/tp_plot_geometry_overlay.m +++ b/functions/transducer/tp_plot_geometry_overlay.m @@ -1,26 +1,65 @@ -function tp_plot_geometry_overlay(img, target, trans_pos, pixel_size, parameters, subject_id, target_name, t1_x, t1_y, t1_z) - max_od_mm = max(parameters.transducer.Elements_OD_mm); % Largest element diameter (mm) - defines transducer aperture - % Sagitta/2: distance from geometric focus to exit plane - % Derivation: h = R - sqrt(R^2-(D/2)^2) where R=curv_radius, D=max_od_mm - % Exit plane = h/2 from sphere center = 0.5*sqrt(4R^2-D^2) - dist_gf_to_ep_mm = 0.5*sqrt(4*parameters.transducer.curv_radius_mm^2-max_od_mm^2); - norm_v = (trans_pos-target)/norm(target-trans_pos); % Unit normal vector pointing from transducer → target - 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_od_mm/pixel_size; - 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; - - skin_only = uint8(img==5); skin_only(orth_plane_disk)=2; skin_only(find(outer_sphere_3d))=3; - - h = figure; imagesc(squeeze(skin_only(:,target(2),:))); hold on; - trans_xz = trans_pos([1,3]); target_xz = target([1,3]); - rectangle('Position',[flip(trans_xz)-2, 4, 4],'Curvature',[0,0],'EdgeColor','b','LineWidth',2); - rectangle('Position',[flip(geom_focus_pos([1,3]))-2, 4, 4],'Curvature',[0,0],'EdgeColor','yellow','LineWidth',2); - rectangle('Position',[flip(ex_plane_pos([1,3]))-2, 4, 4],'Curvature',[0,0],'EdgeColor','white','LineWidth',2); - rectangle('Position',[flip(target_xz)-2, 4, 4],'Curvature',[0,0],'EdgeColor','r','LineWidth',2); - 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); - saveas(h, fullfile(parameters.output_dir,sprintf('sub-%03d_geometry_%s.png', subject_id, target_name)), 'png'); close(h); -end \ No newline at end of file +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.output_dir, sprintf('sub-%03d_geometry_%s.png', subject_id, target_name)), 'png'); +close(h); + +end diff --git a/functions/transducer/tp_visualize_optimal_transducer.m b/functions/transducer/tp_plot_heuristic_transducer.m similarity index 95% rename from functions/transducer/tp_visualize_optimal_transducer.m rename to functions/transducer/tp_plot_heuristic_transducer.m index 021efe72..ffb27b20 100644 --- a/functions/transducer/tp_visualize_optimal_transducer.m +++ b/functions/transducer/tp_plot_heuristic_transducer.m @@ -1,7 +1,7 @@ -function tp_visualize_optimal_transducer(tpos_pars, img_orig, img_info, parameters, pixel_size, target_name, subject_id) -% TP_VISUALIZE_OPTIMAL_TRANSDUCER Create 6-panel visualization of optimal transducer placement +function tp_plot_heuristic_transducer(tpos_pars, img_orig, img_info, parameters, pixel_size, target_name, subject_id) +% TP_PLOT_HEURISTIC_TRANSDUCER Create visualization of heuristic transducer placement % -% Selects optimal position based on intersection, skull/skin distance criteria. Creates comprehensive +% Selects heuristic transducer position based on intersection, skull/skin distance criteria. Creates comprehensive % 6-panel figure showing aligned anatomy, geometric properties, 3D rendering, and validation plots. % % INPUT @@ -14,7 +14,7 @@ function tp_visualize_optimal_transducer(tpos_pars, img_orig, img_info, paramete % subject_id - Scalar subject ID % % OUTPUT -% Saves: sub-XXX_optimal_TARGET.png (6-panel figure) +% Saves: sub-XXX_optimal_TARGET.png % Select optimal position (original multi-step criteria) tppf = tpos_pars(tpos_pars.prop_intersect<0.05,:); diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index a9f07177..3d3872dd 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -1,6 +1,5 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_targets) % TRANSDUCER_POSITIONING Heuristic transducer placement for MNI targets - arguments parameters struct pn struct @@ -10,17 +9,20 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar 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); %% 2. LOAD SEGMENTATION DATA + headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); filename = fullfile(headreco_folder, 'final_tissues.nii.gz'); seg_img = niftiread(filename); -segmented_img_head = niftiinfo(filename); -pixel_size = mean(segmented_img_head.PixelDimensions); +seg_img_header = niftiinfo(filename); +pixel_size = mean(seg_img_header.PixelDimensions); im_center = round(size(seg_img)/2); % [DEBUG] plot the segmentation @@ -35,41 +37,52 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar close(h); end -%% 3. Convert target_vox from MNI to subject space +%% 3. Convert target from MNI (mm) to subject grid space (voxels) fprintf('➤ Target: %s\n', target_name); target_mni = mni_targets.(target_name); +target_vox = map_coordsystems(... + parameters, target_mni, 'mni', 'grid', seg_img_header); -target_vox = map_coordsystems(parameters, target_mni, 'mni', 'grid', segmented_img_head); - -%% 5. Find candidate positions +%% 5. Find candidate transducer positions on skull (expanding sphere) -[outer_sphere_3d, segm_img_slice, t1_x, t1_y, t1_z] = ... - tp_find_candidate_positions(img_orig, target_vox, pixel_size, parameters, subject_id, target_name); +[trans_candidate, outer_sphere_3d, img_slice, parameters] = ... + tp_find_initial_candidate(seg_img, target_vox, pixel_size, parameters); -%% 6. Plot initial candidate positions +%% 6. Plot initial candidate -tp_plot_initial_candidate_positions(segm_img_slice, target_vox, outer_sphere_3d, t1_x, t1_y, t1_z, ... +tp_plot_candidate_positions(... + img_slice, target_vox, trans_candidate, ... pixel_size, parameters, subject_id, target_name); %% 7. Plot geometry -tp_plot_geometry_overlay(img_orig, target_vox, outer_sphere_3d, ... - t1_x, t1_y, t1_z, pixel_size, parameters, subject_id, target_name); +tp_plot_geometry_overlay(seg_img, target_vox, trans_candidate.trans_pos, ... + pixel_size, parameters, subject_id, target_name, outer_sphere_3d); -%% 8. Candidate EVALUATION +%% 8. Evaluate candidate according to criteria -tpos_pars = tp_evaluate_candidate_positions(seg_img, target_vox, parameters, pixel_size); +tpos_pars = tp_evaluate_candidate_positions(... + seg_img, target_vox, parameters, pixel_size); -%% 9. Optimal transducer VISUALIZATION +%% 9. Plot heuristic transducer placement -tp_visualize_optimal_transducer(tpos_pars, seg_img, segmented_img_head, ... +tp_plot_heuristic_transducer(... + tpos_pars, seg_img, seg_img_header, ... parameters, pixel_size, target_name, subject_id); -%% 10. Save Results table +%% 10. Save results table + +tpos_output_file = fullfile(parameters.output_dir, ... + sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); -tpos_output_file = fullfile(parameters.output_dir, sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); writetable(tpos_pars, tpos_output_file, 'Delimiter', ','); -fprintf('Heuristic transducer placement: %s (sub-%03d) → %s\n', target_name, subject_id, tpos_output_file); + +fprintf('Heuristic transducer placement: %s (sub-%03d) → %s\n', ... + target_name, subject_id, tpos_output_file); + +%% 11. [Optional] Convert positions to RAS & MNI + +% TO DO end diff --git a/functions/transform/ras_to_grid.m b/functions/transform/ras_to_grid.m index 643cbb4f..4ded6991 100644 --- a/functions/transform/ras_to_grid.m +++ b/functions/transform/ras_to_grid.m @@ -6,7 +6,7 @@ % to grid (voxel) coordinates using the transformation matrix from a NIfTI header. % % Input: -% ras_pos - [3x1] 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: @@ -20,5 +20,5 @@ 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 From 10d34f186a58d3f4a8805e43603967778693a743 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 12 Mar 2026 16:50:08 +0100 Subject: [PATCH 108/144] [tp-hpc, default] fix tp hpc submission, fix check for expected sim path --- configs/default_config.yaml | 15 ++++++++------- functions/core/load_parameters.m | 12 ++++++------ functions/core/prestus_pipeline_start.m | 2 +- functions/hpc/hpc_job_name.m | 13 ++++++------- functions/hpc/hpc_submit_job.m | 6 +++--- .../transducer/transducer_positioning_start.m | 9 +++++---- 6 files changed, 29 insertions(+), 28 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 7d8e9db1..3482dfa1 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -10,16 +10,17 @@ #------------ I/O management -# Paths to the data location (please use full paths) -data_path: NA +data_path: '' # Location of the the input data [full path] +sim_path: '' # Location of the the simulation outputs [full paths or folder name in data_path] +sig_path: '' # Location of the the segmentation directories (containing m2m folders) [full path] -# Paths to T1 and T2 relative to data_path; all string substitutions will be done using subject_id -# t1_path_template: sub-%1$03d_orig_T1w.nii.gz -# t2_path_template: sub-%1$03d_orig_T2w.nii.gz - -# Should output be ordered based on subject id? +# Should simulation outputs be ordered based on subject id? subject_subfolder: 1 +# Paths to T1 and T2 relative to data_path; all string substitutions will be done using subject_id +#t1_path_template: '' # e.g. 'sub-%1$03d_T1w.nii*' +#t2_path_template: '' # e.g., 'sub-%1$03d_T2w.nii*'' + # Optional affix for result files names results_filename_affix: '' diff --git a/functions/core/load_parameters.m b/functions/core/load_parameters.m index bf07f0a2..39d313a4 100644 --- a/functions/core/load_parameters.m +++ b/functions/core/load_parameters.m @@ -129,16 +129,16 @@ 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, 'sim_path') && ~strcmp(parameters.sim_path, '') + javaFileObj = java.io.File(parameters.sim_path); % Check path type (absolute/relative) if javaFileObj.isAbsolute() - parameters.sim_path = fullfile(parameters.output_location); + parameters.sim_path = fullfile(parameters.sim_path); else - parameters.sim_path = fullfile(parameters.data_path, parameters.output_location); + parameters.sim_path = fullfile(parameters.data_path, parameters.sim_path); end else % Default output directory within data path - parameters.sim_path = fullfile(parameters.data_path, 'sim_outputs/'); + parameters.sim_path = fullfile(parameters.data_path, 'tussim'); end %% Validate paths for required libraries and binaries @@ -160,7 +160,7 @@ %% Default segmentation path fallback - if ~isfield(parameters, 'seg_path') || isempty(parameters.seg_path) + if ~isfield(parameters, 'seg_path') || isempty(parameters.seg_path) || strcmp(parameters.seg_path, '') parameters.seg_path = parameters.data_path; end diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index fae5c24f..7c8ae18f 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -49,7 +49,7 @@ function prestus_pipeline_start(subject_id, parameters, options) hpc_matlab_pipeline(temp_m_path, temp_data_path, path_to_pipeline, options); % Job name - job_name = hpc_job_name(platform, parameters, subject_id); + job_name = hpc_job_name(parameters, subject_id); % Submit job job_id = hpc_submit_job(platform, temp_m_file, parameters, subject_id, log_dir); diff --git a/functions/hpc/hpc_job_name.m b/functions/hpc/hpc_job_name.m index 5e469689..eaf619c8 100644 --- a/functions/hpc/hpc_job_name.m +++ b/functions/hpc/hpc_job_name.m @@ -1,13 +1,12 @@ -function job_name = hpc_job_name(platform, parameters, subject_id) +function job_name = hpc_job_name(parameters, subject_id) %% HPC_JOB_NAME Generate standardized HPC job name % -% job_name = hpc_job_name(platform, parameters, subject_id) +% job_name = hpc_job_name(parameters, subject_id) % % Creates job name in format: PREFIX_sub-XXX where PREFIX is % {slurm|qsub}_job_prefix from parameters (default: PRESTUS). % % Inputs: -% platform - 'slurm' or 'qsub' % parameters - Struct (may contain slurm_job_prefix/qsub_job_prefix) % subject_id - Subject number (double) % @@ -17,11 +16,11 @@ % See also PRESTUS_PIPELINE_START, HPC_SUBMIT_JOB. subj_id_string = sprintf('sub-%03d', subject_id); - prefix_field = sprintf('%s_job_prefix', platform); + prefix_field = sprintf('job_prefix'); - if ~isfield(parameters, prefix_field) - parameters.(prefix_field) = 'PRESTUS'; + if ~isfield(parameters, 'job_prefix') + parameters.job_prefix = 'PRESTUS'; end - job_name = [parameters.(prefix_field) '_' subj_id_string]; + job_name = [parameters.job_prefix '_' subj_id_string]; end diff --git a/functions/hpc/hpc_submit_job.m b/functions/hpc/hpc_submit_job.m index 1f68d336..1237fe74 100644 --- a/functions/hpc/hpc_submit_job.m +++ b/functions/hpc/hpc_submit_job.m @@ -35,12 +35,12 @@ error('Unsupported HPC type: %s', hpc_type); end -fprintf('Job "%s" (ID: %s) submitted successfully\n', hpc_job_name(hpc_type, parameters, subject_id), sprintf('%d', job_id)); +fprintf('Job "%s" (ID: %s) submitted successfully\n', hpc_job_name(parameters, subject_id), sprintf('%d', job_id)); % ========== LOCAL FUNCTIONS ========== function write_slurm_script(temp_slurm_path, parameters, subject_id, temp_m_file, log_dir) subj_id_string = sprintf('sub-%03d', subject_id); - job_name = hpc_job_name('slurm', parameters, subject_id); + job_name = hpc_job_name(parameters, subject_id); fid = fopen(temp_slurm_path, 'w+'); fprintf(fid, '#!/bin/bash\n'); @@ -92,7 +92,7 @@ function write_slurm_script(temp_slurm_path, parameters, subject_id, temp_m_file function write_qsub_script(temp_qsub_path, parameters, subject_id, temp_m_file, log_dir) subj_id_string = sprintf('sub-%03d', subject_id); - job_name = hpc_job_name('qsub', parameters, subject_id); + job_name = hpc_job_name(parameters, subject_id); fid = fopen(temp_qsub_path, 'w+'); fprintf(fid, '#!/bin/bash\n'); diff --git a/functions/transducer/transducer_positioning_start.m b/functions/transducer/transducer_positioning_start.m index e813e048..32ca47ca 100644 --- a/functions/transducer/transducer_positioning_start.m +++ b/functions/transducer/transducer_positioning_start.m @@ -34,14 +34,15 @@ function transducer_positioning_start(subject_id, parameters, pn, target_name, m save(temp_data_path, "subject_id", "parameters", "pn", "target_name", "mni_targets"); fid = fopen(temp_m_path, 'w'); - fprintf(fid, 'load(''%s'');\n', temp_data.file_data); + fprintf(fid, 'load(''%s'');\n', temp_data_path); fprintf(fid, 'cd(''%s'');\n', path_to_pipeline); fprintf(fid, 'transducer_positioning(parameters, pn, subject_id, target_name, mni_targets);\n'); - fprintf(fid, 'delete(''%s'');\n', temp_data.file_data); - fprintf(fid, 'delete(''%s'');\n', temp_script.file_data); + fprintf(fid, 'delete(''%s'');\n', temp_data_path); + fprintf(fid, 'delete(''%s'');\n', temp_m_path); fclose(fid); - job_name = hpc_job_name(parameters, 'tusim_tp', subject_id); + parameters.job_prefix = 'TP'; + job_name = hpc_job_name(parameters, subject_id); job_id = hpc_submit_job(platform, temp_m_file, parameters, subject_id, log_dir); job_info = hpc_job_info(platform, job_id, job_name, subject_id, ... parameters.hpc_memorylimit, parameters.hpc_timelimit, log_dir, 1); From 609fbcbdba31453317b65a4e8bda6d985e741a03 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 12 Mar 2026 17:03:21 +0100 Subject: [PATCH 109/144] [hotfix] fix PRESTUS path loading --- functions/core/prestus_pipeline_start.m | 4 ++-- functions/hpc/hpc_matlab_pipeline.m | 14 ++++++++------ functions/hpc/hpc_setup_temp_files.m | 8 ++++---- .../transducer/transducer_positioning_start.m | 2 +- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index 7c8ae18f..02b88fc9 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -41,12 +41,12 @@ function prestus_pipeline_start(subject_id, parameters, options) case {'slurm', 'qsub'} % ========== HPC EXECUTION ========== hpc_validate_parameters(parameters, platform); - [log_dir, path_to_pipeline, temp_data_path, temp_m_path, temp_m_file] = ... + [log_dir, prestus_path, temp_data_path, temp_m_path, temp_m_file] = ... hpc_setup_temp_files(parameters, subject_id); % Create job files save(temp_data_path, 'subject_id', 'parameters'); - hpc_matlab_pipeline(temp_m_path, temp_data_path, path_to_pipeline, options); + hpc_matlab_pipeline(temp_m_path, temp_data_path, prestus_path, options); % Job name job_name = hpc_job_name(parameters, subject_id); diff --git a/functions/hpc/hpc_matlab_pipeline.m b/functions/hpc/hpc_matlab_pipeline.m index bf22fec9..b7980983 100644 --- a/functions/hpc/hpc_matlab_pipeline.m +++ b/functions/hpc/hpc_matlab_pipeline.m @@ -1,4 +1,4 @@ -function hpc_matlab_pipeline(temp_m_path, temp_data_path, path_to_pipeline, options) +function hpc_matlab_pipeline(temp_m_path, temp_data_path, prestus_path, options) %% HPC_MATLAB_PIPELINE Generate temporary MATLAB batch script % % Creates self-deleting MATLAB batch script that loads parameters and runs @@ -7,20 +7,22 @@ function hpc_matlab_pipeline(temp_m_path, temp_data_path, path_to_pipeline, opti % Inputs: % temp_m_path - Path for output .m script % temp_data_path - Path to .mat parameter file -% path_to_pipeline - Directory containing prestus_pipeline.m +% prestus_path - Directory containing prestus_pipeline.m % options - Optional struct with sequential_configs field % % See also HPC_SETUP_TEMP_FILES, HPC_SUBMIT_JOB. 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, "load '%s'; cd '%s'; prestus_pipeline(subject_id, parameters, options); delete '%s'; delete '%s';", ... - temp_data_path, path_to_pipeline, temp_data_path, temp_m_path); + fprintf(fid, 'prestus_pipeline(subject_id, parameters, options);\n'); else - fprintf(fid, "load '%s'; cd '%s'; prestus_pipeline(subject_id, parameters); delete '%s'; delete '%s';", ... - temp_data_path, path_to_pipeline, temp_data_path, temp_m_path); + fprintf(fid, 'prestus_pipeline(subject_id, parameters);\n'); end +fprintf(fid, 'delete(''%s'');\n', temp_data_path); +fprintf(fid, 'delete(''%s'');\n', temp_m_path); fclose(fid); end \ No newline at end of file diff --git a/functions/hpc/hpc_setup_temp_files.m b/functions/hpc/hpc_setup_temp_files.m index 610ffd3b..cf740e83 100644 --- a/functions/hpc/hpc_setup_temp_files.m +++ b/functions/hpc/hpc_setup_temp_files.m @@ -1,4 +1,4 @@ -function [log_dir, path_to_pipeline, temp_data_path, temp_m_path, temp_m_file] = ... +function [log_dir, prestus_path, temp_data_path, temp_m_path, temp_m_file] = ... hpc_setup_temp_files(parameters, subject_id) %% HPC_SETUP_TEMP_FILES Setup directories and generate temporary files % @@ -6,8 +6,8 @@ % for MATLAB data and script. % % Outputs: -% log_dir - Path to batch_job_logs directory -% path_to_pipeline - Directory containing prestus_pipeline.m +% 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) @@ -26,7 +26,7 @@ log_dir = fullfile(output_dir, 'batch_job_logs'); if ~isfolder(log_dir), mkdir(log_dir); end -[path_to_pipeline, ~, ~] = fileparts(which('prestus_pipeline')); +prestus_path = get_prestus_path; % Generate temp files timestamp = datestr(now, 'yyyymmdd_HHMMSS'); diff --git a/functions/transducer/transducer_positioning_start.m b/functions/transducer/transducer_positioning_start.m index 32ca47ca..aea45b3a 100644 --- a/functions/transducer/transducer_positioning_start.m +++ b/functions/transducer/transducer_positioning_start.m @@ -35,7 +35,7 @@ function transducer_positioning_start(subject_id, parameters, pn, target_name, m fid = fopen(temp_m_path, 'w'); fprintf(fid, 'load(''%s'');\n', temp_data_path); - fprintf(fid, 'cd(''%s'');\n', path_to_pipeline); + fprintf(fid, 'addpath(genpath(''%s''));\n', path_to_pipeline); fprintf(fid, 'transducer_positioning(parameters, pn, subject_id, target_name, mni_targets);\n'); fprintf(fid, 'delete(''%s'');\n', temp_data_path); fprintf(fid, 'delete(''%s'');\n', temp_m_path); From 06847b2de3f1c9f49cf97749e75bc21f301e61e6 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 12 Mar 2026 17:42:48 +0100 Subject: [PATCH 110/144] [tp] reorder steps, idenitfy missing plot inputs --- .../transducer/tp_plot_heuristic_transducer.m | 57 ++++++---- functions/transducer/transducer_positioning.m | 106 +++++++++--------- .../transducer/transducer_positioning_start.m | 4 +- 3 files changed, 96 insertions(+), 71 deletions(-) diff --git a/functions/transducer/tp_plot_heuristic_transducer.m b/functions/transducer/tp_plot_heuristic_transducer.m index ffb27b20..ba776689 100644 --- a/functions/transducer/tp_plot_heuristic_transducer.m +++ b/functions/transducer/tp_plot_heuristic_transducer.m @@ -1,33 +1,67 @@ -function tp_plot_heuristic_transducer(tpos_pars, img_orig, img_info, parameters, pixel_size, target_name, subject_id) +function tp_plot_heuristic_transducer(tpos_pars, img, img_info, parameters, pixel_size, target_name, subject_id) % TP_PLOT_HEURISTIC_TRANSDUCER Create visualization of heuristic transducer placement % % Selects heuristic transducer position based on intersection, skull/skin distance criteria. Creates comprehensive % 6-panel figure showing aligned anatomy, geometric properties, 3D rendering, and validation plots. % % INPUT -% tpos_pars - Table from tp_evaluate_candidate_positions_gpu() -% img_orig - Original segmented head image +% tpos_pars - Table from tp_evaluate_candidate_positions +% img - Segmented head image % img_info - NIfTI header info % parameters - Parameters struct % pixel_size - Scalar voxel size in mm % target_name - String target identifier % subject_id - Scalar subject ID % +% Missing: +% tp_struct.outer_sphere_3d % Skull intersection sphere (from `tp_find_candidate_positions`) +% tp_struct.segm_img_slice % 2D visualization slice with overlays +% tp_struct.target % Original target position [x y z] (vs `target_xyz` from table) +% tp_struct.coord_mesh.xyz % Flattened voxel coordinates +% tp_struct.norm_v % Unit normal vectors for all candidates +% tp_struct.d % Plane equation constant +% tp_struct.max_od_grid % Aperture diameter in voxels +% tp_struct.ex_plane_pos_all % Exit plane positions array +% tp_struct.shifted_trans_pos_coords % Candidate transducer positions +% tp_struct.skin_boundary % Skin surface mask +% % OUTPUT % Saves: sub-XXX_optimal_TARGET.png +% collect inputs from passing structure + +outer_sphere_3d = tp_struct.outer_sphere_3d; +segm_img_slice = tp_struct.segm_img_slice; +target = tp_struct.target; +coord_mesh.xyz = tp_struct.coord_mesh.xyz; +norm_v = tp_struct.norm_v; +d = tp_struct.d; +max_od_grid = tp_struct.max_od_grid; +ex_plane_pos_all = tp_struct.ex_plane_pos_all; +shifted_trans_pos_coords = tp_struct.shifted_trans_pos_coords; +skin_boundary = tp_struct.skin_boundary; + +geom_focus_pos_all = shifted_trans_pos_coords - norm_v*(parameters.transducer.curv_radius_mm/pixel_size); + +sz = size(img); +[t1_x, t1_y, t1_z] = ndgrid(1:sz(1), 1:sz(2), 1:sz(3)); % Full volume voxel coordinates + % Select optimal position (original multi-step criteria) 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 = 1; % First optimal candidate + target_xyz = [tppf.targ_x(i), tppf.targ_y(i), tppf.targ_z(i)]; trans_xyz = [tppf.trans_x(i), tppf.trans_y(i), tppf.trans_z(i)]; +ex_pl_xyz = gather(ex_plane_pos_all(i,:)); +gf_xyz = gather(geom_focus_pos_all(i,:)); + % Align to focal axis (original preprocessing) [rotated_img, trans_xyz, target_xyz, transformation_matrix, ~, ~, ~, ~] = ... - preproc_align_to_focal_axis(img_orig, img_info, round(trans_xyz)', target_xyz', 1, parameters); + preproc_align_to_focal_axis(img, img_info, round(trans_xyz)', target_xyz', 1, parameters); TF = maketform('affine', transformation_matrix); % 6-panel comprehensive visualization @@ -86,7 +120,6 @@ function tp_plot_heuristic_transducer(tpos_pars, img_orig, img_info, parameters, 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)); @@ -98,7 +131,6 @@ function tp_plot_heuristic_transducer(tpos_pars, img_orig, img_info, parameters, 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); @@ -108,18 +140,6 @@ function tp_plot_heuristic_transducer(tpos_pars, img_orig, img_info, parameters, 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; @@ -127,7 +147,6 @@ function tp_plot_heuristic_transducer(tpos_pars, img_orig, img_info, parameters, Ds = smooth3(gpuArray(double(segmented_img_to_plot>0))); skin_isosurface = isosurface(Ds,0.5); - %figure colormap(gray(80)) hiso = patch(skin_isosurface,... diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index 3d3872dd..b62c4425 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -16,73 +16,79 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar [parameters] = path_log_setup(parameters, get_prestus_path, subject_id); -%% 2. LOAD SEGMENTATION DATA - -headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); -filename = fullfile(headreco_folder, 'final_tissues.nii.gz'); -seg_img = niftiread(filename); -seg_img_header = niftiinfo(filename); -pixel_size = mean(seg_img_header.PixelDimensions); -im_center = round(size(seg_img)/2); - -% [DEBUG] plot the segmentation -if parameters.debug - h = figure; - montage({rot90(squeeze(seg_img(im_center(1),:,:))), ... - rot90(squeeze(seg_img(:,im_center(2),:))), ... - squeeze(seg_img(:,:,im_center(3)))}, ... - viridis(8), 'Size', [1 3]); - saveas(h, fullfile(parameters.debug_dir, ... - sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); - close(h); -end +% specify output file +tpos_output_file = fullfile(parameters.output_dir, ... + sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); -%% 3. Convert target from MNI (mm) to subject grid space (voxels) +if confirm_overwriting(tpos_output_file, parameters) -fprintf('➤ Target: %s\n', target_name); -target_mni = mni_targets.(target_name); -target_vox = map_coordsystems(... - parameters, target_mni, 'mni', 'grid', seg_img_header); + %% 2. LOAD SEGMENTATION DATA -%% 5. Find candidate transducer positions on skull (expanding sphere) + headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); + filename = fullfile(headreco_folder, 'final_tissues.nii.gz'); + seg_img = niftiread(filename); + seg_img_header = niftiinfo(filename); + pixel_size = mean(seg_img_header.PixelDimensions); -[trans_candidate, outer_sphere_3d, img_slice, parameters] = ... - tp_find_initial_candidate(seg_img, target_vox, pixel_size, parameters); + % [DEBUG] plot the segmentation + if parameters.debug + h = figure; + im_center = round(size(seg_img)/2); + montage({rot90(squeeze(seg_img(im_center(1),:,:))), ... + rot90(squeeze(seg_img(:,im_center(2),:))), ... + squeeze(seg_img(:,:,im_center(3)))}, ... + viridis(8), 'Size', [1 3]); + saveas(h, fullfile(parameters.debug_dir, ... + sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); + close(h); + clear im_center; + end -%% 6. Plot initial candidate + %% 3. Convert target from MNI (mm) to subject grid space (voxels) -tp_plot_candidate_positions(... - img_slice, target_vox, trans_candidate, ... - pixel_size, parameters, subject_id, target_name); + fprintf('➤ Target: %s\n', target_name); + target_mni = mni_targets.(target_name); + target_vox = map_coordsystems(... + parameters, target_mni, 'mni', 'grid', seg_img_header); -%% 7. Plot geometry + %% 5. Find candidate transducer positions on skull (expanding sphere) -tp_plot_geometry_overlay(seg_img, target_vox, trans_candidate.trans_pos, ... - pixel_size, parameters, subject_id, target_name, outer_sphere_3d); + [trans_candidate, outer_sphere_3d, img_slice, parameters] = ... + tp_find_initial_candidate(seg_img, target_vox, pixel_size, parameters); -%% 8. Evaluate candidate according to criteria + %% 6. Plot initial candidate -tpos_pars = tp_evaluate_candidate_positions(... - seg_img, target_vox, parameters, pixel_size); + tp_plot_candidate_positions(... + img_slice, target_vox, trans_candidate, ... + pixel_size, parameters, subject_id, target_name); -%% 9. Plot heuristic transducer placement + %% 7. Plot geometry -tp_plot_heuristic_transducer(... - tpos_pars, seg_img, seg_img_header, ... - parameters, pixel_size, target_name, subject_id); + tp_plot_geometry_overlay(seg_img, target_vox, trans_candidate.trans_pos, ... + pixel_size, parameters, subject_id, target_name, outer_sphere_3d); -%% 10. Save results table + %% 8. Evaluate candidate according to criteria -tpos_output_file = fullfile(parameters.output_dir, ... - sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); + tpos_pars = tp_evaluate_candidate_positions(... + seg_img, target_vox, parameters, pixel_size); -writetable(tpos_pars, tpos_output_file, 'Delimiter', ','); + %% 9. Save results table -fprintf('Heuristic transducer placement: %s (sub-%03d) → %s\n', ... - target_name, subject_id, tpos_output_file); + writetable(tpos_pars, tpos_output_file, 'Delimiter', ','); -%% 11. [Optional] Convert positions to RAS & MNI + %% 10. [Optional] Convert positions to RAS & MNI -% TO DO + % TO DO + +end + +%% Plot heuristic transducer placement + +tp_plot_heuristic_transducer(... + tpos_pars, seg_img, seg_img_header, ... + parameters, pixel_size, target_name, subject_id); + +fprintf('Heuristic transducer placement: %s (sub-%03d) → %s\n', ... + target_name, subject_id, tpos_output_file); end diff --git a/functions/transducer/transducer_positioning_start.m b/functions/transducer/transducer_positioning_start.m index aea45b3a..f7bdab29 100644 --- a/functions/transducer/transducer_positioning_start.m +++ b/functions/transducer/transducer_positioning_start.m @@ -27,7 +27,7 @@ function transducer_positioning_start(subject_id, parameters, pn, target_name, m % ========== HPC EXECUTION ========== hpc_validate_parameters(parameters, platform) - [log_dir, path_to_pipeline, temp_data_path, temp_m_path, temp_m_file] = ... + [log_dir, prestus_path, temp_data_path, temp_m_path, temp_m_file] = ... hpc_setup_temp_files(parameters, subject_id); % populate temp data @@ -35,7 +35,7 @@ function transducer_positioning_start(subject_id, parameters, pn, target_name, m fid = fopen(temp_m_path, 'w'); fprintf(fid, 'load(''%s'');\n', temp_data_path); - fprintf(fid, 'addpath(genpath(''%s''));\n', path_to_pipeline); + fprintf(fid, 'addpath(genpath(''%s''));\n', prestus_path); fprintf(fid, 'transducer_positioning(parameters, pn, subject_id, target_name, mni_targets);\n'); fprintf(fid, 'delete(''%s'');\n', temp_data_path); fprintf(fid, 'delete(''%s'');\n', temp_m_path); From 53f202b0c49f9925b7c6768c6abd6f74b0e41131 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 13 Mar 2026 12:12:56 +0100 Subject: [PATCH 111/144] [tp] further refactoring, add ear exclusion --- functions/transducer/tp_candidate_mesh.m | 95 +++++++ .../tp_evaluate_candidate_positions.m | 86 ++---- .../transducer/tp_find_initial_candidate.m | 4 +- .../transducer/tp_plot_candidate_positions.m | 9 +- .../transducer/tp_plot_heuristic_transducer.m | 261 ++++++------------ .../transducer/tp_remove_ear_locations.m | 37 +++ .../transducer/tp_select_heuristic_position.m | 68 +++++ functions/transducer/transducer_positioning.m | 78 +++--- 8 files changed, 366 insertions(+), 272 deletions(-) create mode 100644 functions/transducer/tp_candidate_mesh.m create mode 100644 functions/transducer/tp_remove_ear_locations.m create mode 100644 functions/transducer/tp_select_heuristic_position.m diff --git a/functions/transducer/tp_candidate_mesh.m b/functions/transducer/tp_candidate_mesh.m new file mode 100644 index 00000000..6adae398 --- /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.tp_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 index 05175aec..7654f7f3 100644 --- a/functions/transducer/tp_evaluate_candidate_positions.m +++ b/functions/transducer/tp_evaluate_candidate_positions.m @@ -1,4 +1,4 @@ -function tpos_pars = tp_evaluate_candidate_positions(img, target, parameters, pixel_size) +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) @@ -18,68 +18,38 @@ % 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 positions (fraction, skin/skull distances, and variance) ...") +disp("[TP] Evaluating candidate position via intersection of skull with expanding sphere ...") -sz = size(img); -[t1_x, t1_y, t1_z] = ndgrid(1:sz(1), 1:sz(2), 1:sz(3)); % Full volume voxel coordinates +%--- Build candidate mesh and candidate geometry --------------------------------- +mesh = tp_candidate_mesh(img, target, parameters, pixel_size); -% GPU coordinate mesh (vectorized for parallel evaluation) -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 - -%% Extract tissue boundaries for distance calculations -all_masks = img > 0; % Brain+skull+skin -outer_boundary = imdilate(all_masks, strel('sphere',1)) - all_masks; % Exterior surface (air interface) -skin_boundary = all_masks - imerode(all_masks, strel('sphere',1)); % Skin surface gradient -skin_coords = gpuArray(coord_mesh.xyz(find(skin_boundary),:)); % Skin surface points - -% Skull boundary (compact bone exterior) -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'); % Fill inner skull cavity -skull_fill = imerode(skull_fill, strel('sphere',1)); -skull_boundary = skull & ~skull_fill; % Outer compact bone surface -skull_coords = gpuArray(coord_mesh.xyz(find(skull_boundary),:)); - -%% Filter candidate positions: outer surface within tp_dist_close of 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.tp_dist_close/pixel_size)); - -%% GPU vectorized transducer geometry for all candidates -trans_pos_coords = coord_mesh.xyz(close_enough_idx,:); % N_candidates x 3 -norm_v = gpuArray((trans_pos_coords-target) ./ repmat(sqrt(sum((trans_pos_coords-target).^2,2)), [1, 3])); % Unit vectors - -% Physical → voxel geometry conversion -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); % Sagitta/2 -dist_tp_to_ep_mm = parameters.transducer.curv_radius_mm - dist_gf_to_ep_mm; % Transducer-to-exit distance - -% Shift positions forward along ray by 5mm + transducer-to-exit distance -pos_shift_mm = 5 + dist_tp_to_ep_mm; -shifted_trans_pos_coords = trans_pos_coords + norm_v*(pos_shift_mm/pixel_size); - -% Compute geometric focus and exit plane for all candidates -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(img>0)); -max_od_grid = max_od_mm / 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; %% 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_pos_all, coord_mesh, ... - all_masks_indx_gpu, skin_coords, skull_coords, max_od_grid), 1:length(close_enough_idx)); + 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) -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"]); +shifted_trans_pos_coords = gather(mesh.trans_pos); % [N_cand x 3] CPU for table -end +%% Assemble results table (CPU) +metrics = [prop_intersect; mean_dts; var_dts; mean_dist_skull; var_dist_skull]'; +tpos_pars = array2table([close_enough_idx, shifted_trans_pos_coords, ... + repmat(target, N_cand, 1), ... + pdist2(shifted_trans_pos_coords, 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 index 1af3b83f..2396d7e3 100644 --- a/functions/transducer/tp_find_initial_candidate.m +++ b/functions/transducer/tp_find_initial_candidate.m @@ -1,4 +1,4 @@ -function [trans_candidate, outer_sphere_3d, img_slice, parameters] = ... +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 @@ -16,7 +16,7 @@ % OUTPUT % trans_candidate - Initial random transducer position [x,y,z] % outer_sphere_3d - 3D logical mask of valid transducer positions on skull surface -% img_slice - RGB slice through target(y) with search sphere highlighted +% parameters - Parameters with updated min_focal_distance_mm disp("[TP] Finding candidate position via intersection of skull with expanding sphere ...") diff --git a/functions/transducer/tp_plot_candidate_positions.m b/functions/transducer/tp_plot_candidate_positions.m index 16cc0947..9883d6d5 100644 --- a/functions/transducer/tp_plot_candidate_positions.m +++ b/functions/transducer/tp_plot_candidate_positions.m @@ -1,4 +1,4 @@ -function tp_plot_candidate_positions(segm_img_slice, target, trans_candidate, ... +function tp_plot_candidate_positions(target, trans_candidate, ... pixel_size, parameters, subject_id, target_name) % TP_PLOT_CANDIDATE_POSITIONS Plot transducer candidate positions on skull surface slice % @@ -6,7 +6,6 @@ function tp_plot_candidate_positions(segm_img_slice, target, trans_candidate, .. % and search sphere intersection with skull surface. % % INPUT - % segm_img_slice - RGB slice through target(y) with sphere highlighted % target - 1x3 target coordinates [x,y,z] % trans_candidate - Structure with info on transducer candidate % pixel_size - Voxel size (mm) @@ -20,10 +19,14 @@ function tp_plot_candidate_positions(segm_img_slice, target, trans_candidate, .. 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(segm_img_slice); axis image; hold on; + imagesc(img_slice); axis image; hold on; % Target (red rectangle) target_xz = target([1,3]); diff --git a/functions/transducer/tp_plot_heuristic_transducer.m b/functions/transducer/tp_plot_heuristic_transducer.m index ba776689..13233544 100644 --- a/functions/transducer/tp_plot_heuristic_transducer.m +++ b/functions/transducer/tp_plot_heuristic_transducer.m @@ -1,199 +1,112 @@ function tp_plot_heuristic_transducer(tpos_pars, img, img_info, parameters, pixel_size, target_name, subject_id) % TP_PLOT_HEURISTIC_TRANSDUCER Create visualization of heuristic transducer placement % -% Selects heuristic transducer position based on intersection, skull/skin distance criteria. Creates comprehensive -% 6-panel figure showing aligned anatomy, geometric properties, 3D rendering, and validation plots. -% -% INPUT -% tpos_pars - Table from tp_evaluate_candidate_positions -% img - Segmented head image -% img_info - NIfTI header info -% parameters - Parameters struct -% pixel_size - Scalar voxel size in mm -% target_name - String target identifier -% subject_id - Scalar subject ID -% -% Missing: -% tp_struct.outer_sphere_3d % Skull intersection sphere (from `tp_find_candidate_positions`) -% tp_struct.segm_img_slice % 2D visualization slice with overlays -% tp_struct.target % Original target position [x y z] (vs `target_xyz` from table) -% tp_struct.coord_mesh.xyz % Flattened voxel coordinates -% tp_struct.norm_v % Unit normal vectors for all candidates -% tp_struct.d % Plane equation constant -% tp_struct.max_od_grid % Aperture diameter in voxels -% tp_struct.ex_plane_pos_all % Exit plane positions array -% tp_struct.shifted_trans_pos_coords % Candidate transducer positions -% tp_struct.skin_boundary % Skin surface mask +% INPUT (7 required + 1 optional) +% tpos_pars - Table from tp_evaluate_candidate_positions +% img - Segmented head image +% img_info - NIfTI header info +% parameters - Parameters struct +% pixel_size - Scalar voxel size in mm +% target_name - String target identifier +% subject_id - Scalar subject ID +% mesh - [OPTIONAL LAST] Full mesh structure from tp_candidate_mesh() % % OUTPUT % Saves: sub-XXX_optimal_TARGET.png -% collect inputs from passing structure - -outer_sphere_3d = tp_struct.outer_sphere_3d; -segm_img_slice = tp_struct.segm_img_slice; -target = tp_struct.target; -coord_mesh.xyz = tp_struct.coord_mesh.xyz; -norm_v = tp_struct.norm_v; -d = tp_struct.d; -max_od_grid = tp_struct.max_od_grid; -ex_plane_pos_all = tp_struct.ex_plane_pos_all; -shifted_trans_pos_coords = tp_struct.shifted_trans_pos_coords; -skin_boundary = tp_struct.skin_boundary; - -geom_focus_pos_all = shifted_trans_pos_coords - norm_v*(parameters.transducer.curv_radius_mm/pixel_size); - -sz = size(img); -[t1_x, t1_y, t1_z] = ndgrid(1:sz(1), 1:sz(2), 1:sz(3)); % Full volume voxel coordinates - -% Select optimal position (original multi-step criteria) +%% Select optimal position (multi-criteria heuristic) 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 = 1; % First optimal candidate +i = 1; target_xyz = [tppf.targ_x(i), tppf.targ_y(i), tppf.targ_z(i)]; trans_xyz = [tppf.trans_x(i), tppf.trans_y(i), tppf.trans_z(i)]; -ex_pl_xyz = gather(ex_plane_pos_all(i,:)); -gf_xyz = gather(geom_focus_pos_all(i,:)); +%% Recompute mesh variables -% Align to focal axis (original preprocessing) -[rotated_img, trans_xyz, target_xyz, transformation_matrix, ~, ~, ~, ~] = ... - preproc_align_to_focal_axis(img, img_info, round(trans_xyz)', target_xyz', 1, parameters); -TF = maketform('affine', transformation_matrix); +mesh = tp_candidate_mesh(img, target_xyz, parameters, pixel_size); -% 6-panel comprehensive visualization -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, [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); - 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; - 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); - 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]); +coord_mesh.xyz = gather(mesh.coord_mesh.xyz); % struct with field xyz +ex_plane = gather(mesh.ex_plane); % [N_cand x 3] exit plane centers +max_od_grid = gather(mesh.max_od_grid); % scalar, aperture diameter in voxels +geom_focus = gather(mesh.geom_focus); % [N_cand x 3] geometric focus positions - rectangle('Position',[flip(target_xz) - 2, 4, 4],... - 'Curvature',[0,0], 'EdgeColor','r',... - 'LineWidth',2,'LineStyle','-'); +ex_pl_xyz = ex_plane(i,:); +gf_xyz = round(geom_focus(i,:)); - outer_idx = find(outer_sphere_3d&t1_y==target(2)); +%% Align to focal axis - 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','-'); +[rotated_img, trans_xyz, target_xyz, transformation_matrix, ~, ~, ~, ~] = ... + preproc_align_to_focal_axis(... + img, img_info, round(trans_xyz)', target_xyz', 1, parameters); +TF = maketform('affine', transformation_matrix); - get_transducer_box(trans_xz, target_xz, pixel_size, parameters); +ex_pl_xyz = round(tformfwd([ex_pl_xyz]', TF)); +gf_xyz = round(tformfwd([gf_xyz]', TF)); + +%% 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(rotated_img(:,round(trans_xyz(2)),:))); + + % TARGET - Red box + rectangle('Position', [target_xyz([3,1]) - 2, 4, 4], ... + 'Curvature', [0,0], ... + 'EdgeColor', 'r', ... + 'LineWidth', 2, 'LineStyle', '-'); + + % TRANSDUCER (heuristic position) - Blue box + rectangle('Position', [trans_xyz([3,1]) - 2, 4, 4],... + 'Curvature', [0,0],... + 'EdgeColor', 'b', ... + 'LineWidth', 2, 'LineStyle', '-'); + + % GEOMETRIC FOCUS (bowl sphere center) - Yellow box + rectangle('Position', [gf_xyz([3,1]) - 2, 4, 4],... + 'Curvature', [0,0],... + 'EdgeColor', 'yellow',... + 'LineWidth', 2, 'LineStyle', '-'); + + % EXIT PLANE (transducer aperture center) - White box + 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(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]) - output_plot = fullfile(parameters.output_dir,sprintf('sub-%03d_optimal_%s.png', subject_id, target_name)); + output_plot = fullfile(parameters.output_dir,sprintf('sub-%03d_heuristic_%s.png', subject_id, target_name)); saveas(h, output_plot, 'png') 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..2b3087fe --- /dev/null +++ b/functions/transducer/tp_remove_ear_locations.m @@ -0,0 +1,37 @@ +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, {'tp_ear_radius', 'tp_left_ear_center', 'tp_right_ear_center'}) + % Validate complete specification + if ~isempty(parameters.tp_ear_radius) && ~isempty(parameters.tp_left_ear_center) && ~isempty(parameters.tp_right_ear_center) + + % Define nogo zone spheres + ear_radius = parameters.tp_ear_radius; + left_ear_center = parameters.tp_left_ear_center; + right_ear_center = parameters.tp_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..2ffbf535 --- /dev/null +++ b/functions/transducer/tp_select_heuristic_position.m @@ -0,0 +1,68 @@ +function [best_trans_pos, trans_pos, target_pos, table_path] = ... + tp_select_heuristic_position(locs, pixel_size, subject_id, target_name, rootpath) +%% 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) +% pixel_size - Scalar voxel size (mm/voxel) +% subject_id - Scalar subject ID +% target_name - String target identifier (e.g. 'right_PUL') +% rootpath - Root output directory +% +% OUTPUT +% best_trans_pos - Full table row of HEURISTIC position with added Localite fields +% trans_pos - [1x3] integer voxel coordinates [x y z] +% target_pos - [1x3] integer voxel coordinates [x y z] +% table_path - Full path to saved Localite .txt file + +%% Multi-step HEURISTIC selection with adaptive intersection criterion +criterion_intersection = 0.05; +tppf = locs(locs.prop_intersect < criterion_intersection, :); + +% Auto-expand criterion if no candidates found +if isempty(tppf) + criterion_intersection = criterion_intersection + 0.01; + warning('Expanding intersection criterion to %.2f (no candidates at %.2f)', ... + criterion_intersection, criterion_intersection - 0.01); + tppf = locs(locs.prop_intersect < criterion_intersection, :); +end + +% Select minimum distance to target +tppf = tppf(tppf.dist_to_target == min(tppf.dist_to_target), :); +i = find(locs.idx == tppf.idx(1)); % Global index in original locs table + +%% Extract HEURISTIC position +best_trans_pos = locs(i, :); +trans_pos = floor(table2array(best_trans_pos(1, ["trans_x", "trans_y", "trans_z"]))); +target_pos = floor(table2array(best_trans_pos(1, ["targ_x", "targ_y", "targ_z"]))); + +%% Convert to Localite physical coordinates (mm) +trans_pos_localite = round(trans_pos * pixel_size); +target_pos_localite = round(target_pos * pixel_size); + +%% 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.loc_trans_x = trans_pos_localite(1); +best_trans_pos.loc_trans_y = trans_pos_localite(2); +best_trans_pos.loc_trans_z = trans_pos_localite(3); + +best_trans_pos.loc_targ_x = target_pos_localite(1); +best_trans_pos.loc_targ_y = target_pos_localite(2); +best_trans_pos.loc_targ_z = target_pos_localite(3); + +%% Export Localite-compatible table +table_path = fullfile(rootpath, 'data', 'localite', sprintf('sub-%03.0f', subject_id), ... + sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); + +writetable(best_trans_pos, table_path, 'Delimiter', 'tab'); +fprintf('[TP_HEURISTIC] Saved heuristic position: %s\n', table_path); + +end \ No newline at end of file diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index b62c4425..ba19fcf4 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -16,77 +16,85 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar [parameters] = path_log_setup(parameters, get_prestus_path, subject_id); +%% 2. LOAD SEGMENTATION DATA + +headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); +filename = fullfile(headreco_folder, 'final_tissues.nii.gz'); +img = niftiread(filename); +img_info = niftiinfo(filename); +pixel_size = mean(img_info.PixelDimensions); + +% [DEBUG] plot the segmentation +if parameters.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.debug_dir, ... + sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); + close(h); + clear im_center; +end + +%% heuristic positioning + % specify output file tpos_output_file = fullfile(parameters.output_dir, ... sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); if confirm_overwriting(tpos_output_file, parameters) - %% 2. LOAD SEGMENTATION DATA - - headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); - filename = fullfile(headreco_folder, 'final_tissues.nii.gz'); - seg_img = niftiread(filename); - seg_img_header = niftiinfo(filename); - pixel_size = mean(seg_img_header.PixelDimensions); - - % [DEBUG] plot the segmentation - if parameters.debug - h = figure; - im_center = round(size(seg_img)/2); - montage({rot90(squeeze(seg_img(im_center(1),:,:))), ... - rot90(squeeze(seg_img(:,im_center(2),:))), ... - squeeze(seg_img(:,:,im_center(3)))}, ... - viridis(8), 'Size', [1 3]); - saveas(h, fullfile(parameters.debug_dir, ... - sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); - close(h); - clear im_center; - end - %% 3. Convert target from MNI (mm) to subject grid space (voxels) fprintf('➤ Target: %s\n', target_name); target_mni = mni_targets.(target_name); target_vox = map_coordsystems(... - parameters, target_mni, 'mni', 'grid', seg_img_header); + parameters, target_mni, 'mni', 'grid', img_info); %% 5. Find candidate transducer positions on skull (expanding sphere) - [trans_candidate, outer_sphere_3d, img_slice, parameters] = ... - tp_find_initial_candidate(seg_img, target_vox, pixel_size, parameters); + [trans_candidate, outer_sphere_3d, parameters] = ... + tp_find_initial_candidate(img, target_vox, pixel_size, parameters); %% 6. Plot initial candidate tp_plot_candidate_positions(... - img_slice, target_vox, trans_candidate, ... + target_vox, trans_candidate, ... pixel_size, parameters, subject_id, target_name); %% 7. Plot geometry - tp_plot_geometry_overlay(seg_img, target_vox, trans_candidate.trans_pos, ... + tp_plot_geometry_overlay(img, target_vox, trans_candidate.trans_pos, ... pixel_size, parameters, subject_id, target_name, outer_sphere_3d); %% 8. Evaluate candidate according to criteria - tpos_pars = tp_evaluate_candidate_positions(... - seg_img, target_vox, parameters, pixel_size); + tpos = tp_evaluate_candidate_positions(... + img, target_vox, parameters, pixel_size); %% 9. Save results table - writetable(tpos_pars, tpos_output_file, 'Delimiter', ','); + writetable(tpos, tpos_output_file, 'Delimiter', ','); - %% 10. [Optional] Convert positions to RAS & MNI +else + disp('Skipping positioning, loading existing output file...') + tpos = readtable(tpos_output_file, 'Delimiter', ','); +end - % TO DO +%% [Optional] Convert positions to RAS & MNI -end +% TO DO + +%% [Optional] Remove ear locations + +function [tpos] = tp_remove_ear_locations(parameters, tpos); %% Plot heuristic transducer placement tp_plot_heuristic_transducer(... - tpos_pars, seg_img, seg_img_header, ... - parameters, pixel_size, target_name, subject_id); + tpos, img, img_info, parameters, pixel_size, target_name, subject_id); fprintf('Heuristic transducer placement: %s (sub-%03d) → %s\n', ... target_name, subject_id, tpos_output_file); From afc09ade668945206c06ef77164953ecf9ce49d5 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 13 Mar 2026 14:05:36 +0100 Subject: [PATCH 112/144] [heuristic feature, refactor, doc] added ear removal to heuristic, localite T1 output --- documentation/doc_functions.md | 318 +++++++++++------- documentation/doc_parameters.md | 16 +- functions/core/path_log_setup.m | 12 +- functions/core/prestus_pipeline_start.m | 17 +- functions/hpc/hpc_matlab_pipeline.m | 28 -- .../transducer/tp_plot_heuristic_position.m | 90 +++++ .../transducer/tp_plot_heuristic_transducer.m | 112 ------ .../transducer/tp_remove_ear_locations.m | 3 + .../transducer/tp_select_heuristic_position.m | 39 ++- functions/transducer/transducer_positioning.m | 53 ++- .../transform/canonical_affine_transform.m | 60 ++++ ...coordsystems.m => transform_coordinates.m} | 6 +- 12 files changed, 470 insertions(+), 284 deletions(-) delete mode 100644 functions/hpc/hpc_matlab_pipeline.m create mode 100644 functions/transducer/tp_plot_heuristic_position.m delete mode 100644 functions/transducer/tp_plot_heuristic_transducer.m create mode 100644 functions/transform/canonical_affine_transform.m rename functions/transform/{map_coordsystems.m => transform_coordinates.m} (88%) diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index cc3cb45d..85b83d72 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -2,118 +2,206 @@ The following documents the functions provided in PRESTUS. -| **Function Name** | **Category** | **Description** | -|-----------------------------------------|-----------------|------------------------------------------------------------------------------------------------| -| `acoustic_analysis` | ACOUSTIC | Compute acoustic safety metrics (Isppa, MI, max pressure) from sensor data, extract tissue-specific maxima, focal distances, and generate overlaid ISPPA plots on segmentation | -| `acoustic_convert_axisymmetry` | ACOUSTIC | Expand k-Wave axisymmetric (2D rotational) simulation outputs to full 3D (via  convert_axisymmetric_to_3d ) for heating sims or 2D Cartesian (via  convert_axisymmetric_to_2d ) otherwise, updating sensor data, parameters, masks, medium, grid, source, and labels. | -| `acoustic_simulation` | ACOUSTIC | Set up acoustic simulation | -| `acoustic_wrapper` | ACOUSTIC | Set up acoustic simulation | -| `calc_virtual_elem` | CALIBRATION | (STANDALONE) Calculate additional virtual elements | -| `calibration_transducer` | CALIBRATION | Run water simulations to optimize transducer source amplitude and element phases matching a target intensity profile via O’Neil analytical solution, global search, and re-simulation. | -| `compute_oneil_solution` | CALIBRATION | Compute analytical O’Neil pressure along the beam axis for focused annular transducers in water, derives particle velocity and grid adjustment factor, and plots comparisons with simulated/desired intensities. | -| `compute_phases` | CALIBRATION | Calculate per-element transducer phases (degrees) for electronic focusing by computing fractional wavelength delays to steer waves constructively to a target point relative to the exit plane. | -| `extract_real_intensity_profile` | CALIBRATION | Extract or spline-interpolate axial intensity profiles between available measured focal depths (wrt exit plane), aligns peaks, skips near-field for max, and plots/saves results. | -| `extract_simulated_profile` | CALIBRATION | Extract and visualize simulated acoustic pressure data. | -| `perform_global_search` | CALIBRATION | Globally optimize transducer element phases (rad) and particle velocity via FEXminimize or GlobalSearch to minimize error fitting O’Neil analytical intensity to measured axial profiles. | -| `phase_optimization_annulus_full_curve` | CALIBRATION | Optimizes the phase profile for an annular transducer by matching intensity curves. | -| `phase_optimization_annulus` | CALIBRATION | Optimizes the phase profile by calculating focal distance error for an annular transducer. | -| `plot_opt_sim_results` | CALIBRATION | Load optimized k-Wave results, generate 2D focal plane intensity maps and axial profile comparisons (simulated vs. analytical O’Neil vs. desired), and save annotated plots. | -| `recompute_oneil_solution` | CALIBRATION | Recompute O’Neil analytical pressure profile using optimized phases/velocity, convert to intensity, plot comparisons with original/desired profiles (skipping near-field), and report focal metrics. | -| `save_optimized_values` | CALIBRATION | Append optimized transducer phases and amplitudes into a CSV table (indexed by intensity/focus) and save parameters as YAML for PRESTUS config integration. | -| `scale_real_intensity_profile` | CALIBRATION | Linearly scale measured focal intensity profile and update transducer source amplitude (via acoustic impedance) to match desired peak Isppa (W/cm²). | -| `set_real_phases` | CALIBRATION | Load or compute manufacturer-specific (Sonic Concepts/Imasonic) transducer element phases for given focal depth (wrt exit plane), interpolate/unwrap for virtual elements, and return phase degrees. | -| `load_parameters` | CORE | Loads and merges configuration files for simulation parameters. | -| `path_log_setup` | CORE | Set up internal paths and logging, filename for output table | -| `simulation_nifti` | CORE | Save key outputs as 3D Niftis | -| `changem_vectorized` | GROUP | Replace multiple old values in array A with corresponding new values. | -| `combine_plots_by_suffix` | GROUP | Combines subject-specific plots into a single montage image. | -| `create_group_MNI_plots` | GROUP | Generate group-level plots in MNI space for multiple subjects. | -| `head_smooth_and_crop` | HEAD | Convert and smooth segmentations into medium map & crop grid for effiency | -| `preproc_medium_mask` | HEAD | Map segmentation indices onto the medium labels (in the config) [layered only] | -| `preproc_align_to_focal_axis` | HEAD | Rotate input image and its grid coordinates to the transducer-focal axis (+Z) | -| `preproc_crop_eCSF` | HEAD | Expand CSF to define outer edges of layered medium. | -| `preproc_crop_grid` | HEAD | Crop grid to head + transducer + PML. | -| `preproc_head` | HEAD | Preprocesses structural brain data for simulations (segmentation, alignment, cropping). | -| `preproc_segmentation` | HEAD | Setup SimNIBS segmentation | -| `segmentation_run` | HEAD | Submit SimNIBS segmentation | -| `skull_fill_holes` | HEAD | Fill holes in skull segmentation and between skull and skin | -| `skull_rubber_wrap_visualize` | HEAD | Visualize results of skull rubber expansion | -| `skull_rubber_wrap` | HEAD | Inflate the skull layer to locally fill potential holes | -| `smooth_img` | HEAD | Apply 3D smoothing | -| `check_availability` | HELPER | Check file availability. | -| `check_layers` | HELPER | Match requested layers to those available in the segmentation. For (p)CT, homogenize multi-skull layer into a single `skull `layer. | -| `confirm_overwriting` | HELPER | Check overwriting (manual). | -| `confirmation_dlg` | HELPER | Present confirmation dialogue. | -| `find_min_factor` | HELPER | Find the number with the smallest maximum factor in a range. | -| `get_crop_dims` | HELPER | Computes cropping dimensions for a 3D image with a margin. | -| `get_flhm_center_position` | HELPER | Calculates the center position of the full-length half-maximum (FLHM). | -| `get_slice_by_label` | HELPER | Extracts a specific slice from a 3D image based on axis label and slice number. | -| `get_xyz_mesh` | HELPER | Generates a mesh of 3D coordinates for a given image. | -| `getidx` | HELPER | Retrieves indices for requested tissues from a parameter structure. | -| `kwave_version` | HELPER | Display k-Wave version number and (if available) git hash. | -| `log_timer` | HELPER | Start or stop logs for benchmarking time, RAM, and disk space use. | -| `masked_max_3d` | HELPER | Computes the maximum intensity within a masked 3D region. | -| `mergeStructure` | HELPER | Merges multiple scalar structures into one. | -| `read_ini_file` | HELPER | Read an INI file into MATLAB | -| `round_if_integer` | HELPER | Rounds values if they are sufficiently close to integers; otherwise raises an error. | -| `simnibs_version` | HELPER | Get SimNIBS version of segmentation from HTML, print, allocate to `parameters`. | -| `subset_fields` | HELPER | Copy designated structure field to new structure. | -| `tissuemask_binary` | HELPER | Extract binary tissue segmentation masks (for indexing) | -| `upsample_to_grid` | HELPER | Upsamples a 3D image to a higher resolution grid. | -| `zip_fields` | HELPER | Convert a structure's fields and values into a cell array. | -| `transducer_positioning_with_qsub` | HPC | Submits transducer positioning jobs to Qsub cluster using batch scripts. | -| `transducer_positioning_with_slurm` | HPC | Submits transducer positioning jobs to SLURM cluster using batch scripts. | -| `fitPowerLawParamsMulti` | MEDIUM | Fit power law absorption parameters for highly absorbing media. | -| `get_alpha_coeff` | MEDIUM | Computes the amplitude attenuation coefficient for a given medium and frequency. | -| `medium_properties_nifti` | MEDIUM | Save NifTi image of the specified medium property map. | -| `medium_setup` | MEDIUM | Set up medium | -| `medium_pct_density` | MEDIUM | Skull: pCT-informed density mapping | -| `medium_pct_soundspeed` | MEDIUM | Skull: pCT-informed sound speed mapping | -| `medium_pct_attenuation` | MEDIUM | Skull: pCT-informed attenuation mapping | -| `neuronav_compute_series_statistics` | NEURONAV | Compute mean position and variability over stimulus train | -| `neuronav_convert_MNI_to_native` | NEURONAV | Transform coordinates from MNI space to native subject space | -| `neuronav_convert_native_to_MNI` | NEURONAV | Convert native RAS coordinates to MNI space | -| `neuronav_convert_trigger_to_voxels` | NEURONAV | Convert Localite trigger positions to voxel (image) coordinates. | -| `neuronav_export_session_csv` | NEURONAV | Export per-session coordinate arrays to CSV with labeled voxel and RAS (mm) positions. | -| `neuronav_get_group_mean_mni` | NEURONAV | Compute group-level average MNI coordinates (both mm and voxel). | -| `neuronav_select_localite` | NEURONAV | Select most recent Localite XML for a session. | -| `position_transducer_localite` | NEURONAV | Determines transducer and focus positions in voxel space using Localite data and MRI header. | -| `pct_create_pseudoCT` | PSEUDO-CT | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | -| `pct_skullmapping` | PSEUDO-CT | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. [deprecated, debug] | -| `pct_soft_tissue_peak` | PSEUDO-CT | Identifies the soft tissue peak from UTE intensity distribution histograms. | -| `fit_pairwiselinear` | PSEUDO-CT | Perform a pairwise linear fit between HU and density values with optional plot. | -| `plot_coronal_slices` | PLOT | (DEPRECATED) Visualizes coronal slices of a 3D image with optional legends for labeled images. | -| `plot_median_montage` | PLOT | (DEPRECATED) Creates a montage of central slices from a 3D T1 image. | -| `plot_overlay_2d` | PLOT | Overlays map on a background image slice with key positions highlighted. | -| `plot_overlay` | PLOT | Visualizes map overlaid on a 2D slice of a 3D background image with advanced options. | -| `plot_t1_with_transducer` | PLOT | Creates a plot of a T1 slice oriented along the transducer's axis with overlays. | -| `plot_transducer_overlay` | PLOT | Visualize curved transducer geometry with exit plane. | -| `show_3d_head` | PLOT | Visualizes segmented brain images in 3D with transducer placement and target location. | -| `show_positioning_plots` | PLOT | Visualizes transducer positioning before and after preprocessing using segmented images. | -| `grid_axisymmetry` | SOURCE | Convert grid to axisymmetry | -| `grid_tissue_setup` | SOURCE | Set up grid dimensions, preprocess head (if modeled), and place in grid | -| `grid_transducer_location` | SOURCE | Position transducer (and target) in simulation grid | -| `source_create` | SOURCE | Creates ultrasound source signals and masks for k-Wave simulations based on transducer geometry.| -| `source_sensor_setup` | SOURCE | Configure a k-Wave simulation grid, transducer sources, and sensor mask based on input parameters for recording pressure fields in 2D or 3D ultrasonic neuromodulation setups. | -| `thermal_analysis` | THERMAL | Analyze output of thermal simulations | -| `thermal_parameters` | THERMAL | Checks and converts protocol timing setup for thermal estimation | -| `thermal_plot_protocol` | THERMAL | Visualize the requested protocol timing | -| `thermal_plot_sim` | THERMAL | Visualizes heating simulation results over time (temperature, rise, CEM43). Optional video | -| `thermal_simulation` | THERMAL | Simulate ultrasound-induced heating and thermal dose (CEM43) using k-Wave’s kWaveDiffusion from acoustic pressure data across pulsed train repetitions. | -| `focal_distance_calculation` | TRANSDUCER | Compute expected focal distances for (multi-)transducer setup. | -| `get_arc` | TRANSDUCER | Generates the coordinates of an arc in 2D space. | -| `get_trans_pos_from_trigger_markers` | TRANSDUCER | Determines transducer and target positions using Localite trigger marker files. | -| `get_transducer_box` | TRANSDUCER | Computes transducer box dimensions and positions for simulations. | -| `transducer_analyze_position_fast` | TRANSDUCER | Analyzes the transducer position relative to the skull and skin. | -| `transducer_analyze_position` | TRANSDUCER | Computes geometric and statistical measures for a transducer position. | -| `transducer_positioning` | TRANSDUCER | Determines heuristic transducer placement for a given target. | -| `transducer_setup` | TRANSDUCER | Create a transducer mask and label matrix for a computational grid. | -| `convert_2d_to_axisymmetric` | TRANSFORM | Converts 2D k-Wave simulation grid, medium properties, and source to axisymmetric form by halving the shorter dimension (right half from center). | -| `convert_axisymmetric_to_2d` | TRANSFORM | Mirrors axisymmetric simulation data (sensor, medium, masks, source) left-right to full 2D, doubles radial dimension, transposes axes, and updates kgrid/positions. | -| `convert_axisymmetric_to_3d` | TRANSFORM | Expands 2D axisymmetric data (via radialExpand2DTo3D) to full 3D grid by duplicating radial dimension, updates positions/grid/kgrid for cubic symmetry. | -| `convert_final_to_MNI_matlab` | TRANSFORM | Converts an image from subject space to MNI space using MATLAB. | -| `convert_final_to_MNI_simnibs` | TRANSFORM | Converts an image to MNI space using SimNIBS. | -| `mni2subject_coords_LDfix` | TRANSFORM | Transforms a set of coordinates in MNI space to subject space. | -| `radialExpand2DTo3D` | TRANSFORM | Radially expands 2D axisymmetric data into 3D Cartesian volume. | -| `ras_to_grid` | TRANSFORM | Converts RAS coordinates to voxel (grid) coordinates using NIfTI header transformation matrix. | -| `subject2mni_coords_LDfix` | TRANSFORM | Transforms a set of coordinates in MNI space to subject space. | \ No newline at end of file +#### ACOUSTIC + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `acoustic_analysis` | Compute acoustic safety metrics (Isppa, MI, max pressure) from sensor data, extract tissue-specific maxima, focal distances, and generate overlaid ISPPA plots on segmentation | +| `acoustic_convert_axisymmetry` | Expand k-Wave axisymmetric (2D rotational) simulation outputs to full 3D (via  convert_axisymmetric_to_3d ) for heating sims or 2D Cartesian (via  convert_axisymmetric_to_2d ) otherwise, updating sensor data, parameters, masks, medium, grid, source, and labels. | +| `acoustic_simulation` | Set up acoustic simulation | +| `acoustic_wrapper` | Set up acoustic simulation | + +#### CALIBRATION + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `calc_virtual_elem` | (STANDALONE) Calculate additional virtual elements | +| `calibration_transducer` | Run water simulations to optimize transducer source amplitude and element phases matching a target intensity profile via O’Neil analytical solution, global search, and re-simulation. | +| `compute_oneil_solution` | Compute analytical O’Neil pressure along the beam axis for focused annular transducers in water, derives particle velocity and grid adjustment factor, and plots comparisons with simulated/desired intensities. | +| `compute_phases` | Calculate per-element transducer phases (degrees) for electronic focusing by computing fractional wavelength delays to steer waves constructively to a target point relative to the exit plane. | +| `extract_real_intensity_profile` | Extract or spline-interpolate axial intensity profiles between available measured focal depths (wrt exit plane), aligns peaks, skips near-field for max, and plots/saves results. | +| `extract_simulated_profile` | Extract and visualize simulated acoustic pressure data. | +| `perform_global_search` | Globally optimize transducer element phases (rad) and particle velocity via FEXminimize or GlobalSearch to minimize error fitting O’Neil analytical intensity to measured axial profiles. | +| `phase_optimization_annulus_full_curve` | Optimizes the phase profile for an annular transducer by matching intensity curves. | +| `phase_optimization_annulus` | Optimizes the phase profile by calculating focal distance error for an annular transducer. | +| `plot_opt_sim_results` | Load optimized k-Wave results, generate 2D focal plane intensity maps and axial profile comparisons (simulated vs. analytical O’Neil vs. desired), and save annotated plots. | +| `recompute_oneil_solution` | Recompute O’Neil analytical pressure profile using optimized phases/velocity, convert to intensity, plot comparisons with original/desired profiles (skipping near-field), and report focal metrics. | +| `save_optimized_values` | Append optimized transducer phases and amplitudes into a CSV table (indexed by intensity/focus) and save parameters as YAML for PRESTUS config integration. | +| `scale_real_intensity_profile` | Linearly scale measured focal intensity profile and update transducer source amplitude (via acoustic impedance) to match desired peak Isppa (W/cm²). | +| `set_real_phases` | Load or compute manufacturer-specific (Sonic Concepts/Imasonic) transducer element phases for given focal depth (wrt exit plane), interpolate/unwrap for virtual elements, and return phase degrees. | + +#### CORE + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `load_parameters` | Loads and merges configuration files for simulation parameters. | +| `path_log_setup` | Set up internal paths and logging, filename for output table | +| `simulation_nifti` | Save key outputs as 3D Niftis | + +#### GROUP + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `changem_vectorized` | Replace multiple old values in array A with corresponding new values. | +| `combine_plots_by_suffix` | Combines subject-specific plots into a single montage image. | +| `create_group_MNI_plots` | Generate group-level plots in MNI space for multiple subjects. | + +#### HEAD + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `head_smooth_and_crop` | Convert and smooth segmentations into medium map & crop grid for effiency | +| `preproc_medium_mask` | Map segmentation indices onto the medium labels (in the config) [layered only] | +| `preproc_align_to_focal_axis` | Rotate input image and its grid coordinates to the transducer-focal axis (+Z) | +| `preproc_crop_eCSF` | Expand CSF to define outer edges of layered medium. | +| `preproc_crop_grid` | Crop grid to head + transducer + PML. | +| `preproc_head` | Preprocesses structural brain data for simulations (segmentation, alignment, cropping). | +| `preproc_segmentation` | Setup SimNIBS segmentation | +| `segmentation_run` | Submit SimNIBS segmentation | +| `skull_fill_holes` | Fill holes in skull segmentation and between skull and skin | +| `skull_rubber_wrap_visualize` | Visualize results of skull rubber expansion | +| `skull_rubber_wrap` | Inflate the skull layer to locally fill potential holes | +| `smooth_img` | Apply 3D smoothing | + +#### HELPER + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `check_availability` | Check file availability. | +| `check_layers` | Match requested layers to those available in the segmentation. For (p)CT, homogenize multi-skull layer into a single `skull `layer. | +| `confirm_overwriting` | Check overwriting (manual). | +| `confirmation_dlg` | Present confirmation dialogue. | +| `find_min_factor` | Find the number with the smallest maximum factor in a range. | +| `get_crop_dims` | Computes cropping dimensions for a 3D image with a margin. | +| `get_flhm_center_position` | Calculates the center position of the full-length half-maximum (FLHM). | +| `get_slice_by_label` | Extracts a specific slice from a 3D image based on axis label and slice number. | +| `get_xyz_mesh` | Generates a mesh of 3D coordinates for a given image. | +| `getidx` | Retrieves indices for requested tissues from a parameter structure. | +| `kwave_version` | Display k-Wave version number and (if available) git hash. | +| `log_timer` | Start or stop logs for benchmarking time, RAM, and disk space use. | +| `masked_max_3d` | Computes the maximum intensity within a masked 3D region. | +| `mergeStructure` | Merges multiple scalar structures into one. | +| `read_ini_file` | Read an INI file into MATLAB | +| `round_if_integer` | Rounds values if they are sufficiently close to integers; otherwise raises an error. | +| `simnibs_version` | Get SimNIBS version of segmentation from HTML, print, allocate to `parameters`. | +| `subset_fields` | Copy designated structure field to new structure. | +| `tissuemask_binary` | Extract binary tissue segmentation masks (for indexing) | +| `upsample_to_grid` | Upsamples a 3D image to a higher resolution grid. | +| `zip_fields` | Convert a structure's fields and values into a cell array. | + +#### HPC + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `hpc_detect_system` | Detect SLURM or qsub HPC system. | +| `hpc_job_info` | Generate formatted job display information. | +| `hpc_job_name` | Generate standardized HPC job name. | +| `hpc_setup_temp_files` | Setup directories and generate temporary files. | +| `hpc_submit_job` | Submit HPC batch job (SLURM or qsub). | +| `hpc_validate_parameters` | Validate HPC job parameters. | +| `hpc_wait_for_completion` | Monitor HPC job until completion. | + +#### MEDIUM + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `fitPowerLawParamsMulti` | Fit power law absorption parameters for highly absorbing media. | +| `get_alpha_coeff` | Computes the amplitude attenuation coefficient for a given medium and frequency. | +| `medium_properties_nifti` | Save NifTi image of the specified medium property map. | +| `medium_setup` | Set up medium | +| `medium_pct_density` | Skull: pCT-informed density mapping | +| `medium_pct_soundspeed` | Skull: pCT-informed sound speed mapping | +| `medium_pct_attenuation` | Skull: pCT-informed attenuation mapping | + +#### NEURONAV + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `neuronav_compute_series_statistics` | Compute mean position and variability over stimulus train | +| `neuronav_convert_MNI_to_native` | Transform coordinates from MNI space to native subject space | +| `neuronav_convert_native_to_MNI` | Convert native RAS coordinates to MNI space | +| `neuronav_convert_trigger_to_voxels` | Convert Localite trigger positions to voxel (image) coordinates. | +| `neuronav_export_session_csv` | Export per-session coordinate arrays to CSV with labeled voxel and RAS (mm) positions. | +| `neuronav_get_group_mean_mni` | Compute group-level average MNI coordinates (both mm and voxel). | +| `neuronav_select_localite` | Select most recent Localite XML for a session. | +| `position_transducer_localite` | Determines transducer and focus positions in voxel space using Localite data and MRI header. | + +#### PSEUDO-CT + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `fit_pairwiselinear` | Perform a pairwise linear fit between HU and density values with optional plot. | +| `pct_create_pseudoCT` | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | +| `pct_skullexpand` | Load SimNIBS NIfTIs + headers, run skull rubber wrap, save output. | +| `pct_skullmapping` | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. [deprecated, debug] | +| `pct_soft_tissue_peak` | Identifies the soft tissue peak from UTE intensity distribution histograms. | + +#### PLOT + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `plot_coronal_slices` | (DEPRECATED) Visualizes coronal slices of a 3D image with optional legends for labeled images. | +| `plot_median_montage` | (DEPRECATED) Creates a montage of central slices from a 3D T1 image. | +| `plot_overlay_2d` | Overlays map on a background image slice with key positions highlighted. | +| `plot_overlay` | Visualizes map overlaid on a 2D slice of a 3D background image with advanced options. | +| `plot_t1_with_transducer` | Creates a plot of a T1 slice oriented along the transducer's axis with overlays. | +| `plot_transducer_overlay` | Visualize curved transducer geometry with exit plane. | +| `show_3d_head` | Visualizes segmented brain images in 3D with transducer placement and target location. | +| `show_positioning_plots` | Visualizes transducer positioning before and after preprocessing using segmented images. | + +#### SOURCE + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `grid_axisymmetry` | Convert grid to axisymmetry | +| `grid_tissue_setup` | Set up grid dimensions, preprocess head (if modeled), and place in grid | +| `grid_transducer_location` | Position transducer (and target) in simulation grid | +| `source_create` | Creates ultrasound source signals and masks for k-Wave simulations based on transducer geometry.| +| `source_sensor_setup` | Configure a k-Wave simulation grid, transducer sources, and sensor mask based on input parameters for recording pressure fields in 2D or 3D ultrasonic neuromodulation setups. | + +#### THERMAL + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `thermal_analysis` | Analyze output of thermal simulations | +| `thermal_parameters` | Checks and converts protocol timing setup for thermal estimation | +| `thermal_plot_protocol` | Visualize the requested protocol timing | +| `thermal_plot_sim` | Visualizes heating simulation results over time (temperature, rise, CEM43). Optional video | +| `thermal_simulation` | Simulate ultrasound-induced heating and thermal dose (CEM43) using k-Wave’s kWaveDiffusion from acoustic pressure data across pulsed train repetitions. | +| `thermal_update_timeseries` | Track max T/CEM43 per tissue layer. | + +#### TRANSDUCER + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `focal_distance_calculation` | Compute expected focal distances for (multi-)transducer setup. | +| `get_arc` | Generates the coordinates of an arc in 2D space. | +| `get_trans_pos_from_trigger_markers` | Determines transducer and target positions using Localite trigger marker files. | +| `get_transducer_box` | Computes transducer box dimensions and positions for simulations. | +| `transducer_analyze_position_fast` | Analyzes the transducer position relative to the skull and skin. | +| `transducer_analyze_position` | Computes geometric and statistical measures for a transducer position. | +| `transducer_positioning` | Determines heuristic transducer placement for a given target. | +| `tp_candidate_mesh` | Build coordinate mesh and candidate transducer geometry. | +| `tp_evaluate_candidate_positions` | Evaluate candidate transducer positions. | +| `tp_find_initial_candidate` | Find candidate transducer positions on skull surface. | +| `tp_plot_candidate_positions` | Plot transducer candidate positions on skull surface slice. | +| `tp_plot_geometry_overlay` | Visualize transducer geometry on skin segmentation slice. | +| `tp_plot_heuristic_position` | Create visualization of heuristic transducer placement. | +| `tp_remove_ear_locations` | Exclude ear entries from heuristic transducer positions. | +| `tp_select_heuristic_position` | Select optimal transducer position and export Localite coordinates. | +| `transducer_setup` | Create a transducer mask and label matrix for a computational grid. | + +#### TRANSFORM + +| **Function Name** | **Description** | +|-----------------------------------------|-------------------------------------------------------------------------------------------------| +| `convert_2d_to_axisymmetric` | Converts 2D k-Wave simulation grid, medium properties, and source to axisymmetric form by halving the shorter dimension (right half from center). | +| `convert_axisymmetric_to_2d` | Mirrors axisymmetric simulation data (sensor, medium, masks, source) left-right to full 2D, doubles radial dimension, transposes axes, and updates kgrid/positions. | +| `convert_axisymmetric_to_3d` | Expands 2D axisymmetric data (via radialExpand2DTo3D) to full 3D grid by duplicating radial dimension, updates positions/grid/kgrid for cubic symmetry. | +| `convert_final_to_MNI_matlab` | Converts an image from subject space to MNI space using MATLAB. | +| `convert_final_to_MNI_simnibs` | Converts an image to MNI space using SimNIBS. | +| `mni2subject_coords_LDfix` | Transforms a set of coordinates in MNI space to subject space. | +| `radialExpand2DTo3D` | Radially expands 2D axisymmetric data into 3D Cartesian volume. | +| `ras_to_grid` | Converts RAS coordinates to voxel (grid) coordinates using NIfTI header transformation matrix. | +| `subject2mni_coords_LDfix` | Transforms a set of coordinates in MNI space to subject space. | +| `transform_coordinates` | Transform input coordinates between coordinate systems (wrapper) space. | \ No newline at end of file diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 0355b785..335a7363 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -11,10 +11,11 @@ To set up a specific application, an additional `config_.yaml` should be | `data_path` | Absolute path to structural input data location. | [string] Mandatory | | `seg_path` | Absolute path to SimNIBS segmentations. | [string] Mandatory | | `sim_path` | Absolute path to the simulation output. | [string] Mandatory | +| `localite_path` | Absolute path to localite planning. | [string] Optional | | `simnibs_bin_path` | Absolute path to SimNIBS binaries. | [string] Mandatory | | `paths_to_add` | Toolbox paths to add with addpath(). | [cell] e.g., `{"path/to/x", "path/to/Y"}` | | `subpaths_to_add` | Toolbox paths to add with addpath(genpath()). | [cell] e.g., `{"path/to/x", "path/to/Y"}` | -| `subject_subfolder` | Manage outputs in subject-specific subdirectories? | (`1 = yes [default], 0 = no`) | +| `subject_subfolder` | Manage simulation (and optionally localite planning) outputs in subject-specific subdirectories? | (`1 = yes [default], 0 = no`) | | `results_filename_affix` | Affix for result file names | [string] Can be used to differentiate simulation outputs for the same subject-transducer combination(e.g., different intensities and/or targets.) | | `interactive` | Interactive mode (`1 = yes, 0 = no`). | (`1 = yes, 0 = no`) Asks prior to overwriting or starting long computations. If set to non-interactive, see the flags `overwrite_files` and `overwrite_simnibs`. | | `overwrite_files` | File overwrite behavior (`ask`, `never`, or `always`).| (`ask`, `never`, or `always`) This parameter does NOT apply to SimNIBS segmentations. | @@ -227,4 +228,15 @@ For transducer calibration, a separate `calibration_config.yaml` applies that sh | `opt_limits` | Distance limits for optimization [mm] | | | `opt_weights` | Weighting of the original profile during fitting (1 = equal weighting, > 1 Gaussian weighting, increasingly narrow with larger weights) | | | `opt_seed` | Random seed for optimization | | -| `skip_front_peak_mm` | Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts. | Used only for calculating the peak distance and FWHM. | \ No newline at end of file +| `skip_front_peak_mm` | Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts. | Used only for calculating the peak distance and FWHM. | + +### Heuristic transducer placement + +| **Parameter** | **Description** | **Comments** | +|---------------------------------|---------------------------------------------------------|---------------------------------------| +| `tp_dist_close` | Distance from target considered sufficiently close [mm] | | +| `tp_ear_radius` | Radius of the nogo zone [mm] | [Optional] for ear exclusion | +| `tp_left_ear_center` | Approximate coordinates for left ear [img voxels] | [Optional] for ear exclusion | +| `tp_right_ear_center` | Approximate coordinates for right ear [img voxels] | [Optional] for ear exclusion | +| `tp_criterion_intersection` | Heuristic placement criterion: intersection | Default: `0.05` (5%) | +| `tp_save_localiteT1` | Save localite T1 | [Optional] Requires `localite_path` | diff --git a/functions/core/path_log_setup.m b/functions/core/path_log_setup.m index 603a5180..a2810564 100644 --- a/functions/core/path_log_setup.m +++ b/functions/core/path_log_setup.m @@ -52,13 +52,23 @@ % return to PRESTUS path cd(prestus_path); - % Make subfolder (if enabled) and check if directory exists + % [SIMULATION OUTPUT] 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)); + if ~exist(parameters.output_dir); mkdir(parameters.output_dir); end; else parameters.output_dir = parameters.sim_path; end + % [LOCALITE OUTPUT] Make subfolder (if enabled) and check if directory exists + if (isfield(parameters, 'localite_path') && ~isempty(parameters.localite_path)) && ... + isfield(parameters,'subject_subfolder') && parameters.subject_subfolder == 1 + parameters.localite_path = fullfile(parameters.localite_path, sprintf('sub-%03d', subject_id)); + if ~exist(parameters.localite_path); mkdir(parameters.localite_path); end; + else + parameters.localite_path = parameters.localite_path; + end + % specify dedicated subfolder for debugging contents parameters.debug_dir = fullfile(parameters.output_dir, 'debug'); diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index 02b88fc9..c4f8e313 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -46,7 +46,22 @@ function prestus_pipeline_start(subject_id, parameters, options) % Create job files save(temp_data_path, 'subject_id', 'parameters'); - hpc_matlab_pipeline(temp_m_path, temp_data_path, prestus_path, options); + + % 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(subject_id, parameters, options);\n'); + else + fprintf(fid, 'prestus_pipeline(subject_id, parameters);\n'); + end + fprintf(fid, 'delete(''%s'');\n', temp_data_path); + fprintf(fid, 'delete(''%s'');\n', temp_m_path); + fclose(fid); + end % Job name job_name = hpc_job_name(parameters, subject_id); diff --git a/functions/hpc/hpc_matlab_pipeline.m b/functions/hpc/hpc_matlab_pipeline.m deleted file mode 100644 index b7980983..00000000 --- a/functions/hpc/hpc_matlab_pipeline.m +++ /dev/null @@ -1,28 +0,0 @@ -function hpc_matlab_pipeline(temp_m_path, temp_data_path, prestus_path, options) -%% HPC_MATLAB_PIPELINE Generate temporary MATLAB batch script -% -% Creates self-deleting MATLAB batch script that loads parameters and runs -% prestus_pipeline. Handles optional sequential_configs. -% -% Inputs: -% temp_m_path - Path for output .m script -% temp_data_path - Path to .mat parameter file -% prestus_path - Directory containing prestus_pipeline.m -% options - Optional struct with sequential_configs field -% -% See also HPC_SETUP_TEMP_FILES, HPC_SUBMIT_JOB. - -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(subject_id, parameters, options);\n'); -else - fprintf(fid, 'prestus_pipeline(subject_id, parameters);\n'); -end -fprintf(fid, 'delete(''%s'');\n', temp_data_path); -fprintf(fid, 'delete(''%s'');\n', temp_m_path); -fclose(fid); -end \ No newline at end of file diff --git a/functions/transducer/tp_plot_heuristic_position.m b/functions/transducer/tp_plot_heuristic_position.m new file mode 100644 index 00000000..42acf5dc --- /dev/null +++ b/functions/transducer/tp_plot_heuristic_position.m @@ -0,0 +1,90 @@ +function tp_plot_heuristic_position(trans_pos, target_pos, img, img_info, 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_info - 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 = trans_pos; +target_xyz = target_pos; + +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_info, 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.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, 'localite_path') && ~isempty(parameters.localite_path) + saveas(h, fullfile(parameters.localite_path, ... + sprintf("sub-%03.0f_%s", subject_id, target_name)), 'png'); +end + +% close figure +close(h); diff --git a/functions/transducer/tp_plot_heuristic_transducer.m b/functions/transducer/tp_plot_heuristic_transducer.m deleted file mode 100644 index 13233544..00000000 --- a/functions/transducer/tp_plot_heuristic_transducer.m +++ /dev/null @@ -1,112 +0,0 @@ -function tp_plot_heuristic_transducer(tpos_pars, img, img_info, parameters, pixel_size, target_name, subject_id) -% TP_PLOT_HEURISTIC_TRANSDUCER Create visualization of heuristic transducer placement -% -% INPUT (7 required + 1 optional) -% tpos_pars - Table from tp_evaluate_candidate_positions -% img - Segmented head image -% img_info - NIfTI header info -% parameters - Parameters struct -% pixel_size - Scalar voxel size in mm -% target_name - String target identifier -% subject_id - Scalar subject ID -% mesh - [OPTIONAL LAST] Full mesh structure from tp_candidate_mesh() -% -% OUTPUT -% Saves: sub-XXX_optimal_TARGET.png - -%% Select optimal position (multi-criteria heuristic) -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 = 1; - -target_xyz = [tppf.targ_x(i), tppf.targ_y(i), tppf.targ_z(i)]; -trans_xyz = [tppf.trans_x(i), tppf.trans_y(i), tppf.trans_z(i)]; - -%% Recompute mesh variables - -mesh = tp_candidate_mesh(img, target_xyz, parameters, pixel_size); - -coord_mesh.xyz = gather(mesh.coord_mesh.xyz); % struct with field xyz -ex_plane = gather(mesh.ex_plane); % [N_cand x 3] exit plane centers -max_od_grid = gather(mesh.max_od_grid); % scalar, aperture diameter in voxels -geom_focus = gather(mesh.geom_focus); % [N_cand x 3] geometric focus positions - -ex_pl_xyz = ex_plane(i,:); -gf_xyz = round(geom_focus(i,:)); - -%% Align to focal axis - -[rotated_img, trans_xyz, target_xyz, transformation_matrix, ~, ~, ~, ~] = ... - preproc_align_to_focal_axis(... - img, img_info, round(trans_xyz)', target_xyz', 1, parameters); - -TF = maketform('affine', transformation_matrix); - -ex_pl_xyz = round(tformfwd([ex_pl_xyz]', TF)); -gf_xyz = round(tformfwd([gf_xyz]', TF)); - -%% 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(rotated_img(:,round(trans_xyz(2)),:))); - - % TARGET - Red box - rectangle('Position', [target_xyz([3,1]) - 2, 4, 4], ... - 'Curvature', [0,0], ... - 'EdgeColor', 'r', ... - 'LineWidth', 2, 'LineStyle', '-'); - - % TRANSDUCER (heuristic position) - Blue box - rectangle('Position', [trans_xyz([3,1]) - 2, 4, 4],... - 'Curvature', [0,0],... - 'EdgeColor', 'b', ... - 'LineWidth', 2, 'LineStyle', '-'); - - % GEOMETRIC FOCUS (bowl sphere center) - Yellow box - rectangle('Position', [gf_xyz([3,1]) - 2, 4, 4],... - 'Curvature', [0,0],... - 'EdgeColor', 'yellow',... - 'LineWidth', 2, 'LineStyle', '-'); - - % EXIT PLANE (transducer aperture center) - White box - 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(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]) - - output_plot = fullfile(parameters.output_dir,sprintf('sub-%03d_heuristic_%s.png', subject_id, target_name)); - saveas(h, output_plot, 'png') - close(h); diff --git a/functions/transducer/tp_remove_ear_locations.m b/functions/transducer/tp_remove_ear_locations.m index 2b3087fe..4afc8f18 100644 --- a/functions/transducer/tp_remove_ear_locations.m +++ b/functions/transducer/tp_remove_ear_locations.m @@ -8,6 +8,9 @@ % Validate complete specification if ~isempty(parameters.tp_ear_radius) && ~isempty(parameters.tp_left_ear_center) && ~isempty(parameters.tp_right_ear_center) + % keep copy of original positions + locs_original = locs; + % Define nogo zone spheres ear_radius = parameters.tp_ear_radius; left_ear_center = parameters.tp_left_ear_center; diff --git a/functions/transducer/tp_select_heuristic_position.m b/functions/transducer/tp_select_heuristic_position.m index 2ffbf535..d2d0e515 100644 --- a/functions/transducer/tp_select_heuristic_position.m +++ b/functions/transducer/tp_select_heuristic_position.m @@ -1,5 +1,5 @@ -function [best_trans_pos, trans_pos, target_pos, table_path] = ... - tp_select_heuristic_position(locs, pixel_size, subject_id, target_name, rootpath) +function [trans_pos, target_pos, best_trans_pos] = ... + tp_select_heuristic_position(locs, pixel_size, subject_id, target_name, parameters) %% TP_SELECT_HEURISTIC_POSITION Select optimal transducer position and export Localite coordinates % % Multi-step HEURISTIC selection: @@ -12,16 +12,29 @@ % pixel_size - Scalar voxel size (mm/voxel) % subject_id - Scalar subject ID % target_name - String target identifier (e.g. 'right_PUL') -% rootpath - Root output directory +% parameters - [OPTIONAL] Struct with .tp_criterion_intersection, .output_dir, .localite_path % % OUTPUT -% best_trans_pos - Full table row of HEURISTIC position with added Localite fields % trans_pos - [1x3] integer voxel coordinates [x y z] % target_pos - [1x3] integer voxel coordinates [x y z] -% table_path - Full path to saved Localite .txt file +% best_trans_pos - Full table row of HEURISTIC position with added Localite fields + +%% Select optimal position (multi-criteria heuristic) +%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),:); + +%% Check for configurable intersection criterion +if nargin < 5 || ~isfield(parameters, 'tp_criterion_intersection') || isempty(parameters.tp_criterion_intersection) + criterion_intersection = 0.05; % Default + fprintf('[TP_HEURISTIC] Using default intersection criterion: %.2f\n', criterion_intersection); +else + criterion_intersection = parameters.tp_criterion_intersection; + fprintf('[TP_HEURISTIC] Using specified intersection criterion: %.2f\n', criterion_intersection); +end -%% Multi-step HEURISTIC selection with adaptive intersection criterion -criterion_intersection = 0.05; +%% Multi-step HEURISTIC selection with adaptive criterion tppf = locs(locs.prop_intersect < criterion_intersection, :); % Auto-expand criterion if no candidates found @@ -59,10 +72,16 @@ best_trans_pos.loc_targ_z = target_pos_localite(3); %% Export Localite-compatible table -table_path = fullfile(rootpath, 'data', 'localite', sprintf('sub-%03.0f', subject_id), ... - sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); - +table_path = fullfile(parameters.output_dir, sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); writetable(best_trans_pos, table_path, 'Delimiter', 'tab'); fprintf('[TP_HEURISTIC] Saved heuristic position: %s\n', table_path); +%% [Optional] Save copy in localite directory +if isfield(parameters, 'localite_path') && ~isempty(parameters.localite_path) + localite_path = fullfile(parameters.localite_path,... + sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); + writetable(best_trans_pos, localite_path, 'Delimiter', 'tab'); + 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 ba19fcf4..17630e2f 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -18,11 +18,11 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar %% 2. LOAD SEGMENTATION DATA -headreco_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); -filename = fullfile(headreco_folder, 'final_tissues.nii.gz'); +m2m_folder = fullfile(pn.seg_path, sprintf('m2m_sub-%03d', subject_id)); +filename = fullfile(m2m_folder, 'final_tissues.nii.gz'); img = niftiread(filename); img_info = niftiinfo(filename); -pixel_size = mean(img_info.PixelDimensions); +voxel_size = mean(img_info.PixelDimensions); % [DEBUG] plot the segmentation if parameters.debug @@ -50,29 +50,29 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar fprintf('➤ Target: %s\n', target_name); target_mni = mni_targets.(target_name); - target_vox = map_coordsystems(... + target_vox = transform_coordinates(... parameters, target_mni, 'mni', 'grid', img_info); %% 5. Find candidate transducer positions on skull (expanding sphere) [trans_candidate, outer_sphere_3d, parameters] = ... - tp_find_initial_candidate(img, target_vox, pixel_size, parameters); + tp_find_initial_candidate(img, target_vox, voxel_size, parameters); %% 6. Plot initial candidate tp_plot_candidate_positions(... target_vox, trans_candidate, ... - pixel_size, parameters, subject_id, target_name); + voxel_size, parameters, subject_id, target_name); %% 7. Plot geometry tp_plot_geometry_overlay(img, target_vox, trans_candidate.trans_pos, ... - pixel_size, parameters, subject_id, target_name, outer_sphere_3d); + voxel_size, parameters, subject_id, target_name, outer_sphere_3d); %% 8. Evaluate candidate according to criteria tpos = tp_evaluate_candidate_positions(... - img, target_vox, parameters, pixel_size); + img, target_vox, parameters, voxel_size); %% 9. Save results table @@ -89,14 +89,43 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar %% [Optional] Remove ear locations -function [tpos] = tp_remove_ear_locations(parameters, tpos); +[tpos] = tp_remove_ear_locations(parameters, tpos); -%% Plot heuristic transducer placement +%% Select heuristic transducer position -tp_plot_heuristic_transducer(... - tpos, img, img_info, parameters, pixel_size, target_name, subject_id); +[trans_pos, target_pos, ~] = ... + tp_select_heuristic_position(tpos, voxel_size, subject_id, target_name, parameters); + +%% Plot heuristic transducer position + +tp_plot_heuristic_position(... + trans_pos, target_pos, img, img_info, 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, 'tp_save_localiteT1') && parameters.tp_save_localiteT1 && ... + isfield(parameters, 'localite_path') && ~isempty(parameters.localite_path) + disp("Requested to transform T1 header for localite ...") + path_t1 = fullfile(m2m_folder, 'T1.nii.gz'); + path_localite_out = fullfile(parameters.localite_path, ... + ['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') + end 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/map_coordsystems.m b/functions/transform/transform_coordinates.m similarity index 88% rename from functions/transform/map_coordsystems.m rename to functions/transform/transform_coordinates.m index dff8c8c3..8a886acd 100644 --- a/functions/transform/map_coordsystems.m +++ b/functions/transform/transform_coordinates.m @@ -1,5 +1,5 @@ -function output_pos = map_coordsystems(parameters, input_pos, input_cs, output_cs, nii_hdr) -% MAP_COORDSYSTEMS Coord mapper with explicit varargin requirements. +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: @@ -28,7 +28,7 @@ if strcmp(output_cs, 'ras_plus') output_pos = ras_pos; elseif strcmp(output_cs, 'grid') - output_pos = map_coordsystems(parameters, ras_pos, 'ras_plus', 'grid', nii_hdr); + output_pos = transform_coordinates(parameters, ras_pos, 'ras_plus', 'grid', nii_hdr); else error('MNI output_cs: ''ras_plus'' or ''grid'' only'); end From 52cb0b54769f2e43899120f53879e0465d7b6819 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Fri, 13 Mar 2026 14:33:46 +0100 Subject: [PATCH 113/144] [doc] draft documentation for heuristic transducer placement --- documentation/doc_placement.md | 27 +------- documentation/doc_placement_heuristic.md | 66 +++++++++++++++++++ functions/transducer/transducer_positioning.m | 16 ++--- mkdocs.yaml | 1 + 4 files changed, 77 insertions(+), 33 deletions(-) create mode 100644 documentation/doc_placement_heuristic.md diff --git a/documentation/doc_placement.md b/documentation/doc_placement.md index 6630c569..77e89e8b 100644 --- a/documentation/doc_placement.md +++ b/documentation/doc_placement.md @@ -1,4 +1,4 @@ -# Transducer placement +# Transducer Placement Simulations in free water will place the transducer in a homogeneous water medium without the need to specify a target. For head smulations, any transducer needs to be positioned close to the scalp (possibly at some distance to allow for coupling), and a target needs to be specified. @@ -10,33 +10,10 @@ Suitable coordinates (x/y/z) may be identified in preferred imaging software bas ## Heuristic coordinate selection -The function `transducer_positioning` identifies heuristic locations for transducer placement. - -The function targets brain regions specified by MNI coordinates, converting them to subject-native space using SimNIBS tools. It aims to identify candidate transducer positions where the geometric focus aligns with a target within a user-defined focal distance range, while ensuring >50% aperture overlaps skull/skin and low variance in distances. For each position, it computes the intersection proportion between the transducer and skin, mean/variance distances to skin and skull, and geometric focus/exit plane positions to ensure efficient coupling while minimizing aberrations. Besides target MNI coordinates, the function does not require manual intervention, enabling automatic end-to-end workflows. +PRESTUS can identify heuristic locations for transducer placement (see [Heuristic Tranducer Placement](doc_placement_heuristic.md)). This benefits iterative approaches without manual intervention. Outside of PRESTUS, alternative tools such as [PlanTUS](https://github.com/mlueckel/PlanTUS) can be used to manually identify candidate transducer locations based on a broader range of criteria. - -### Coordinate table - -The `tpars_sub-XXX_target.csv` table contains transducer position candidates with the following columns. This information can be used to select a suitable candidate (e.g., by selecting a position with low intersection of the transducer and head tissue (prop_intersect < 0.05) and minimal Euclidean distance to the target; often supported by visual inspection). - -| Column | Description | -|-----------------|------------------------------------------------| -| idx | index | -| trans_x | Transducer x-coordinate (voxels) | -| trans_y | Transducer y-coordinate (voxels) | -| trans_z | Transducer z-coordinate (voxels) | -| targ_x | Target x-coordinate (voxels) | -| targ_y | Target y-coordinate (voxels) | -| targ_z | Target z-coordinate (voxels) | -| dist_to_target | Euclidean distance transducer-to-target (voxels)| -| prop_intersect | Proportion of transducer volume intersecting head | -| meandistskin | Mean distance to skin surface in aperture plane (voxels) | -| vardistskin | Variance of distances to skin (voxels²) | -| meandistskull | Mean distance to skull surface in aperture plane (voxels)| -| vardistskull | Variance of distances to skull (voxels²) | - ## Neuronavigation coordinate selection PRESTUS provides helper functions to read-in coordinates acquired with neuronavigation systems. Currently, PRESTUS supports read-in of localite positions. diff --git a/documentation/doc_placement_heuristic.md b/documentation/doc_placement_heuristic.md new file mode 100644 index 00000000..e9ab13d0 --- /dev/null +++ b/documentation/doc_placement_heuristic.md @@ -0,0 +1,66 @@ +# Heuristic Transducer Placement + +The function `transducer_positioning` identifies heuristic locations for transducer placement. It can be called with `transducer_positioning_start`. + +The function targets brain regions specified by MNI coordinates, converting them to subject-native space using SimNIBS tools. It aims to identify candidate transducer positions where the geometric focus aligns with a target within a user-defined focal distance range. For each position, it computes the intersection proportion between the transducer and skin, mean/variance distances to skin and skull, and geometric focus/exit plane positions. It (optionally removes) areas including ears and selects a heuristic position based on user-defined criteria. Besides target MNI coordinates, the function does not require manual intervention, enabling automatic end-to-end workflows. + +For parameters, see the [overview](doc_parameters.md#heuristic-transducer-placement). + +### Candidate positions + +- Convert target from MNI (mm) to subject grid space (voxels) +- Find candidate transducer positions on skull (expanding sphere) +- Calculate criteria for each location: target distance, intersection with skin, mean distance to skull, variance in distance to skin & skull + +#### Table: Candidate Coordinates + +The `tpars_sub-XXX_target.csv` table contains transducer position candidates with the following columns. This information can be used to select a suitable candidate (e.g., by selecting a position with low intersection of the transducer and head tissue (prop_intersect < 0.05) and minimal Euclidean distance to the target; often supported by visual inspection). + +| Column | Description | +|-----------------|------------------------------------------------| +| idx | index | +| trans_x | Transducer x-coordinate (voxels) | +| trans_y | Transducer y-coordinate (voxels) | +| trans_z | Transducer z-coordinate (voxels) | +| targ_x | Target x-coordinate (voxels) | +| targ_y | Target y-coordinate (voxels) | +| targ_z | Target z-coordinate (voxels) | +| dist_to_target | Euclidean distance transducer-to-target (voxels)| +| prop_intersect | Proportion of transducer volume intersecting head | +| meandistskin | Mean distance to skin surface in aperture plane (voxels) | +| vardistskin | Variance of distances to skin (voxels²) | +| meandistskull | Mean distance to skull surface in aperture plane (voxels)| +| vardistskull | Variance of distances to skull (voxels²) | + +### Remove ear locations + +**[Optional]** + +For practical reasons, transducer locations overlapping with the ears are not desired. Possible locations that overlap with ear positions can optionally be removed within an `tp_ear_radius` (Radius of the nogo zone, mm) around `tp_left_ear_center` (approximate coordinates for left ear, voxels) and `tp_right_ear_center`. + +### Select heuristic transducer position + +The desired criterion for intersection with the skin can be defined via `tp_criterion_intersection`. If no placement is found wihtin this criterion, the intersection will be iteratively expanded by 1% until a match is identified. + +> Other criteria are not currently implemented. e.g.: +``` +%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),:); +``` + +Amongst locations fulfilling the above criteria, the location with a minimum distance to the target is selected. + +### Plot heuristic transducer position + +PRESTUS generates an overview of the selected transducer placement: + +![ex_heuristic_placement](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/ex_heuristic_placement.png) + +If `parameters.localite_path` is specified, it will also deposit a copy of the plot there. + +### Save T1w with localite-ready header + +**[Optional]** + + Localite can struggle with canonical T1 header affine matrices. 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/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index 17630e2f..713af341 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -46,35 +46,35 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar if confirm_overwriting(tpos_output_file, parameters) - %% 3. Convert target from MNI (mm) to subject grid space (voxels) + %% Convert target from MNI (mm) to subject grid space (voxels) fprintf('➤ Target: %s\n', target_name); target_mni = mni_targets.(target_name); target_vox = transform_coordinates(... parameters, target_mni, 'mni', 'grid', img_info); - %% 5. Find candidate transducer positions on skull (expanding sphere) + %% Find candidate transducer positions on skull (expanding sphere) [trans_candidate, outer_sphere_3d, parameters] = ... tp_find_initial_candidate(img, target_vox, voxel_size, parameters); - %% 6. Plot initial candidate + %% Plot initial candidate tp_plot_candidate_positions(... target_vox, trans_candidate, ... voxel_size, parameters, subject_id, target_name); - %% 7. Plot geometry + %% Plot geometry tp_plot_geometry_overlay(img, target_vox, trans_candidate.trans_pos, ... voxel_size, parameters, subject_id, target_name, outer_sphere_3d); - %% 8. Evaluate candidate according to criteria + %% Evaluate candidate according to criteria tpos = tp_evaluate_candidate_positions(... img, target_vox, parameters, voxel_size); - %% 9. Save results table + %% Save results table writetable(tpos, tpos_output_file, 'Delimiter', ','); @@ -83,7 +83,7 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar tpos = readtable(tpos_output_file, 'Delimiter', ','); end -%% [Optional] Convert positions to RAS & MNI +%% [Optional] Convert positions to RAS % TO DO @@ -104,7 +104,7 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar fprintf('Heuristic transducer placement: %s (sub-%03d) → %s\n', ... target_name, subject_id, tpos_output_file); -%% [optional] save T1w image with localite-ready header +%% [Optional] Save T1w image with localite-ready header if isfield(parameters, 'tp_save_localiteT1') && parameters.tp_save_localiteT1 && ... isfield(parameters, 'localite_path') && ~isempty(parameters.localite_path) diff --git a/mkdocs.yaml b/mkdocs.yaml index 4bd28c6e..f139be9c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -44,6 +44,7 @@ nav: - Transducer Setup: doc_transducer.md - Transducer Calibration: doc_calibration.md - Transducer Placement: doc_placement.md + - Transducer Placement (Heuristic): doc_placement_heuristic.md - Head Processing: doc_preproc.md - Medium Setup: doc_medium.md - Outputs: doc_outputs.md From 5a88d818bb776145842a6905dde4a184d78c0082 Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Fri, 13 Mar 2026 16:11:21 +0100 Subject: [PATCH 114/144] adding padding when makeBowl is used and transducer is out of bounds --- functions/head/preproc_align_to_focal_axis.m | 33 +++++++++++++++++--- functions/plot/plot_t1_with_transducer.m | 21 +++++++++++-- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/functions/head/preproc_align_to_focal_axis.m b/functions/head/preproc_align_to_focal_axis.m index 180e78c4..f385878f 100644 --- a/functions/head/preproc_align_to_focal_axis.m +++ b/functions/head/preproc_align_to_focal_axis.m @@ -81,15 +81,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); 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 From a2be9e8ec33cf0cfbdb21f181eb02fafa2f49a4a Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Fri, 13 Mar 2026 20:50:44 +0100 Subject: [PATCH 115/144] [fix] the slurm submission error due to sbatch format error --- functions/calibration/calibration_transducer.m | 4 ++-- functions/core/prestus_pipeline_start.m | 1 - functions/hpc/hpc_submit_job.m | 14 +++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index 86e7af60..237094d1 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -94,7 +94,7 @@ % Run the simulation based on the submission method sim_param.hpc_wait_for_completion = true; - prestus_pipeline_start(sim_id, sim_param, true); + prestus_pipeline_start(sim_id, sim_param); %% Load initial results @@ -161,7 +161,7 @@ opt_param.results_filename_affix = '_optimized'; sim_param.hpc_wait_for_completion = true; - prestus_pipeline_start(sim_id, sim_param, true); + prestus_pipeline_start(sim_id, sim_param); %% Load optimized simulation results opt_res = load(sprintf('%s/sub-%03d_water_results%s.mat', ... diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index c4f8e313..5460b671 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -61,7 +61,6 @@ function prestus_pipeline_start(subject_id, parameters, options) fprintf(fid, 'delete(''%s'');\n', temp_data_path); fprintf(fid, 'delete(''%s'');\n', temp_m_path); fclose(fid); - end % Job name job_name = hpc_job_name(parameters, subject_id); diff --git a/functions/hpc/hpc_submit_job.m b/functions/hpc/hpc_submit_job.m index 1237fe74..dd7e3c25 100644 --- a/functions/hpc/hpc_submit_job.m +++ b/functions/hpc/hpc_submit_job.m @@ -50,19 +50,19 @@ function write_slurm_script(temp_slurm_path, parameters, subject_id, temp_m_file needs_gpu = isfield(parameters, 'hpc_partition') || ... (isfield(parameters, 'code_type') && any(strcmp(parameters.code_type, {'matlab_gpu', 'cpp_gpu'}))); - if isfield(parameters, 'hpc_partition') && ~isempty(parameters.hpc_partition) - fprintf(fid, '#SBATCH --partition=%s\n', parameters.hpc_partition); + 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'); fprintf(fid, '#SBATCH --gres=gpu:1\n'); end - if isfield(parameters, 'hpc_gpu') && ~isempty(parameters.hpc_gpu) - fprintf(fid, '#SBATCH --gres=%s\n', parameters.hpc_gpu); + if isfield(parameters, 'hpc_gpu') && ~isempty(strtrim(char(parameters.hpc_gpu))) + fprintf(fid, '#SBATCH --gres=%s\n', strtrim(char(parameters.hpc_gpu))); end - - if isfield(parameters, 'hpc_reservation') && ~isempty(parameters.hpc_reservation) - fprintf(fid, '#SBATCH --reservation=%s\n', parameters.hpc_reservation); + + 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); From 0f687c29a1961e834ae4bb35eed7a429edcbd805 Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Fri, 13 Mar 2026 21:00:42 +0100 Subject: [PATCH 116/144] [fix] if localite_path isn't defined the fallback should be the output_dir --- functions/core/path_log_setup.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/functions/core/path_log_setup.m b/functions/core/path_log_setup.m index a2810564..e472e4ea 100644 --- a/functions/core/path_log_setup.m +++ b/functions/core/path_log_setup.m @@ -65,8 +65,10 @@ isfield(parameters,'subject_subfolder') && parameters.subject_subfolder == 1 parameters.localite_path = fullfile(parameters.localite_path, sprintf('sub-%03d', subject_id)); if ~exist(parameters.localite_path); mkdir(parameters.localite_path); end; - else - parameters.localite_path = parameters.localite_path; + else + if ~isfield(parameters, 'localite_path') || isempty(parameters.localite_path) + parameters.localite_path = parameters.output_dir; + end end % specify dedicated subfolder for debugging contents From eb03105a04f07cd623529e761266c4248340469c Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Fri, 13 Mar 2026 22:08:56 +0100 Subject: [PATCH 117/144] [fix] variable img wasn't passed between tp_plot and transducer_positioning.m --- functions/transducer/tp_plot_candidate_positions.m | 3 ++- functions/transducer/transducer_positioning.m | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/functions/transducer/tp_plot_candidate_positions.m b/functions/transducer/tp_plot_candidate_positions.m index 9883d6d5..cf01c641 100644 --- a/functions/transducer/tp_plot_candidate_positions.m +++ b/functions/transducer/tp_plot_candidate_positions.m @@ -1,4 +1,4 @@ -function tp_plot_candidate_positions(target, trans_candidate, ... +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 % @@ -6,6 +6,7 @@ function tp_plot_candidate_positions(target, trans_candidate, ... % 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) diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index 713af341..3a598cd4 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -61,7 +61,7 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar %% Plot initial candidate tp_plot_candidate_positions(... - target_vox, trans_candidate, ... + img, target_vox, trans_candidate, ... voxel_size, parameters, subject_id, target_name); %% Plot geometry From 936b4c1c242aeb1c382032237453b2a8d36f4c95 Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Sat, 14 Mar 2026 07:25:47 +0100 Subject: [PATCH 118/144] [fix] the unoptimized parameters were passed on for recomputing Oneil --- functions/calibration/calibration_transducer.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index 237094d1..f298eb01 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -160,8 +160,8 @@ opt_param.transducer.source_phase_deg = opt_source_phase_deg; opt_param.results_filename_affix = '_optimized'; - sim_param.hpc_wait_for_completion = true; - prestus_pipeline_start(sim_id, sim_param); + opt_param.hpc_wait_for_completion = true; + prestus_pipeline_start(sim_id, opt_param); %% Load optimized simulation results opt_res = load(sprintf('%s/sub-%03d_water_results%s.mat', ... From d53067a6dde2686cd09a7605257fd3c384ec3432 Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Sat, 14 Mar 2026 08:35:46 +0100 Subject: [PATCH 119/144] [fix] nii_image wasnt available for cpu as it was still a gpuArray --- functions/head/preproc_align_to_focal_axis.m | 3 +++ 1 file changed, 3 insertions(+) diff --git a/functions/head/preproc_align_to_focal_axis.m b/functions/head/preproc_align_to_focal_axis.m index f385878f..1de5378c 100644 --- a/functions/head/preproc_align_to_focal_axis.m +++ b/functions/head/preproc_align_to_focal_axis.m @@ -121,6 +121,9 @@ %% Step 7: Transform image % Apply affine transformation to rotate and scale the image + % Ensure image is on CPU (tformarray does not support gpuArray) + nii_image = gather(nii_image); + if numel(unique(nii_image))<20 % if the image is a mask use nearest neighbor parameters.interpolation = 'nearest'; else From 207567dc5226f833a2f35a1d646afd8e7174ccfe Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Sat, 14 Mar 2026 13:44:35 +0100 Subject: [PATCH 120/144] [fix] nii_image wasnt available for cpu as it was still a gpuArray, adjusted in other instances as well --- functions/head/preproc_align_to_focal_axis.m | 8 ++++++-- .../transducer/tp_evaluate_candidate_positions.m | 11 +++++++---- functions/transducer/tp_plot_heuristic_position.m | 5 +++-- functions/transducer/transducer_positioning.m | 1 + 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/functions/head/preproc_align_to_focal_axis.m b/functions/head/preproc_align_to_focal_axis.m index 1de5378c..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]'; @@ -121,8 +126,7 @@ %% Step 7: Transform image % Apply affine transformation to rotate and scale the image - % Ensure image is on CPU (tformarray does not support gpuArray) - nii_image = gather(nii_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'; diff --git a/functions/transducer/tp_evaluate_candidate_positions.m b/functions/transducer/tp_evaluate_candidate_positions.m index 7654f7f3..8716e44e 100644 --- a/functions/transducer/tp_evaluate_candidate_positions.m +++ b/functions/transducer/tp_evaluate_candidate_positions.m @@ -41,13 +41,16 @@ 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); -shifted_trans_pos_coords = gather(mesh.trans_pos); % [N_cand x 3] CPU for table - %% Assemble results table (CPU) -metrics = [prop_intersect; mean_dts; var_dts; mean_dist_skull; var_dist_skull]'; +% Ensure all components are on CPU before table assembly +close_enough_idx = gather(close_enough_idx); +shifted_trans_pos_coords = gather(shifted_trans_pos_coords); +metrics = gather([prop_intersect; mean_dts; var_dts; mean_dist_skull; var_dist_skull]'); +dist_to_target = gather(pdist2(shifted_trans_pos_coords, target)); + tpos_pars = array2table([close_enough_idx, shifted_trans_pos_coords, ... repmat(target, N_cand, 1), ... - pdist2(shifted_trans_pos_coords, target), ... + 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"]); diff --git a/functions/transducer/tp_plot_heuristic_position.m b/functions/transducer/tp_plot_heuristic_position.m index 42acf5dc..bffe86ef 100644 --- a/functions/transducer/tp_plot_heuristic_position.m +++ b/functions/transducer/tp_plot_heuristic_position.m @@ -11,8 +11,9 @@ function tp_plot_heuristic_position(trans_pos, target_pos, img, img_info, parame % target_name - String target identifier % subject_id - Scalar subject ID -trans_xyz = trans_pos; -target_xyz = target_pos; +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)); diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index 3a598cd4..cab9e44a 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -21,6 +21,7 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar 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_info = niftiinfo(filename); voxel_size = mean(img_info.PixelDimensions); From cf5931a07208335edc8ca3c98067693ddaac40df Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Sat, 14 Mar 2026 17:48:54 +0100 Subject: [PATCH 121/144] [heuristic placement] add and document additional criteria, convert to RAS mm, reformat txt --- configs/default_config.yaml | 16 +- documentation/doc_parameters.md | 7 +- documentation/doc_placement_heuristic.md | 48 ++--- .../tp_evaluate_candidate_positions.m | 9 +- .../transducer/tp_plot_heuristic_position.m | 6 +- .../transducer/tp_select_heuristic_position.m | 171 +++++++++++++----- functions/transducer/transducer_positioning.m | 14 +- functions/transform/transform_coordinates.m | 10 +- 8 files changed, 195 insertions(+), 86 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 3482dfa1..f0a68e13 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -256,9 +256,23 @@ skullfillmethod: 'rubberwrap' # Method for filling holes in the skull ['rubberwr wrapradius: 10 # skull rubber wrap radius [grid voxels]; Bigger = tighter rubber that ignores bigger dents (recommendaton: 2 to 10) skullwrap_visualize: 0 # Visualize results of skullwrap? (Currently causes issues on HPC) -#------------ Transducer placement (WIP) +#------------ Transducer Placement (Localite) # Should the script position_transducer_localite be used? transducer_from_localite: 0 # Allows one to correct for varying distances between the infrared trackers attached to the transducer and the exit plane reference_transducer_distance_mm: 15 # (can only be used with the function 'transducer_from_localite') + +#------------ Transducer Placement (Heuristic) + +tp_dist_close: [] +tp_ear_radius: 35 +tp_left_ear_center: [] +tp_right_ear_center: [] +tp_criterion_intersection: 0.05 +tp_criterion_skin_mean: [] +tp_criterion_skull_mean: [] +tp_criterion_skin_var: [] +tp_criterion_skull_var: [] +tp_expand_step: 0.01 +tp_save_localiteT1: true \ No newline at end of file diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 335a7363..91397595 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -238,5 +238,10 @@ For transducer calibration, a separate `calibration_config.yaml` applies that sh | `tp_ear_radius` | Radius of the nogo zone [mm] | [Optional] for ear exclusion | | `tp_left_ear_center` | Approximate coordinates for left ear [img voxels] | [Optional] for ear exclusion | | `tp_right_ear_center` | Approximate coordinates for right ear [img voxels] | [Optional] for ear exclusion | -| `tp_criterion_intersection` | Heuristic placement criterion: intersection | Default: `0.05` (5%) | +| `tp_criterion_intersection` | Criterion: intersection with skin [fraction] | Default: `0.05` (5%) | +| `tp_criterion_skin_mean` | Criterion: mean distance of EP voxels from skin [quantile] | Default: `NaN` | +| `tp_criterion_skull_mean` | Criterion: mean distance of EP voxels from skull [quantile] | Default: `NaN` | +| `tp_criterion_skin_var` | Criterion: variance of EP voxel distance from skin [quantile] | Default: `NaN` | +| `tp_criterion_skull_var` | Criterion: variance of EP voxel distance from skull [quantile] | Default: `NaN` | +| `tp_expand_step` | Expansion step for skin intersection | Default: `0.01` (1%) | | `tp_save_localiteT1` | Save localite T1 | [Optional] Requires `localite_path` | diff --git a/documentation/doc_placement_heuristic.md b/documentation/doc_placement_heuristic.md index e9ab13d0..0b71a275 100644 --- a/documentation/doc_placement_heuristic.md +++ b/documentation/doc_placement_heuristic.md @@ -16,38 +16,40 @@ For parameters, see the [overview](doc_parameters.md#heuristic-transducer-placem The `tpars_sub-XXX_target.csv` table contains transducer position candidates with the following columns. This information can be used to select a suitable candidate (e.g., by selecting a position with low intersection of the transducer and head tissue (prop_intersect < 0.05) and minimal Euclidean distance to the target; often supported by visual inspection). -| Column | Description | -|-----------------|------------------------------------------------| -| idx | index | -| trans_x | Transducer x-coordinate (voxels) | -| trans_y | Transducer y-coordinate (voxels) | -| trans_z | Transducer z-coordinate (voxels) | -| targ_x | Target x-coordinate (voxels) | -| targ_y | Target y-coordinate (voxels) | -| targ_z | Target z-coordinate (voxels) | -| dist_to_target | Euclidean distance transducer-to-target (voxels)| -| prop_intersect | Proportion of transducer volume intersecting head | -| meandistskin | Mean distance to skin surface in aperture plane (voxels) | -| vardistskin | Variance of distances to skin (voxels²) | -| meandistskull | Mean distance to skull surface in aperture plane (voxels)| -| vardistskull | Variance of distances to skull (voxels²) | +| Metric | Description | Interpretation | +|-----------------|------------------------------------------------|-----------------| +|`idx` | index | | +|`trans_x` | Transducer x-coordinate (voxels) | | +|`trans_y` | Transducer y-coordinate (voxels) | | +|`trans_z` | Transducer z-coordinate (voxels) | | +|`targ_x` | Target x-coordinate (voxels) | | +|`targ_y` | Target y-coordinate (voxels) | | +|`targ_z` | Target z-coordinate (voxels) | | +|`dist_to_target` | Euclidean distance transducer-to-target (voxels)| | +|`prop_intersect` | Fraction of voxels in the transducer’s orthogonal plane that intersect with the skull mask. | Low values (≈ 0) indicate minimal skull obstruction; high values (> 0.3) indicate significant bone interference. | +|`mean_dist_skin` | Mean distance from **non‑intersecting** voxels to the skin boundary. | Indicates average proximity of the plane to the skin surface. | +|`var_dist_skin` | Variance of distances from non‑intersecting voxels to the skin boundary. | Higher variance → surface curvature or oblique intersection. | +|`mean_dist_skull`| Mean distance from **all** voxels in the plane to the nearest skull boundary. | Shows average clearance between the plane and skull. | +|`var_dist_skull` | Variance of distances to the skull boundary. | Quantifies irregularity of skull spacing within the beam footprint. | + ### Remove ear locations **[Optional]** -For practical reasons, transducer locations overlapping with the ears are not desired. Possible locations that overlap with ear positions can optionally be removed within an `tp_ear_radius` (Radius of the nogo zone, mm) around `tp_left_ear_center` (approximate coordinates for left ear, voxels) and `tp_right_ear_center`. +For practical reasons, transducer locations overlapping with the ears are not desired. Possible locations that overlap with ear positions can optionally be removed within an `tp_ear_radius` (Radius of the nogo zone, mm) around `tp_left_ear_center` (approximate coordinates for left ear, voxels) and `tp_right_ear_center`. ### Select heuristic transducer position -The desired criterion for intersection with the skin can be defined via `tp_criterion_intersection`. If no placement is found wihtin this criterion, the intersection will be iteratively expanded by 1% until a match is identified. +The desired criterion for intersection with the skin can be defined via `tp_criterion_intersection`. If no placement is found within this criterion, the intersection will be iteratively expanded by `tp_expand_step` (default: 1%) until a match is identified. -> Other criteria are not currently implemented. e.g.: -``` -%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),:); -``` +| Criterion | Default | Explanation | +|:----------|---------------|-------------| +| `tp_criterion_intersect` | < 0.05 | [Fraction] Reduce skull traversal | +| `tp_criterion_skin_mean` | NaN | [Quantile] Short EP distance to skin | +| `tp_criterion_skull_mean` | NaN | [Quantile] Short EP distance to skull | +| `tp_criterion_skin_var` | NaN | [Quantile] Coherent transducer exit plane toward skin (e.g., little curvature) | +| `tp_criterion_skull_var` | NaN | [Quantile] Coherent transducer exit plane toward skull surface (Irregular bone → phase distortion → incoherent wavefront) | Amongst locations fulfilling the above criteria, the location with a minimum distance to the target is selected. diff --git a/functions/transducer/tp_evaluate_candidate_positions.m b/functions/transducer/tp_evaluate_candidate_positions.m index 8716e44e..e0f5c18d 100644 --- a/functions/transducer/tp_evaluate_candidate_positions.m +++ b/functions/transducer/tp_evaluate_candidate_positions.m @@ -34,21 +34,24 @@ 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); -shifted_trans_pos_coords = gather(shifted_trans_pos_coords); +trans_pos = gather(trans_pos); metrics = gather([prop_intersect; mean_dts; var_dts; mean_dist_skull; var_dist_skull]'); -dist_to_target = gather(pdist2(shifted_trans_pos_coords, target)); +dist_to_target = gather(pdist2(trans_pos, target)); -tpos_pars = array2table([close_enough_idx, shifted_trans_pos_coords, ... +tpos_pars = array2table([close_enough_idx, trans_pos, ... repmat(target, N_cand, 1), ... dist_to_target, ... metrics], ... diff --git a/functions/transducer/tp_plot_heuristic_position.m b/functions/transducer/tp_plot_heuristic_position.m index bffe86ef..451ba64b 100644 --- a/functions/transducer/tp_plot_heuristic_position.m +++ b/functions/transducer/tp_plot_heuristic_position.m @@ -1,11 +1,11 @@ -function tp_plot_heuristic_position(trans_pos, target_pos, img, img_info, parameters, pixel_size, target_name, subject_id) +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_info - NIfTI header info +% img_header - NIfTI header info % parameters - Parameters struct % pixel_size - Scalar voxel size in mm % target_name - String target identifier @@ -23,7 +23,7 @@ function tp_plot_heuristic_position(trans_pos, target_pos, img, img_info, parame [img_rotated, trans_xyz_rotated, target_xyz_rotated, transformation_matrix, ~, ~, ~, ~] = ... preproc_align_to_focal_axis(... - img, img_info, round(trans_xyz)', target_xyz', 1, parameters); + img, img_header, round(trans_xyz)', target_xyz', 1, parameters); %% Visualization diff --git a/functions/transducer/tp_select_heuristic_position.m b/functions/transducer/tp_select_heuristic_position.m index d2d0e515..fe060bab 100644 --- a/functions/transducer/tp_select_heuristic_position.m +++ b/functions/transducer/tp_select_heuristic_position.m @@ -1,5 +1,5 @@ -function [trans_pos, target_pos, best_trans_pos] = ... - tp_select_heuristic_position(locs, pixel_size, subject_id, target_name, parameters) +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: @@ -9,78 +9,165 @@ % % INPUT % locs - Table with transducer candidate metrics (from tp_evaluate_candidate_positions) -% pixel_size - Scalar voxel size (mm/voxel) % 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 - [1x3] integer voxel coordinates [x y z] -% target_pos - [1x3] integer voxel coordinates [x y z] -% best_trans_pos - Full table row of HEURISTIC position with added Localite fields - -%% Select optimal position (multi-criteria heuristic) -%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),:); - -%% Check for configurable intersection criterion -if nargin < 5 || ~isfield(parameters, 'tp_criterion_intersection') || isempty(parameters.tp_criterion_intersection) - criterion_intersection = 0.05; % Default - fprintf('[TP_HEURISTIC] Using default intersection criterion: %.2f\n', criterion_intersection); +% 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( ... + 'tp_criterion_intersect', 0.05, ... + 'tp_criterion_skin_mean', NaN, ... + 'tp_criterion_skull_mean', NaN, ... + 'tp_criterion_skin_var', NaN, ... + 'tp_criterion_skull_var', NaN, ... + 'tp_expand_step', 0.01 ... +); + +% Fill in missing parameter fields +fields = fieldnames(defaults); +for k = 1:numel(fields) + if ~isfield(parameters, fields{k}) || isempty(parameters.(fields{k})) + parameters.(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.tp_criterion_intersect) + criterion = parameters.tp_criterion_intersect; + tppf = locs(locs.prop_intersect < criterion, :); + + while isempty(tppf) && criterion < 0.3 + criterion = criterion + parameters.tp_expand_step; + tppf = locs(locs.prop_intersect < criterion, :); + end else - criterion_intersection = parameters.tp_criterion_intersection; - fprintf('[TP_HEURISTIC] Using specified intersection criterion: %.2f\n', criterion_intersection); + tppf = locs; +end + +%% STEP 2 — Skin proximity +if ~isnan(parameters.tp_criterion_skin_mean) && ~isempty(tppf) + % Use quantile + skin_lim = quantile(tppf.mean_dist_skin, parameters.tp_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 -%% Multi-step HEURISTIC selection with adaptive criterion -tppf = locs(locs.prop_intersect < criterion_intersection, :); +%% STEP 3 — Skull variance + +if ~isnan(parameters.tp_criterion_skull_var) && ~isempty(tppf) + % Use quantile + skull_var_lim = quantile(tppf.var_dist_skull, parameters.tp_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 -% Auto-expand criterion if no candidates found -if isempty(tppf) - criterion_intersection = criterion_intersection + 0.01; - warning('Expanding intersection criterion to %.2f (no candidates at %.2f)', ... - criterion_intersection, criterion_intersection - 0.01); - tppf = locs(locs.prop_intersect < criterion_intersection, :); +%% STEP 4 — Skull mean distance (always applied) +if ~isnan(parameters.tp_criterion_skull_mean) && ~isempty(tppf) + % Use quantile + skull_mean_lim = quantile(tppf.mean_dist_skull, parameters.tp_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 -% Select minimum distance to target -tppf = tppf(tppf.dist_to_target == min(tppf.dist_to_target), :); -i = find(locs.idx == tppf.idx(1)); % Global index in original locs table +%% STEP 5 — Skin variance +if ~isnan(parameters.tp_criterion_skin_var) && ~isempty(tppf) + % Use quantile + skin_var_lim = quantile(tppf.var_dist_skin, parameters.tp_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 = floor(table2array(best_trans_pos(1, ["trans_x", "trans_y", "trans_z"]))); -target_pos = floor(table2array(best_trans_pos(1, ["targ_x", "targ_y", "targ_z"]))); +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) -trans_pos_localite = round(trans_pos * pixel_size); -target_pos_localite = round(target_pos * pixel_size); +% 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.loc_trans_x = trans_pos_localite(1); -best_trans_pos.loc_trans_y = trans_pos_localite(2); -best_trans_pos.loc_trans_z = trans_pos_localite(3); +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.loc_targ_x = target_pos_localite(1); -best_trans_pos.loc_targ_y = target_pos_localite(2); -best_trans_pos.loc_targ_z = target_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.output_dir, sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); -writetable(best_trans_pos, table_path, 'Delimiter', 'tab'); +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, 'localite_path') && ~isempty(parameters.localite_path) localite_path = fullfile(parameters.localite_path,... sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); - writetable(best_trans_pos, localite_path, 'Delimiter', 'tab'); + writetable(table_formatted, localite_path, 'Delimiter', 'tab', 'WriteVariableNames', false); fprintf('[TP_HEURISTIC] Additional Localite copy saved: %s\n', localite_path); end diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index cab9e44a..04f74cb3 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -22,8 +22,8 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar filename = fullfile(m2m_folder, 'final_tissues.nii.gz'); img = niftiread(filename); img = gather(img); % Ensure img is on CPU -img_info = niftiinfo(filename); -voxel_size = mean(img_info.PixelDimensions); +img_header = niftiinfo(filename); +voxel_size = mean(img_header.PixelDimensions); % [DEBUG] plot the segmentation if parameters.debug @@ -52,7 +52,7 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar fprintf('➤ Target: %s\n', target_name); target_mni = mni_targets.(target_name); target_vox = transform_coordinates(... - parameters, target_mni, 'mni', 'grid', img_info); + parameters, target_mni, 'mni', 'grid', img_header); %% Find candidate transducer positions on skull (expanding sphere) @@ -84,10 +84,6 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar tpos = readtable(tpos_output_file, 'Delimiter', ','); end -%% [Optional] Convert positions to RAS - -% TO DO - %% [Optional] Remove ear locations [tpos] = tp_remove_ear_locations(parameters, tpos); @@ -95,12 +91,12 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar %% Select heuristic transducer position [trans_pos, target_pos, ~] = ... - tp_select_heuristic_position(tpos, voxel_size, subject_id, target_name, parameters); + 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_info, parameters, voxel_size, target_name, subject_id); + 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); diff --git a/functions/transform/transform_coordinates.m b/functions/transform/transform_coordinates.m index 8a886acd..50ee6d1c 100644 --- a/functions/transform/transform_coordinates.m +++ b/functions/transform/transform_coordinates.m @@ -37,9 +37,9 @@ if strcmp(output_cs, 'grid') disp("Mapping RAS+ to subject grid...") - output_pos = ras_to_grid(input_pos, nii_hdr); + output_pos = round(transformPointsInverse(nii_hdr.Transform, input_pos)); % alternative: - % output_pos = round(transformPointsInverse(nii_hdr.Transform, input_pos)); + % output_pos = ras_to_grid(input_pos, nii_hdr); else error('RAS+ input, output_cs: ''grid'' only'); end @@ -49,8 +49,10 @@ if strcmp(output_cs, 'ras_plus') % Voxel -> RAS+ (forward affine) disp("Mapping subject grid to subject RAS+...") - tmp = nii_hdr.Transform.T * [input_pos(:); 1]; - output_pos = tmp(1:3)'; + 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 From 7c2900322a241aaeb05e28e03f44781383efe947 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Sat, 14 Mar 2026 18:00:48 +0100 Subject: [PATCH 122/144] [doc] add example localite output table --- documentation/doc_placement_heuristic.md | 43 ++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/documentation/doc_placement_heuristic.md b/documentation/doc_placement_heuristic.md index 0b71a275..c94c6fbe 100644 --- a/documentation/doc_placement_heuristic.md +++ b/documentation/doc_placement_heuristic.md @@ -53,6 +53,45 @@ The desired criterion for intersection with the skin can be defined via `tp_crit Amongst locations fulfilling the above criteria, the location with a minimum distance to the target is selected. +An an output, this function will create a table that contains the coordinates in **voxel (grid space)**, **mm (Localite space)**, and **mm (RAS+ space)** (based on the image header of the segmentation image). + +> IMPORTANT: Localite mm coordinates assume that Localite planning image has `canonical_affine_transform` applied (see [below](#save-localite-planning-image))! The Localite coordinates are simply rescaled by the specified voxel sizes in the header assuming no affine transformations. Always visually verify coordinates ... + +**Example coordinate output table**: + +
+ +trans_x 161 +trans_y 138 +trans_z 246 +targ_x 117 +targ_y 139 +targ_z 161 + +<...> + +LOCALITE_Transducer_mm Inf +loc_trans_x_mm 144.9 +loc_trans_y_mm 123.09 +loc_trans_z_mm 221.02 + +LOCALITE_Target_mm Inf +loc_targ_x_mm 105.3 +loc_targ_y_mm 124.88 +loc_targ_z_mm 144.65 + +RAS_Transducer_mm Inf +ras_trans_x_mm 43.35 +ras_trans_y_mm -16.17 +ras_trans_z_mm 82.3 + +RAS_Target_mm Inf +ras_targ_x_mm 5.45 +ras_targ_y_mm -20.71 +ras_targ_z_mm 5.19 + +
+ ### Plot heuristic transducer position PRESTUS generates an overview of the selected transducer placement: @@ -61,8 +100,8 @@ 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 T1w with localite-ready header +### Save Localite planning image **[Optional]** - Localite can struggle with canonical T1 header affine matrices. 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. + 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. From 4aa2eb4baf7c19199aaaa023f8f7b936e92197e5 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Sat, 14 Mar 2026 18:04:36 +0100 Subject: [PATCH 123/144] [doc] reformat table --- documentation/doc_placement_heuristic.md | 37 +++++++++++------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/documentation/doc_placement_heuristic.md b/documentation/doc_placement_heuristic.md index c94c6fbe..a24a6a5d 100644 --- a/documentation/doc_placement_heuristic.md +++ b/documentation/doc_placement_heuristic.md @@ -58,24 +58,22 @@ An an output, this function will create a table that contains the coordinates in > IMPORTANT: Localite mm coordinates assume that Localite planning image has `canonical_affine_transform` applied (see [below](#save-localite-planning-image))! The Localite coordinates are simply rescaled by the specified voxel sizes in the header assuming no affine transformations. Always visually verify coordinates ... **Example coordinate output table**: - -
- -trans_x 161 -trans_y 138 -trans_z 246 -targ_x 117 -targ_y 139 -targ_z 161 - -<...> - -LOCALITE_Transducer_mm Inf -loc_trans_x_mm 144.9 -loc_trans_y_mm 123.09 -loc_trans_z_mm 221.02 - -LOCALITE_Target_mm Inf +
+trans_x	161     
+trans_y	138     
+trans_z	246     
+targ_x	117     
+targ_y	139     
+targ_z	161     
+
+<...>       
+
+LOCALITE_Transducer_mm	Inf       
+loc_trans_x_mm	144.9       
+loc_trans_y_mm	123.09      
+loc_trans_z_mm	221.02      
+
+LOCALITE_Target_mm	Inf     
 loc_targ_x_mm	105.3
 loc_targ_y_mm	124.88
 loc_targ_z_mm	144.65
@@ -89,8 +87,7 @@ RAS_Target_mm	Inf
 ras_targ_x_mm	5.45
 ras_targ_y_mm	-20.71
 ras_targ_z_mm	5.19
-
-
+ ### Plot heuristic transducer position From b8bb150f5f2416b9a11244264ef0b72a2e2f5a85 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Sat, 14 Mar 2026 18:36:55 +0100 Subject: [PATCH 124/144] [doc] refactor neuronav placement and coordinates --- documentation/README.md | 2 +- documentation/doc_coordinate_systems.md | 32 +++++++++ documentation/doc_neuronav.md | 95 ------------------------- documentation/doc_placement_neuronav.md | 68 ++++++++++++++++++ mkdocs.yaml | 3 +- 5 files changed, 103 insertions(+), 97 deletions(-) create mode 100644 documentation/doc_coordinate_systems.md delete mode 100644 documentation/doc_neuronav.md create mode 100644 documentation/doc_placement_neuronav.md diff --git a/documentation/README.md b/documentation/README.md index 928429b9..f5d1a478 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -13,7 +13,7 @@ Key features of the modular end-to-end pipeline include: - Multi-layer [medium property mapping](doc_medium.md) (water, skin, multi-layer skull, and brain). - [(pseudo-)CT](doc_pseudoCT.md)-informed continuous skull mapping. - Virtual multi-element [transducer calibration](doc_calibration.md) (free-water profile emulation). -- Estimation of entry-target [coordinates](doc_placement.md) incl. [neuronavigation](doc_neuronav.md) read-in. +- Estimation of entry-target [coordinates](doc_placement.md) incl. [heuristic placement](doc_placement_heuristic.md) and [neuronavigation](doc_placement_neuronav.md) read-in. - Flexible [temporal protocol specification](doc_simulations-thermal.md) (e.g., including breaks). - k-Wave integration for robust [acoustic](doc_simulations-acoustic.md) and [thermal](doc_simulations-thermal.md) simulations. - Support for [high-performance computing](doc_hpc.md). diff --git a/documentation/doc_coordinate_systems.md b/documentation/doc_coordinate_systems.md new file mode 100644 index 00000000..043e7f9f --- /dev/null +++ b/documentation/doc_coordinate_systems.md @@ -0,0 +1,32 @@ +# Coordinate Reference Systems + +The function `transform_coordinates` can be used to transform coordinates between coordinate reference systems. + +1. Subject Space: planning image [voxel indices] (`grid`) + - Reference: Specific image grid (subject’s T1 MRI, segmentation) + - Orientation: Based on image storage; mapping real anatomy depends on image header “transform”. +2. Subject Space: localite planning image [mm] (`ras_plus` with adjusted header) + - Subject space in mm without any affine transform to world space (see [the localite planning image](doc_placement_heuristic.md#save-localite-planning-image)). + - Use the header from the adjusted localite planning image for the transform. +2. World (Scanner) Space / RAS (Right-Anterior-Superior) [mm] (`ras_plus`) + - Reference: Physical space of scanner (native) or MNI brain. + - Orientation: RAS (Right-Anterior-Superior, SPM/FreeSurfer/FSL convention); origin location varies by header (e.g., AC). +4. MNI Space RAS (Right-Anterior-Superior) [mm] (`mni`) + - Reference: Standard stereotactic space, e.g., MNI152 (often referred to as “world” space in standard template). + - Note: Not usually 0-centered; (0,0,0) refers to AC in MNI, which can be off-center in template volume. +5. MNI Space [voxel indices] (`undefined`; see below) + - Reference: Specific image (often 181×217×181 grid), for operations like drawing spheres or extracting image data. + - Conversion: Use template’s affine matrix to convert MNI mm ↔ MNI voxel indices. + + ``` + template_affine = niftiinfo('MNI152_T1_1mm.nii.gz').Transform.T; + inv_affine = inv(template_affine); + mni_vox = (inv_affine * [mni_mm 1]')'; % yields [i j k 1], take [1:3] + ``` + +#### Available transforms + +PRESTUS reuses available nonlinear transform matrices from SimNIBS. + +- CHARM’s `Conform2MNI_nonl.nii.gz` is a nonlinear deformation field that maps points from an individual SUBJECT’S SCANNER/CONFORM (native) space to MNI (template) space, expressed in millimeters. +- `MNI2Conform_nonl.nii.gz` is the inverse of `Conform2MNI_nonl.nii.gz`. It is a 4D nonlinear deformation field created by CHARM/SimNIBS that maps coordinates from MNI (template) space back into the subject’s native millimeter (mm) space. \ No newline at end of file diff --git a/documentation/doc_neuronav.md b/documentation/doc_neuronav.md deleted file mode 100644 index 4818473b..00000000 --- a/documentation/doc_neuronav.md +++ /dev/null @@ -1,95 +0,0 @@ -# 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. Multiple triggers can be sent during stimulation to acquire updates to the transducer location over time. Recorded locations will be recorded by localite to files named `TMSTrigger/TriggerMarkers_Coil0_'. - -Recorded locations will be averaged within a position (associated with a continuous time stamp stream). Multiple positions per recording session are supported. Average transducer and target locations can be provided in multiple coordinate spaces (native image space being the most relevant for PRESTUS). The functions also facilitate standard-space definitons (experimental), e.g., to plot average transducer positions in standard space. - -To get started, see an example demo (wihtout provided data) in `/examples/demo_localite.m`. - -### Relevant spaces and coordinate systems - -1. **Subject Space [planning image] (voxels)** - - Type: Integer (i, j, k) indices. E.g., 93 116 78 - - Reference: Specific image grid (subject’s T1 MRI, subject’s own segmentation, or MNI template image) - - Usage: Access data arrays; relates directly to matrix dimensions. - - Orientation: Based on image storage; mapping real anatomy depends on image header “transform”. - - Columns in output CSV: - - `Mtrans_pos_x`, `Mtrans_pos_y`, `Mtrans_pos_z` - - `Mtarget_pos_x`, `Mtarget_pos_y`, `Mtarget_pos_z` -2. **Subject Space [segmentation image] (voxels)** - - same as above, but for the simnibs segmentation image -3. **World (Scanner) Space / RAS (Right-Anterior-Superior) (mm)** - - Type: Real (x, y, z) coordinates, in millimeters. - - Reference: Physical space of scanner (native) or MNI brain. - - Usage: Anatomical localization; input for nonlinear warps and overlays across images. - - Orientation: RAS (Right-Anterior-Superior, SPM/FreeSurfer/FSL convention); origin location varies by header (e.g., AC). -4. **MNI Space RAS (Right-Anterior-Superior) (mm)** - - Type: Real (x, y, z) in millimeters. - - Reference: Standard stereotactic space, e.g., MNI152 (often referred to as “world” space in standard template). - - Usage: Cross-subject reporting, group analysis, publication, plotting in standard space. - - Note: Not usually 0-centered; (0,0,0) refers to AC in MNI, which can be off-center in template volume. - - Columns in output CSV (nonlinear deformation field (e.g., `Conform2MNI_nonl.nii.gz`) using the SimNIBS/CHARM pipeline): - - `Mtrans_pos_MNI_x`, `Mtrans_pos_MNI_y`, `Mtrans_pos_MNI_z` - - `Mtarget_pos_MNI_x`, `Mtarget_pos_MNI_y`, `Mtarget_pos_MNI_z` -5. **MNI Space (voxels)** - - Type: Integer voxel (i, j, k) indices within a template MNI image. - - Reference: Specific image (often 181×217×181 grid), for operations like drawing spheres or extracting image data. - - Conversion: Use template’s affine matrix to convert MNI mm ↔ MNI voxel indices. - ``` - template_affine = niftiinfo('MNI152_T1_1mm.nii.gz').Transform.T; - inv_affine = inv(template_affine); - mni_vox = (inv_affine * [mni_mm 1]')'; % yields [i j k 1], take [1:3] - ``` - -### Coordinate Spaces used in PRESTUS neuronav functions - -- neuronav_convert_trigger_to_voxels - - Input: Localite/trigger data (usually in RAS mm) - - Converts: RAS mm → subject (native) voxel space - - Output: grid/voxel indices (i,j,k for subject’s T1) for targets/transducers -- neuronav_grid_to_mm_batch - - Input: Subject voxel indices (i,j,k) - - Operation: Uses subject’s T1 NIfTI affine transform - - Output: Subject native mm coordinates (in scanner/world space) -- neuronav_apply_deformation (forward warp) - - Input: Native mm locations - - Operation: Queries subject’s Conform2MNI_nonl.nii.gz (subject-native → MNI (mm)) - - Output: MNI mm coordinates (for reporting/plotting/averaging) -- neuronav_get_group_mean_mni - - Input: CSVs containing columns like Mtrans_pos_MNI_x/y/z (all from previous function outputs) - - Operation: Aggregates these MNI mm locations across subjects/sessions, computes mean (in mm) - - Output: MNI mm group-mean locations (still in millimeters, not voxels) -- neuronav_apply_inverse_deformation (inverse warp) - - Input: MNI mm group-mean locations (x, y, z in mm) - - Operation: Uses subject’s MNI2Conform_nonl.nii.gz nonlinear field to map standard points back into that subject’s native space. - - Output: Native mm location (in subject’s world/scanner space, for that subject) -- neuronav_mm_to_voxel (helper) - - Input: Native mm (x, y, z) location - - Operation: Uses subject T1 affine to map mm → voxel (i, j, k) - - Output: Subject voxel indices for subsequent image work or storage -- neuronav_export_session_csv - - Columns include: - - Native (subject) voxel indices for target/transducer: (i, j, k from subject’s grid) - - Native mm coordinates (if desired): (x, y, z in subject’s scanner space) - - MNI mm coordinates for each (from deformation step): (x, y, z in standard space) - - MNI voxel indices (may be inaccurate with limited FOVs) - -#### Example Transform order (per subject) - 1. Trigger to Voxel (neuronav_convert_trigger_to_voxels): → subject voxel indices - 2. Voxel → mm (neuronav_grid_to_mm_batch): → subject mm - 3. Native mm → MNI mm (neuronav_apply_deformation): → MNI mm - # [Optional]: Interpolate average positions across subjects - 4. Aggregate means in MNI mm across subjects (neuronav_get_group_mean_mni) - 5. Group mean MNI mm → subject mm (neuronav_apply_inverse_deformation) - 6. Subject mm → subject voxel (neuronav_mm_to_voxel) - -#### Available transforms - -PRESTUS reuses available nonlinear transform matrices from SimNIBS. - -- CHARM’s `Conform2MNI_nonl.nii.gz` is a nonlinear deformation field that maps points from an individual SUBJECT’S SCANNER/CONFORM (native) space to MNI (template) space, expressed in millimeters. -- `MNI2Conform_nonl.nii.gz` is the inverse of `Conform2MNI_nonl.nii.gz`. It is a 4D nonlinear deformation field created by CHARM/SimNIBS that maps coordinates from MNI (template) space back into the subject’s native millimeter (mm) space. - -- **Note:** recorded localite coordinates are relative to a neuronavigation planning image [1] (e.g., `T1_forneuronav`) that was loaded in the neuronavigation software. SimNIBS' nonlinear transform matrices to MNI are relative to the `final_tissues.nii.gz` segmentation image [2] however, so we first need to apply a transform from the planning to the segmentation image to the coordinates before they can be ported to MNI space. \ No newline at end of file diff --git a/documentation/doc_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/mkdocs.yaml b/mkdocs.yaml index f139be9c..44a67519 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -45,10 +45,11 @@ nav: - 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 - - Neuronavigation Placement: doc_neuronav.md - 🔬 Simulations: - Acoustic: doc_simulations-acoustic.md - Thermal: doc_simulations-thermal.md From e9cdc3c8e5a1bb996837b574d38c65b7e4e8e0f3 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Sat, 14 Mar 2026 19:12:16 +0100 Subject: [PATCH 125/144] [pct] regularize kplan skull sound speed mapping to 1500 --- documentation/doc_pseudoCT.md | 2 ++ functions/medium/medium_pct_soundspeed.m | 3 +++ 2 files changed, 5 insertions(+) diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 170d2f63..77d76570 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -147,6 +147,8 @@ Mapping algorithm is goverened by `pct_mapping_soundspeed`. $$ 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}} $$ diff --git a/functions/medium/medium_pct_soundspeed.m b/functions/medium/medium_pct_soundspeed.m index 386089f8..5aeffc3c 100644 --- a/functions/medium/medium_pct_soundspeed.m +++ b/functions/medium/medium_pct_soundspeed.m @@ -5,6 +5,9 @@ sound_speed(skull_idx) = 1.33.*density(skull_idx) + 167; + % regularize minimum to sound speed in water + sound_speed(skull_idx) = max(parameters.medium.water.sound_speed, sound_speed(skull_idx)); + case 'marsac' c_water = parameters.medium.water.sound_speed; % sound speed [m/s] From ce2b7b0a3fc540aae36aaa48deaf56cb94219371 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Sat, 14 Mar 2026 19:15:29 +0100 Subject: [PATCH 126/144] [pct doc] add comment on kPlan densities --- documentation/doc_pseudoCT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 77d76570..50d7fcfd 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -82,6 +82,8 @@ Mapping algorithm is goverened by `pct_mapping_density`. density = [1.2, 1060, 1530, 2150] ``` + > 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... + **Thermal simulations** Overwrite bone density prior to the thermal simulation: From 732c39a9ea88399d44de6a8d61f42c49c05a63f8 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 16 Mar 2026 14:57:03 +0100 Subject: [PATCH 127/144] [calibration] ensure transducer distances are present --- functions/calibration/plot_opt_sim_results.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/functions/calibration/plot_opt_sim_results.m b/functions/calibration/plot_opt_sim_results.m index 3c430c67..928d8ea7 100644 --- a/functions/calibration/plot_opt_sim_results.m +++ b/functions/calibration/plot_opt_sim_results.m @@ -95,6 +95,8 @@ function plot_opt_sim_results(parameters, profile_target, profile_oneil, profile %% 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); From 359ac7c5f34716f740e9cd7d69fdd229cca13bab Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 16 Mar 2026 15:28:54 +0100 Subject: [PATCH 128/144] [hpc] hotfix cpu/gpu choice --- functions/hpc/hpc_submit_job.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/hpc/hpc_submit_job.m b/functions/hpc/hpc_submit_job.m index dd7e3c25..3e8bf591 100644 --- a/functions/hpc/hpc_submit_job.m +++ b/functions/hpc/hpc_submit_job.m @@ -47,18 +47,18 @@ function write_slurm_script(temp_slurm_path, parameters, subject_id, temp_m_file fprintf(fid, '#SBATCH --job-name=%s\n', job_name); % Partition & GPU detection - needs_gpu = isfield(parameters, 'hpc_partition') || ... - (isfield(parameters, 'code_type') && any(strcmp(parameters.code_type, {'matlab_gpu', 'cpp_gpu'}))); + needs_gpu = (isfield(parameters, 'code_type') && any(strcmp(parameters.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'); - fprintf(fid, '#SBATCH --gres=gpu:1\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))) From 4e23050e69e7973187ecd9987e8486967dd48334 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 16 Mar 2026 15:30:02 +0100 Subject: [PATCH 129/144] [segmentation] fix log directory to segmentation folder --- functions/head/segmentation_run.m | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/functions/head/segmentation_run.m b/functions/head/segmentation_run.m index a7238970..04148d10 100644 --- a/functions/head/segmentation_run.m +++ b/functions/head/segmentation_run.m @@ -5,9 +5,9 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param parameters.seg_path = 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.seg_path, 'batch_job_logs'); if ~isfolder(log_dir) mkdir(log_dir) end @@ -53,14 +53,23 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param end end - % if not running on a qsub or slurm HPC, the job will stop to run the segmentation manually + % 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)), ... + fullfile(log_dir, sprintf('%s_qsub_segment_error_$timestamp.log', subj_id_string)), ... parameters.seg_path); - % execute simnibs call in segmentation directory full_cmd = sprintf('cd %s; timestamp=$(date +%%Y%%m%%d_%%H%%M%%S); echo "%s/%s" | %s', ... From 76cdeef7af67e026c1234066573da323da25e28f Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Mon, 16 Mar 2026 16:38:04 +0100 Subject: [PATCH 130/144] [hotfix] unify hpc_wait_for_job --- documentation/doc_functions.md | 2 +- functions/calibration/calibration_transducer.m | 4 ++-- functions/hpc/hpc_detect_system.m | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index 85b83d72..3ba64abc 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -99,7 +99,7 @@ The following documents the functions provided in PRESTUS. | `hpc_setup_temp_files` | Setup directories and generate temporary files. | | `hpc_submit_job` | Submit HPC batch job (SLURM or qsub). | | `hpc_validate_parameters` | Validate HPC job parameters. | -| `hpc_wait_for_completion` | Monitor HPC job until completion. | +| `hpc_wait_for_job` | Monitor HPC job until completion. | #### MEDIUM diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index f298eb01..ea6e9ef4 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -93,7 +93,7 @@ sim_param.interactive = 0; % Run the simulation based on the submission method - sim_param.hpc_wait_for_completion = true; + sim_param.hpc_wait_for_job = true; prestus_pipeline_start(sim_id, sim_param); %% Load initial results @@ -160,7 +160,7 @@ opt_param.transducer.source_phase_deg = opt_source_phase_deg; opt_param.results_filename_affix = '_optimized'; - opt_param.hpc_wait_for_completion = true; + opt_param.hpc_wait_for_job = true; prestus_pipeline_start(sim_id, opt_param); %% Load optimized simulation results diff --git a/functions/hpc/hpc_detect_system.m b/functions/hpc/hpc_detect_system.m index 226b4244..2778c910 100644 --- a/functions/hpc/hpc_detect_system.m +++ b/functions/hpc/hpc_detect_system.m @@ -3,8 +3,6 @@ % % Detects available HPC scheduler by checking for sbatch (SLURM) or qsub. % Returns 'slurm', 'qsub', or throws error if neither found. -% -% See also HPC_SUBMIT_JOB, HPC_WAIT_FOR_COMPLETION. [sbatch_out, sbatch_path] = system('which sbatch'); [qsub_out, qsub_path] = system('which qsub'); From fd8486b35cb9fe7c1fc38ee30b2e6c05e621aaf5 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 17 Mar 2026 11:33:36 +0100 Subject: [PATCH 131/144] [hpc] hotfix: wait for job --- documentation/doc_functions.md | 36 ++++---- functions/core/prestus_pipeline_start.m | 1 - functions/hpc/hpc_wait_for_completion.m | 107 ++++++++++++++++-------- 3 files changed, 92 insertions(+), 52 deletions(-) diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index 3ba64abc..d2e33230 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -6,8 +6,8 @@ The following documents the functions provided in PRESTUS. | **Function Name** | **Description** | |-----------------------------------------|-------------------------------------------------------------------------------------------------| -| `acoustic_analysis` | Compute acoustic safety metrics (Isppa, MI, max pressure) from sensor data, extract tissue-specific maxima, focal distances, and generate overlaid ISPPA plots on segmentation | -| `acoustic_convert_axisymmetry` | Expand k-Wave axisymmetric (2D rotational) simulation outputs to full 3D (via  convert_axisymmetric_to_3d ) for heating sims or 2D Cartesian (via  convert_axisymmetric_to_2d ) otherwise, updating sensor data, parameters, masks, medium, grid, source, and labels. | +| `acoustic_analysis` | Compute acoustic safety metrics (Isppa, MI, max pressure) from sensor data, extract tissue-specific maxima, focal distances, and generate overlaid ISPPA plots on segmentation. | +| `acoustic_convert_axisymmetry` | Expand k-Wave axisymmetric (2D rotational) simulation outputs to full 3D (via  convert_axisymmetric_to_3d ) for heating sims or 2D Cartesian (via  convert_axisymmetric_to_2d ) otherwise, updating sensor data, parameters, masks, medium, grid, source, and labels. | | `acoustic_simulation` | Set up acoustic simulation | | `acoustic_wrapper` | Set up acoustic simulation | @@ -16,19 +16,19 @@ The following documents the functions provided in PRESTUS. | **Function Name** | **Description** | |-----------------------------------------|-------------------------------------------------------------------------------------------------| | `calc_virtual_elem` | (STANDALONE) Calculate additional virtual elements | -| `calibration_transducer` | Run water simulations to optimize transducer source amplitude and element phases matching a target intensity profile via O’Neil analytical solution, global search, and re-simulation. | -| `compute_oneil_solution` | Compute analytical O’Neil pressure along the beam axis for focused annular transducers in water, derives particle velocity and grid adjustment factor, and plots comparisons with simulated/desired intensities. | -| `compute_phases` | Calculate per-element transducer phases (degrees) for electronic focusing by computing fractional wavelength delays to steer waves constructively to a target point relative to the exit plane. | -| `extract_real_intensity_profile` | Extract or spline-interpolate axial intensity profiles between available measured focal depths (wrt exit plane), aligns peaks, skips near-field for max, and plots/saves results. | +| `calibration_transducer` | Run water simulations to optimize transducer source amplitude and element phases matching a target intensity profile via O’Neil analytical solution, global search, and re-simulation. | +| `compute_oneil_solution` | Compute analytical O’Neil pressure along the beam axis for focused annular transducers in water, derives particle velocity and grid adjustment factor, and plots comparisons with simulated/desired intensities. | +| `compute_phases` | Calculate per-element transducer phases (degrees) for electronic focusing by computing fractional wavelength delays to steer waves constructively to a target point relative to the exit plane. | +| `extract_real_intensity_profile` | Extract or spline-interpolate axial intensity profiles between available measured focal depths (wrt exit plane), aligns peaks, skips near-field for max, and plots/saves results. | | `extract_simulated_profile` | Extract and visualize simulated acoustic pressure data. | -| `perform_global_search` | Globally optimize transducer element phases (rad) and particle velocity via FEXminimize or GlobalSearch to minimize error fitting O’Neil analytical intensity to measured axial profiles. | +| `perform_global_search` | Globally optimize transducer element phases (rad) and particle velocity via FEXminimize or GlobalSearch to minimize error fitting O’Neil analytical intensity to measured axial profiles. | | `phase_optimization_annulus_full_curve` | Optimizes the phase profile for an annular transducer by matching intensity curves. | | `phase_optimization_annulus` | Optimizes the phase profile by calculating focal distance error for an annular transducer. | -| `plot_opt_sim_results` | Load optimized k-Wave results, generate 2D focal plane intensity maps and axial profile comparisons (simulated vs. analytical O’Neil vs. desired), and save annotated plots. | -| `recompute_oneil_solution` | Recompute O’Neil analytical pressure profile using optimized phases/velocity, convert to intensity, plot comparisons with original/desired profiles (skipping near-field), and report focal metrics. | -| `save_optimized_values` | Append optimized transducer phases and amplitudes into a CSV table (indexed by intensity/focus) and save parameters as YAML for PRESTUS config integration. | -| `scale_real_intensity_profile` | Linearly scale measured focal intensity profile and update transducer source amplitude (via acoustic impedance) to match desired peak Isppa (W/cm²). | -| `set_real_phases` | Load or compute manufacturer-specific (Sonic Concepts/Imasonic) transducer element phases for given focal depth (wrt exit plane), interpolate/unwrap for virtual elements, and return phase degrees. | +| `plot_opt_sim_results` | Load optimized k-Wave results, generate 2D focal plane intensity maps and axial profile comparisons (simulated vs. analytical O’Neil vs. desired), and save annotated plots. | +| `recompute_oneil_solution` | Recompute O’Neil analytical pressure profile using optimized phases/velocity, convert to intensity, plot comparisons with original/desired profiles (skipping near-field), and report focal metrics. | +| `save_optimized_values` | Append optimized transducer phases and amplitudes into a CSV table (indexed by intensity/focus) and save parameters as YAML for PRESTUS config integration. | +| `scale_real_intensity_profile` | Linearly scale measured focal intensity profile and update transducer source amplitude (via acoustic impedance) to match desired peak Isppa (W/cm²). | +| `set_real_phases` | Load or compute manufacturer-specific (Sonic Concepts/Imasonic) transducer element phases for given focal depth (wrt exit plane), interpolate/unwrap for virtual elements, and return phase degrees. | #### CORE @@ -131,7 +131,7 @@ The following documents the functions provided in PRESTUS. | **Function Name** | **Description** | |-----------------------------------------|-------------------------------------------------------------------------------------------------| | `fit_pairwiselinear` | Perform a pairwise linear fit between HU and density values with optional plot. | -| `pct_create_pseudoCT` | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | +| `pct_create_pseudoCT` | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | | `pct_skullexpand` | Load SimNIBS NIfTIs + headers, run skull rubber wrap, save output. | | `pct_skullmapping` | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. [deprecated, debug] | | `pct_soft_tissue_peak` | Identifies the soft tissue peak from UTE intensity distribution histograms. | @@ -157,7 +157,7 @@ The following documents the functions provided in PRESTUS. | `grid_tissue_setup` | Set up grid dimensions, preprocess head (if modeled), and place in grid | | `grid_transducer_location` | Position transducer (and target) in simulation grid | | `source_create` | Creates ultrasound source signals and masks for k-Wave simulations based on transducer geometry.| -| `source_sensor_setup` | Configure a k-Wave simulation grid, transducer sources, and sensor mask based on input parameters for recording pressure fields in 2D or 3D ultrasonic neuromodulation setups. | +| `source_sensor_setup` | Configure a k-Wave simulation grid, transducer sources, and sensor mask based on input parameters for recording pressure fields in 2D or 3D ultrasonic neuromodulation setups. | #### THERMAL @@ -195,13 +195,13 @@ The following documents the functions provided in PRESTUS. | **Function Name** | **Description** | |-----------------------------------------|-------------------------------------------------------------------------------------------------| -| `convert_2d_to_axisymmetric` | Converts 2D k-Wave simulation grid, medium properties, and source to axisymmetric form by halving the shorter dimension (right half from center). | -| `convert_axisymmetric_to_2d` | Mirrors axisymmetric simulation data (sensor, medium, masks, source) left-right to full 2D, doubles radial dimension, transposes axes, and updates kgrid/positions. | -| `convert_axisymmetric_to_3d` | Expands 2D axisymmetric data (via radialExpand2DTo3D) to full 3D grid by duplicating radial dimension, updates positions/grid/kgrid for cubic symmetry. | +| `convert_2d_to_axisymmetric` | Converts 2D k-Wave simulation grid, medium properties, and source to axisymmetric form by halving the shorter dimension (right half from center). | +| `convert_axisymmetric_to_2d` | Mirrors axisymmetric simulation data (sensor, medium, masks, source) left-right to full 2D, doubles radial dimension, transposes axes, and updates kgrid/positions. | +| `convert_axisymmetric_to_3d` | Expands 2D axisymmetric data (via radialExpand2DTo3D) to full 3D grid by duplicating radial dimension, updates positions/grid/kgrid for cubic symmetry. | | `convert_final_to_MNI_matlab` | Converts an image from subject space to MNI space using MATLAB. | | `convert_final_to_MNI_simnibs` | Converts an image to MNI space using SimNIBS. | | `mni2subject_coords_LDfix` | Transforms a set of coordinates in MNI space to subject space. | | `radialExpand2DTo3D` | Radially expands 2D axisymmetric data into 3D Cartesian volume. | | `ras_to_grid` | Converts RAS coordinates to voxel (grid) coordinates using NIfTI header transformation matrix. | | `subject2mni_coords_LDfix` | Transforms a set of coordinates in MNI space to subject space. | -| `transform_coordinates` | Transform input coordinates between coordinate systems (wrapper) space. | \ No newline at end of file +| `transform_coordinates` | Transform input coordinates between coordinate systems (wrapper) space. | \ No newline at end of file diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index 5460b671..062a46f2 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -77,7 +77,6 @@ function prestus_pipeline_start(subject_id, parameters, options) fprintf('⏳ Waiting for job completion...\n'); fprintf('═══════════════════════════════\n'); hpc_wait_for_completion(job_id, platform); - fprintf('✅ Job %s completed\n\n', job_id_display); else fprintf('➡️ Continuing in MATLAB ...\n\n'); end diff --git a/functions/hpc/hpc_wait_for_completion.m b/functions/hpc/hpc_wait_for_completion.m index 60a287c1..fe0b0bcf 100644 --- a/functions/hpc/hpc_wait_for_completion.m +++ b/functions/hpc/hpc_wait_for_completion.m @@ -1,56 +1,97 @@ function hpc_wait_for_completion(job_id, hpc_type) %% HPC_WAIT_FOR_COMPLETION Monitor HPC job until completion -% -% hpc_wait_for_completion(job_id, hpc_type) -% -% Polls job status every 20s until COMPLETED/FAILED (SLURM) or C/no-longer-listed (qsub). -% -% Inputs: -% job_id - Job ID (numeric string for SLURM, string for qsub) -% hpc_type - 'slurm' or 'qsub' -% -% See also HPC_SUBMIT_JOB. +% Fixed for Donders/DCCN SLURM cluster format disp('User has chosen to wait until job is finished...'); job_completed = false; +max_checks = 540; % 3 hours +checks = 0; +tic_start = tic; -while ~job_completed - switch hpc_type +while ~job_completed && checks < max_checks + checks = checks + 1; + + switch lower(hpc_type) case 'slurm' - [status, out] = system(sprintf('sacct -j %s -o State --noheader | tail -n 1', job_id)); - if status == 0 - job_state = strtrim(out); - fprintf('SLURM Job status: %s\n', job_state); - if any(strcmp(job_state, {'COMPLETED', 'FAILED'})) + 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 - pause(20); + fprintf('✓ SLURM Job %s gone from queue → COMPLETE (check %d)\n', job_id_str, checks); + job_completed = true; end - case 'qsub' - [status, out] = system(sprintf('qstat -f %s | grep job_state', job_id)); - if status == 0 - parts = strsplit(out, '='); - if numel(parts) == 2 - job_state = strtrim(parts{2}); - fprintf('qsub Job status: %s\n', job_state); - if strcmp(job_state, 'C') - disp('Job completed successfully.'); + 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 - else - [status_check, ~] = system(sprintf('qstat %s', job_id)); - if status_check ~= 0 - disp('Job no longer listed. Assuming completed.'); - job_completed = true; - 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, elapsed/60, checks); +else + fprintf('✓ Job %s (%s) complete after %.1f min (%d checks)\n', job_id, hpc_type, elapsed/60, checks); +end + end + From 4ea73f1e146c45451f6577e7651bee6fe879c683 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 17 Mar 2026 12:07:26 +0100 Subject: [PATCH 132/144] [hpc] fix job formatting --- functions/hpc/hpc_wait_for_completion.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/hpc/hpc_wait_for_completion.m b/functions/hpc/hpc_wait_for_completion.m index fe0b0bcf..0ede253e 100644 --- a/functions/hpc/hpc_wait_for_completion.m +++ b/functions/hpc/hpc_wait_for_completion.m @@ -88,9 +88,9 @@ function hpc_wait_for_completion(job_id, hpc_type) elapsed = toc(tic_start); if ~job_completed - warning('TIMEOUT job %s after %.1f min (%d checks)', job_id, elapsed/60, checks); + 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, hpc_type, elapsed/60, checks); + fprintf('✓ Job %s (%s) complete after %.1f min (%d checks)\n', job_id_str, hpc_type, elapsed/60, checks); end end From 1682866804474d5dc5fd2511d3f820527e38773c Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 17 Mar 2026 13:14:16 +0100 Subject: [PATCH 133/144] [pct] kplan: regularize min. skull density to water --- documentation/doc_pseudoCT.md | 2 ++ functions/medium/medium_pct_density.m | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/documentation/doc_pseudoCT.md b/documentation/doc_pseudoCT.md index 50d7fcfd..90120211 100644 --- a/documentation/doc_pseudoCT.md +++ b/documentation/doc_pseudoCT.md @@ -82,6 +82,8 @@ Mapping algorithm is goverened by `pct_mapping_density`. density = [1.2, 1060, 1530, 2150] ``` + Density estimates are regularized at the minimum to the water density specified in the configuration. + > 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... **Thermal simulations** diff --git a/functions/medium/medium_pct_density.m b/functions/medium/medium_pct_density.m index 8df61d09..201a7ce9 100644 --- a/functions/medium/medium_pct_density.m +++ b/functions/medium/medium_pct_density.m @@ -18,6 +18,9 @@ end close(gcf); + % regularize minimum to density in water + density(skull_idx) = max(parameters.medium.water.density, density(skull_idx)); + case 'k-wave' offset_HU = 1000; @@ -47,9 +50,6 @@ % 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 From cc447e17b2e079955abd7e6cfd716419be3fbc49 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 19 Mar 2026 23:31:34 +0100 Subject: [PATCH 134/144] [parameter] refactor parameters --- .gitignore | 3 +- configs/default_config.yaml | 341 +++++----- configs/tmp_test_n-transducers.yaml | 2 +- configs/tutorial_config.yaml | 24 +- documentation/doc_parameters.md | 619 +++++++++++------- documentation/doc_preproc.md | 2 +- documentation/doc_troubleshooting.md | 2 +- examples/calibration_standalone.m | 2 +- examples/demo_localite.m | 8 +- functions/acoustic/acoustic_analysis.m | 36 +- .../acoustic/acoustic_convert_axisymmetry.m | 2 +- functions/acoustic/acoustic_simulation.m | 58 +- functions/acoustic/acoustic_wrapper.m | 12 +- .../calibration/calibration_transducer.m | 41 +- .../calibration/extract_simulated_profile.m | 24 +- .../scale_real_intensity_profile.m | 6 +- functions/core/generate_simulation_report.m | 84 +-- functions/core/load_parameters.m | 97 ++- functions/core/path_log_setup.m | 79 ++- functions/core/prestus_pipeline_start.m | 57 +- functions/core/print_parameter_summary.m | 75 ++- functions/core/simulation_nifti.m | 40 +- functions/group/combine_plots_by_suffix.m | 2 +- functions/group/create_group_MNI_plots.m | 71 +- functions/head/head_smooth_and_crop.m | 20 +- functions/head/preproc_crop_eCSF.m | 15 +- functions/head/preproc_crop_grid.m | 34 +- functions/head/preproc_head.m | 106 ++- functions/head/preproc_medium_mask.m | 18 +- functions/head/preproc_segmentation.m | 21 +- functions/head/segmentation_run.m | 81 +-- functions/head/skull_fill_holes.m | 25 +- functions/head/skull_rubber_wrap.m | 28 +- functions/head/skull_rubber_wrap_visualize.m | 8 +- functions/helper/charm_seg_labels.m | 37 ++ functions/helper/check_layers.m | 2 +- functions/helper/confirm_overwriting.m | 10 +- functions/helper/tissuemask_binary.m | 2 +- functions/hpc/hpc_job_info.m | 8 +- functions/hpc/hpc_job_name.m | 19 +- functions/hpc/hpc_setup_temp_files.m | 13 +- functions/hpc/hpc_submit_job.m | 56 +- functions/hpc/hpc_validate_parameters.m | 6 +- functions/hpc/hpc_wait_for_completion.m | 6 +- functions/medium/medium_pct_density.m | 8 +- functions/medium/medium_properties_nifti.m | 2 +- functions/medium/medium_setup.m | 76 +-- .../neuronav/neuronav_get_group_mean_mni.m | 2 +- .../neuronav/position_transducer_localite.m | 6 +- functions/pct/pct_skullexpand.m | 25 +- functions/plot/plot_overlay.m | 8 +- functions/plot/plot_transducer_overlay.m | 12 +- functions/plot/show_3d_head.m | 2 +- functions/plot/show_positioning_plots.m | 2 +- functions/prestus_pipeline.m | 139 ++-- functions/source/grid_axisymmetry.m | 10 +- functions/source/grid_tissue_setup.m | 43 +- functions/source/grid_transducer_location.m | 24 +- functions/source/source_create.m | 38 +- functions/source/source_sensor_setup.m | 44 +- functions/thermal/thermal_analysis.m | 16 +- functions/thermal/thermal_parameters.m | 27 +- functions/thermal/thermal_plot_protocol.m | 4 +- functions/thermal/thermal_plot_sim.m | 40 +- functions/thermal/thermal_simulation.m | 32 +- .../transducer/focal_distance_calculation.m | 4 +- functions/transducer/tp_candidate_mesh.m | 2 +- .../tp_evaluate_candidate_positions.m | 2 +- .../transducer/tp_plot_candidate_positions.m | 2 +- .../transducer/tp_plot_geometry_overlay.m | 2 +- .../transducer/tp_plot_heuristic_position.m | 6 +- .../transducer/tp_remove_ear_locations.m | 15 +- .../transducer/tp_select_heuristic_position.m | 49 +- functions/transducer/transducer_positioning.m | 19 +- .../transducer/transducer_positioning_start.m | 37 +- functions/transducer/transducer_setup.m | 10 +- .../transform/convert_axisymmetric_to_2d.m | 12 +- .../transform/convert_axisymmetric_to_3d.m | 28 +- .../transform/convert_final_to_MNI_simnibs.m | 6 +- .../transform/mni2subject_coords_LDfix.m | 6 +- .../transform/subject2mni_coords_LDfix.m | 6 +- functions/transform/transform_coordinates.m | 2 +- simple_main.m | 17 +- 83 files changed, 1541 insertions(+), 1446 deletions(-) create mode 100644 functions/helper/charm_seg_labels.m diff --git a/.gitignore b/.gitignore index 921a38e4..14556549 100644 --- a/.gitignore +++ b/.gitignore @@ -86,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/configs/default_config.yaml b/configs/default_config.yaml index f0a68e13..41311983 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -8,114 +8,188 @@ # Defaults should be overwritten (where sensible or necessary) in a separate config.yaml file. #------------------------------------------------ +#------------ General + +subject_id: 0 # Participant ID (integer, used for path substitution in path.t1_pattern and path.t2_pattern) +platform: 'auto' # Execution platform: auto (recommended) | slurm | qsub | matlab (recommended for debugging) + +#------------ Simulation type & execution + +simulation: + medium: layered # select which medium setup to use: 'water' or 'layered' (a layered medium consists of several layers defined below) + code_type: matlab_gpu # Select which k-Wave code to run: + # MATLAB CPU code 'matlab_cpu' + # MATLAB GPU code 'matlab_gpu' + # C++ CPU code 'cpp_cpu' + # C++ GPU code 'cpp_gpu' + precision: 'single' # Computational precision for both acoustic and thermal simulations + interactive: 0 # Run in interactive mode (ask for user input when necessary, plot evolving figures) + debug: 1 # Activate debug mode with additional intermediate outputs? + +#------------ Paths + +path: + anat: '' # Location of the structural input data [full path] + sim: '' # Location of the simulation outputs [full path or folder name in path.anat] + seg: '' # Location of the segmentation directories (containing m2m folders) [full path] + t1_pattern: 'sub-%1$03d_T1w.nii*' # T1 image path template relative to path.anat; supports %03d-style subject ID substitution + t2_pattern: 'sub-%1$03d_T2w.nii*' # T2 image path template relative to path.anat + localite: '' # Path to Localite neuronavigation output folder + subject_subfolder: 1 # Should each participant's output be stored in a separate folder? + +#------------ Startup (environment & toolbox paths) + +startup: + simnibs_bin_path: '' # Path to the SimNIBS environment (please use full paths) + paths_to_add: [] # Additional paths to add with addpath() + subpaths_to_add: [] # Paths to add recursively with addpath(genpath()); relative to config file location + #------------ I/O management -data_path: '' # Location of the the input data [full path] -sim_path: '' # Location of the the simulation outputs [full paths or folder name in data_path] -sig_path: '' # Location of the the segmentation directories (containing m2m folders) [full path] - -# Should simulation outputs be ordered based on subject id? -subject_subfolder: 1 - -# Paths to T1 and T2 relative to data_path; all string substitutions will be done using subject_id -#t1_path_template: '' # e.g. 'sub-%1$03d_T1w.nii*' -#t2_path_template: '' # e.g., 'sub-%1$03d_T2w.nii*'' - -# Optional affix for result files names -results_filename_affix: '' - -# This allows you to add additional paths and subpaths while running the pipeline -paths_to_add: [] -subpaths_to_add: [] -simnibs_bin_path: '' # Path to the SimNIBS environment (please use full paths) - -# In interactive mode (1), the figures are shown and confirmation dialogs appear (the latter of which will halt the pipeline until confirmation) -# In non-interactive mode (0), no plots are shown and no confirmation dialogs will -interactive: 0 -# Needs to be included if running in a non-interactive mode -overwrite_files: always # 'never' to never overwrite, 'always' to always overwrite, any other value to ask for confirmation -# By default, SimNIBS segmentation results are not overwritten, unless enabled here -overwrite_simnibs: 0 - -#------------ Simulation type - -# select which medium setup to use: 'water' or 'layered' (a layered medium consists of several layers defined below) -simulation_medium: layered - -use_pseudoCT: 0 # [Optional] use (p)CT (if available) to inform skull medium properties -pct_mapping_density: 'k-plan' # pCT mapping algorithm for density: k-plan/k-wave/marsac/aubry -pct_mapping_soundspeed: 'k-plan' # pCT mapping algorithm for sound speed: k-plan/marsac/aubry -pct_mapping_attenuation: 'k-plan' # pCT mapping algorithm for attenuation: k-plan/mueller - -# Run specific modules? - -run_grid_setup: 1 # Setup grid and run head processing? Mandatory for simulations. -run_medium_setup: 1 # Map medium acoustic properties Mandatory for simulations. -run_source_setup: 1 # Set up acoustic source? Mandatory for simulations. -run_acoustic_sims: 1 # Run acoustic simulations? -run_acoustic_analysis: 1 # Run acoustic analysis? -run_heating_sims: 0 # Run thermal simulations? Default: off; run once acoustic simulation provides sensible results. -run_thermal_analysis: 1 # Run thermal analysis? -run_nifti_creation: 1 # Run nifti creation? -run_posthoc_water_sims: 1 # Run simulations in free water with identical setup? -generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? - -# Select which k-Wave code to run (see 'run_simulations' for more documentation) -# MATLAB CPU code 'matlab_cpu' -# MATLAB GPU code 'matlab_gpu' -# C++ CPU code 'cpp_cpu' -# GPU code 'cpp_gpu' -code_type: matlab_gpu - -savemat: 0 # Save output matrices? -debug: 1 # Activate a debug mode in which more intermediate outputs are provided? +io: + output_affix: '' # Optional affix for output file names; differentiates outputs for the same subject/transducer + overwrite_files: always # 'never' to never overwrite, 'always' to always overwrite, any other value to ask for confirmation + overwrite_simnibs: 0 # By default, SimNIBS segmentation results are not overwritten, unless enabled here + save_matrices: 0 # Save output matrices? + save_heatingvideo: 0 # Save a video of incremental heating? + +#------------ Module execution + +modules: + run_grid_setup: 1 # Setup grid and run head processing? Mandatory for simulations. + run_medium_setup: 1 # Map medium acoustic properties? Mandatory for simulations. + run_source_setup: 1 # Set up acoustic source? Mandatory for simulations. + run_acoustic_sims: 1 # Run acoustic simulations? + run_acoustic_analysis: 1 # Run acoustic analysis? + run_heating_sims: 0 # Run thermal simulations? Default: off; run once acoustic simulation provides sensible results. + run_thermal_analysis: 1 # Run thermal analysis? + run_nifti_creation: 1 # Run nifti creation? + run_posthoc_water_sims: 1 # Run simulations in free water with identical setup? + generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? #------------ High-performance computing -platform: 'auto' # auto (recommended) | slurm | qsub | matlab (recommended for debugging) -hpc_gpu: '' -hpc_partition: '' -hpc_reservation: '' -hpc_wait_for_job: false -hpc_timelimit: '04:00:00' -hpc_memorylimit: 20 - -# An optional path to LD_LIBRARY used during SimNIBS installation -# If you experience an `undefined symbol` error in `create_mesh_surf.cpython-39-x86_64-linux-gnu.so`, set either of the next lines to your LD_LIBRARY location -ld_library_path: '' # [qsub] /opt/gcc/7.2.0/lib64 | [slurm]: /home/'group'/'user'/.conda/envs/simnibs_env/lib/python3.9/site-packages/simnibs/mesh_tools/cgal/../../external/lib/linux +hpc: + gpu: '' + partition: '' + reservation: '' + wait_for_job: false + timelimit: '04:00:00' + memorylimit: 20 + ld_library_path: '' # [qsub] /opt/gcc/7.2.0/lib64 | [slurm]: /home/'group'/'user'/.conda/envs/simnibs_env/lib/python3.9/site-packages/simnibs/mesh_tools/cgal/../../external/lib/linux + job_prefix: 'PRESTUS' # Prefix for HPC job names + max_wait_checks: 540 # Maximum number of status checks when waiting for a job (1 check/20s ≈ 3 hours) -#------------ Target specification +#------------ Target analysis -focus_area_radius: 5 # Radius of the target area around the focus in which the ISPPA is averaged for the outputs [mm] +analysis: + focus_area_radius: 5 # Radius of the target area around the focus in which the ISPPA is averaged for the outputs [mm] #------------ Transducer specification (no defaults) + transducer: n_elements: [] # Number of elements in the transducer [#] Elements_ID_mm: [] # Inner diameter of each element [mm] Elements_OD_mm: [] # Outer diameter of each element [mm] curv_radius_mm: [] # Radius of curvature of the bowl [mm] dist_to_plane_mm: [] # Distance to the transducer plane from the geometric focus [mm] - source_amp: [] # Pressure amplitude [Pa] - source_phase_deg: [] # Source phase [deg] + source_amp: [] # Pressure amplitude [Pa] + source_phase_deg: [] # Source phase [deg] source_freq_hz: [] # Fundamental frequency [Hz] trans_pos: [] # Transducer position [T1 grid space] focus_pos: [] # Stimulation target position[T1 grid space] -# Use the kwaveArray class for transducer modeling? -use_kWaveArray: 1 +#------------ Transducer Placement + +placement: + localite: + enabled: 0 # Should the script position_transducer_localite be used? + reference_distance_mm: 15 # Allows one to correct for varying distances between the infrared trackers attached to the transducer and the exit plane (can only be used with the function 'transducer_from_localite') + heuristic: + dist_close: [] + ear_radius: 35 + save_localite_t1: false # Save Localite-aligned T1 output for header correction? + left_ear_center: [] + right_ear_center: [] + criterion_intersection: 0.05 + criterion_skin_mean: [] + criterion_skull_mean: [] + criterion_skin_var: [] + criterion_skull_var: [] + expand_step: 0.01 + +#------------ Simulation grid + +grid: + resolution_mm: 0.5 # Grid resolution (must be isotropic) [mm] + default_dims: [144, 144, 400] # [points per dimension] [used for setting up free-water grid] + pml_size: 10 # Perfectly Matched Layer (PML) size [3D recommendation: 10] + source_ppw: [] # Points Per Wavelength [default: calculated internally] + source_cfl: 0.15 # Courant-Friedrichs-Lewy fraction + source_limit_fraction: 0.9 # Fraction of the stability limit to use for time step (0 = do not use stability limit) + max_expand: 40 # Prime number grid expansion size to (potentially) speed up computations + use_kWaveArray: 1 # Use the kwaveArray class for transducer modeling? + axisymmetric: 0 # Use 2D axisymmetric simulation mode? + +#------------ Head model / Segmentation processing + +headmodel: + head_pad_mm: 0 # Symmetric padding applied to the cropped head grid prior to transducer + PML setup [mm] + csf_expansion: 40 # Dilation of the CSF brain mask into surrounding head regions [grid voxels] + # Thresholds for smoothing masks (and acoustic properties) + smooth_method: 'gaussian' # Filter type ['gaussian' (default)|'box'] + smooth_threshold_skull: 0.5 # (higher threshold -> thinner mask) + smooth_threshold_other: 0.5 # (higher threshold -> thinner mask) + smooth_fwhm_mm: 1 # Smoothing FWHM [mm] + smooth_properties: false # Apply smoothing with parameters above to acoustic property maps? + # Settings for skull hole-filling + skull_fill_method: 'rubberwrap' # Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] + skull_wrap_radius: 10 # Rubber-wrap radius [grid voxels]; larger = tighter rubber ignoring bigger dents (recommendation: 2–10) + skull_wrap_visualize: 0 # Visualize rubber-wrap result? (Currently causes issues on HPC) + +#------------ Segmentation + +segmentation: + use_qform: 0 # Force qform reorientation before charm segmentation? Set to 1 if charm reports qform/sform mismatch. + debug: 0 # Pass --debug flag to charm segmentation for verbose output? + +#------------ (pseudo-)CT skull property mapping + +pct: + enabled: 0 # Use (p)CT (if available) to inform skull medium properties + mapping_density: 'k-plan' # pCT mapping algorithm for density: k-plan/k-wave/marsac/aubry + mapping_soundspeed: 'k-plan' # pCT mapping algorithm for sound speed: k-plan/marsac/aubry + mapping_attenuation: 'k-plan' # pCT mapping algorithm for attenuation: k-plan/mueller + +#------------ Simulation layers +# Assign tissue compartments to model, with their SimNIBS segmentation label indices. +# You can remove or add layers. If you want a skull-only simulation, remove brain and skin for example. + +layers: + water: [0, 3, 6, 9, 10] # this isn't really neccessary as water is a default layer - all that has a number not used for other layers will be assigned water properties + brain: [1, 2] + skin: [5] + skull: [4] + skull_cortical: [7] + skull_trabecular: [8] + + +#------------ Sonication timing protocol (no defaults - must be specified for thermal simulations) + +timing: + pd: 0 # Pulse Duration (PD) [s] + pri: 0 # Pulse Repetition Interval (PRI) [s]; PRF = 1/pri + ptd: 0 # Pulse Train Duration (PTD) [s] + pt_timestep: 0.02 # Modelling time step within a pulse train [s] + ptri: 0 # Pulse Train Repetition Interval (PTRI) [s] + ptrd: 0 # Pulse Train Repetition Duration (PTRD) [s] + post_ptri_dur: 0 # Post-PTRI steady-state duration [s] + post_pt_timestep: 1 # Modelling time step following PT & PTRI [s] + equal_step_duration: 0 # Equal step durations for on and off cycles? 1=yes, 0=no + +#------------ Thermal parameters -#------------ Thermal parameters (no defaults for temporal protocol) thermal: - pd: 0 - pri: 0 - ptd: 0 - pt_timestep: 0.02 - ptri: 0 - ptrd: 0 - post_ptri_dur: 0 - post_pt_timestep: 1 - equal_step_duration: 0 - # Optional parameters: cem43_iso: 0 temp_0: #[deg C] initial temperature water: 37 @@ -127,46 +201,6 @@ thermal: sensor_xy_halfsize: 100 # maximum size of the sensor window in which the temperature is recorded (in grid units) record_t_at_every_step: 0 # should we record temperature for every time step _for the whole sensor window_ (memory intensive, turn off if you experience 'out of memory' issues) -heatingvideo: 0 # Save a video of incremental heating? - -#------------------------------------------------ -#------------ INTERNAL -------------------------- -#------------------------------------------------ - -#------------ Head segmentation - -# Segmentation software ('charm' is recommended, 'headreco' is deprecated) -segmentation_software: charm - -# Labels for layered simulation, assuming for now the SimNIBS masks indices; -# Note that the labels are applied in the order specified here, so the skull should normally be the last as it matters the most -# This labeling scheme assumes the outputs of charm in SimNIBS -# You can remove or add layers. If you want a skull-only simulation, you can remove brain and skin for example. - -# Assign multi-layer compartments to model, and indicate SimNIBS segmentation ids -layers: - water: [0, 3, 6, 9, 10] # this isn't really neccessary as water is a default layer - all that has a number not used for other layers will be assigned water properties - brain: [1, 2] - skin: [5] - skull: [4] - skull_cortical: [7] - skull_trabecular: [8] - -# Provide SimNIBS segmentation labels -seg_labels: - bonemask: [1,2,3,4,7,8,9] # include all media within the skull mask - external: [0] - wm: [1] - gm: [2] - csf: [3] - skull: [4] - skin: [5] - eye: [6] - skull_cortical: [7] - skull_trabecular: [8] - blood: [9] - muscle: [10] - #------------ Medium properties # References: ITRUSST benchmarks (https://arxiv.org/abs/2202.04552); Tissue Properties DB (https://itis.swiss/virtual-population/tissue-properties/overview/); # Note: skull attenuation and absorption values remain uncertain, use defaults with caution! @@ -224,55 +258,4 @@ medium: thermal_conductivity: 0.32 # [W/m/°C] specific_heat_capacity: 1313 # [J/kg/°C] perfusion: 10 - absorption_fraction: 0.28 - -#------------ Simulation grid - -grid_step_mm: 0.5 # Grid resolution (must be isotropic) [mm] -default_grid_dims: [144, 144, 400] # [points per dimension] [used for setting up free-water grid] -pml_size: 10 # Perfectly Matched Layer (PML) size [3D recommendation: 10] - -source_ppw: [] # Points Per Wavelength [default: calculated internally] -source_cfl: 0.15 # Courant-Friedrichs-Lewy fraction -source_limit_fraction: 0.9 # Fraction of the stability limit to use for time step (0 = do not use stability limit) - -precision: 'single' # Computational precision for both acoustic and thermal simulations - -#------------ Segmentation / Head preprocessing - -pad_mm: 0 # Apply symmetric padding to grid prior to transducer + PML setup? -csf_mask_expansion_factor: 40 # Expansion factor for the cerebro-spinal fluid brain mask to capture head areas of interest in simulation -grid_max_expand: 40 # Prime number grid expansion size to (potentially) speed up computations - -# Thresholds for smoothing masks (and acoustic properties) -smooth_method: 'gaussian' # Filter type ['gaussian' (default)|'box'] -smooth_threshold_skull: 0.5 # (higher threshold -> thinner mask) -smooth_threshold_other: 0.5 # (higher threshold -> thinner mask) -smooth_fwhm_mm: 1 # Smoothing FWHM [mm] -smooth_properties: false # Apply smoothing with parameters above to acoustic property maps? - -# Settings for shrinkwrap (if requested) -skullfillmethod: 'rubberwrap' # Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] -wrapradius: 10 # skull rubber wrap radius [grid voxels]; Bigger = tighter rubber that ignores bigger dents (recommendaton: 2 to 10) -skullwrap_visualize: 0 # Visualize results of skullwrap? (Currently causes issues on HPC) - -#------------ Transducer Placement (Localite) - -# Should the script position_transducer_localite be used? -transducer_from_localite: 0 -# Allows one to correct for varying distances between the infrared trackers attached to the transducer and the exit plane -reference_transducer_distance_mm: 15 # (can only be used with the function 'transducer_from_localite') - -#------------ Transducer Placement (Heuristic) - -tp_dist_close: [] -tp_ear_radius: 35 -tp_left_ear_center: [] -tp_right_ear_center: [] -tp_criterion_intersection: 0.05 -tp_criterion_skin_mean: [] -tp_criterion_skull_mean: [] -tp_criterion_skin_var: [] -tp_criterion_skull_var: [] -tp_expand_step: 0.01 -tp_save_localiteT1: true \ No newline at end of file + absorption_fraction: 0.28 \ No newline at end of file diff --git a/configs/tmp_test_n-transducers.yaml b/configs/tmp_test_n-transducers.yaml index 36539989..00b5b073 100644 --- a/configs/tmp_test_n-transducers.yaml +++ b/configs/tmp_test_n-transducers.yaml @@ -51,4 +51,4 @@ subject_subfolder: 1 results_filename_affix: 'tmp_test_n-transducers' # Grid resolution (must be isotropic) -grid_step_mm: 0.9 # [mm] +grid.resolution_mm: 0.9 # [mm] diff --git a/configs/tutorial_config.yaml b/configs/tutorial_config.yaml index 28b9c77b..49e3774a 100755 --- a/configs/tutorial_config.yaml +++ b/configs/tutorial_config.yaml @@ -1,13 +1,16 @@ -data_path: /home/.../Ernie_T1_T2/ # the folder with either a) t1 & t2 images, or b) t1 image and the segmentation results, e.g., Ernie subject from SimNIBS -simnibs_bin_path: /home/.../SimNIBS-4.0/bin/ # change to simnibs install +path: + anat: /home/.../Ernie_T1_T2/ # the folder with either a) t1 & t2 images, or b) t1 image and the segmentation results, e.g., Ernie subject from SimNIBS + t1_pattern: ernie_T1.nii.gz # path to T1 relative to path.anat; all string substitutions will be done using subject_id + t2_pattern: ernie_T2.nii.gz # path to T2 relative to path.anat -t1_path_template: ernie_T1.nii.gz # path to T1 relative to data_path; all string substitutions will be done using subject_id -t2_path_template: ernie_T2.nii.gz # path to T2 relative to data_path; all string substitutions will be done using subject_id +startup: + simnibs_bin_path: /home/.../SimNIBS-4.0/bin/ # change to simnibs install -# If you encounter the error "The qform and sform of do not match. Please run charm with the --forceqform option" when running charm, you can set this to 1 to do it for all subjects -use_forceqform: 0 +segmentation: + # If you encounter the error "The qform and sform do not match. Please run charm with the --forceqform option" when running charm, you can set this to 1 to do it for all subjects + use_qform: 0 -transducer: +transducer: n_elements: 4 # number of elements in the transducer Elements_ID_mm: [0, 32.9184, 46.1264, 56.0324] Elements_OD_mm: [32.3596, 45.5676, 55.5244, 64.008] @@ -19,11 +22,12 @@ transducer: trans_pos: [128, 139, 15] # position on T1 grid focus_pos: [128, 142, 75] # stimulation target on T1 grid -default_grid_dims: [144, 144, 256] +grid: + default_dims: [144, 144, 256] -thermal: +timing: pd: 0.3 - pri: 0.6 + pri: 0.6 ptd: 0.6 pt_timestep: 0.1 ptri: 1.2 diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 91397595..95e0a5e1 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -1,247 +1,380 @@ ## PRESTUS parameters -The following documents the parameters used in PRESTUS. The specification philosophy is the following: `default_config.yaml` provides a list of all parameters with default settings and will be read in first by the function `load_parameters`. This default configuration file should not be changed in standard applications to ensure that necessary fields are provided. - -To set up a specific application, an additional `config_.yaml` should be provided. This file should contain exclusively the fields where defaults should be overwritten (e.g., to provide specific transducer settings). Alternatively, parameters can be specified prior to calling the `prestus_pipeline` in MATLAB. This allows dynamic iterations over parameters of interest. - -### I/O management - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-------------------------------------------------------|----------------------| -| `data_path` | Absolute path to structural input data location. | [string] Mandatory | -| `seg_path` | Absolute path to SimNIBS segmentations. | [string] Mandatory | -| `sim_path` | Absolute path to the simulation output. | [string] Mandatory | -| `localite_path` | Absolute path to localite planning. | [string] Optional | -| `simnibs_bin_path` | Absolute path to SimNIBS binaries. | [string] Mandatory | -| `paths_to_add` | Toolbox paths to add with addpath(). | [cell] e.g., `{"path/to/x", "path/to/Y"}` | -| `subpaths_to_add` | Toolbox paths to add with addpath(genpath()). | [cell] e.g., `{"path/to/x", "path/to/Y"}` | -| `subject_subfolder` | Manage simulation (and optionally localite planning) outputs in subject-specific subdirectories? | (`1 = yes [default], 0 = no`) | -| `results_filename_affix` | Affix for result file names | [string] Can be used to differentiate simulation outputs for the same subject-transducer combination(e.g., different intensities and/or targets.) | -| `interactive` | Interactive mode (`1 = yes, 0 = no`). | (`1 = yes, 0 = no`) Asks prior to overwriting or starting long computations. If set to non-interactive, see the flags `overwrite_files` and `overwrite_simnibs`. | -| `overwrite_files` | File overwrite behavior (`ask`, `never`, or `always`).| (`ask`, `never`, or `always`) This parameter does NOT apply to SimNIBS segmentations. | -| `overwrite_simnibs` | Overwrite SimNIBS segmentation results? | (`1 = yes, 0 = no` [default]) | - -### Simulation type -see [doc_preproc.md](doc_preproc.md) - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| -| `simulation_medium` | Medium setup for simulation (`water`/`layered`/`phantom`). | Mandatory. | -| `layers` | Labels for layered simulation, defining mask indices for different tissue types. | Mandatory. This parameter allows adding or removing layers of interest (if the corresponding segmenation is available and `parameters.medium` has a corresponding label containing the acoustic properties). | -| `seg_labels` | Labels for segmentations, specifying indices for CSF, bone mask, and eye regions. | Mandatory. | -| `run_grid_setup` | Setup grid and run head processing? Mandatory for simulations. | (`1 = yes, 0 = no`) | -| `run_medium_setup` | Map medium acoustic properties? Mandatory for simulations. | (`1 = yes, 0 = no`) | -| `run_source_setup` | Set up acoustic source? Mandatory for simulations. | (`1 = yes, 0 = no`) | -| `run_acoustic_sims` | Run acoustic simulations? | (`1 = yes, 0 = no`) | -| `run_acoustic_analysis` | Run acoustic analysis? | (`1 = yes, 0 = no`) | -| `run_heating_sims` | Run thermal simulations? | (`1 = yes, 0 = no`) | -| `run_thermal_analysis` | Run thermal analysis? | (`1 = yes, 0 = no`) | -| `run_nifti_creation` | Run Nifti creation? | (`1 = yes, 0 = no`) | -| `run_posthoc_water_sims` | Run water simulations following head simulations. | (`1 = yes, 0 = no`) | -| `generate_report` | Generate a self-contained HTML simulation report at the end of the pipeline? | (`1 = yes, 0 = no`) | -| `n_sim_dims` | Simulation type (2D / 3D). | `2` = 2D (`kspaceFirstOrder2D`), `3` = 3D(`kspaceFirstOrder3D`). If not specified, it is inferred from `default_grid_dims`. For `axisymmetric` setups (see below), specify 2D. | -| `axisymmetric` | Run 2D simulations with axisymmetry (`kspaceFirstOrderAS `). | (`1 = yes, 0 = no`) see [doc_simulations-acoustic.md](doc_simulations-acoustic.md). | -| `savemat` | Save outputs of acoustic and/or heating simulations as .mat files? | (`1 = yes, 0 = no`) For many parallel simulations, setting this to 0 saves disk space. | -| `debug` | Activate a more verbose debug mode in which more intermediate output plots and files are provided? | (`1 = yes, 0 = no`) | - -### Segmentation/Preprocessing -see [doc_preproc.md](doc_preproc.md) - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| -| `segmentation_software` | Segmentation software used (`headreco` or `charm`). | Use of the former `headreco` may result in errors (e.g., when creating pseudoCTs) due to the assumption of charm-based tissue labels in parts of the codebase. | -| `pad_mm` | Size of symmetric padding to apply to grid prior to transducer + PML setup [mm; default: `0`] | | -| `csf_mask_expansion_factor` | Expansion factor for cerebrospinal fluid (CSF) brain mask; controls CSF mask dilation. [grid voxels] | | -| `grid_max_expand` | Maximum expansion [mm] for computational grid to optimize prime numbers and speed up computations. | | -| `smooth_method` | Smoothing filter type [`gaussian`(default) / `box`] | | -| `smooth_threshold_skull` | Threshold for smoothing the skull mask; higher values result in thinner masks. | | -| `smooth_threshold_other` | Threshold for smoothing other masks; higher values result in thinner masks. | | -| `smooth_fwhm_mm` | FWHM of smoothing kernel [mm] (default: 1) | | -| `skullfillmethod` | Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] | | -| `wrapradius` | Skull rubber wrap radius [grid voxels] (default: 10) | Larger = tighter rubber that ignores bigger dents; recommended: 2-10 | -| `smooth_properties` | Apply smoothing with parameters above to acoustic property maps? | `false`(default)/`true` | - -### Transducer specification -see [doc_transducer.md](doc_transducer.md) - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| -| `transducer.source_freq_hz` | Central frequency of the acoustic source (in Hz). | | -| `transducer.n_elements` | Number of elements in the transducer. | | -| `transducer.Elements_ID_mm` | Inner diameter of each transducer element (in mm). | | -| `transducer.Elements_OD_mm` | Outer diameter of each transducer element (in mm). | | -| `transducer.curv_radius_mm` | Radius of curvature of the transducer bowl (in mm). | | -| `transducer.dist_to_plane_mm` | Distance from the geometric focus to the transducer plane (in mm). | | -| `transducer.source_amp` | Amplitude of the acoustic source (in Pa). | Must be calibrated. | -| `transducer.source_phase_deg` | Phase of the acoustic source (in degrees). | Must be calibrated. | -| `transducer.source_phase_rad` | Phase of the acoustic source (in radians). | Must be calibrated. | -| `transducer.trans_pos` | Position of transducer bowl (XYZ, T1 grid voxel space). | | -| `transducer.focus_pos` | Position of stimulation target (XYZ, T1 grid voxel space). | | -| `expected_focal_distance_ep` | Expected distance from the transducer exit plane to the stimulation focus (in mm). | Transducer depth setting [Either `expected_focal_distance_ep`, `expected_focal_distance_bowl`, or [`transducer.focus_pos` and `transducer.trans_pos`] have to be specified.] | -| `expected_focal_distance_bowl` | Expected distance from the transducer bowl to the stimulation focus (in mm). | | -| `transducer_from_localite` | Load transducer position from Localite files?. | (`1 = yes, 0 = no` [default]) | -| `reference_transducer_distance_mm` | Distance from tracker to transducer exit plane (in mm). | Allows to correct for varying distances between the infrared trackers attached to the transducer and the exit plane. Only applies when `transducer_from_localite=1`. | -| `use_kWaveArray` | Use the kWaveArray class for modeling transducers. | (`1 = yes, 0 = no`) see k-Wave documentation. | - -### Target specification - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| -| `focus_area_radius` | Radius of the target area around the focus where ISPPA is averaged (in mm). | Default: 5 mm | - -### Simulation grid - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| -| `grid_step_mm` | Resolution of the computational grid (must be isotropic, in mm). | | -| `default_grid_dims` | Default dimensions of the simulation grid for free-water simulations. | | -| `pml_size` | Perfectly Matched Layer (PML) size to absorb waves at grid boundaries. | See [k-Wave documentation](http://www.k-wave.org/documentation/example_na_controlling_the_pml.php). Default: `10` (recommended for 3D grids)| -| `source_ppw` | Points Per Wavelength [default: calculated internally] | | -| `source_cfl` | Courant-Friedrichs-Lewy fraction | | -| `source_limit_fraction` | Fraction of the stability limit to use for time step | `0`: do not use stability limit | -| `precision` | Computational precision for both acoustic and thermal simulations | `single` (default) / `double` (more precise, but more computationally demanding).| - -### Medium properties -see [doc_medium.md](doc_medium.md) - -| **Parameter** | **Description** | **Comments** | -|---------------------------------------------------|-----------------------------------------------------------------------------------|----------------------| -| `medium.water.sound_speed` | Speed of sound in water (in m/s). | ITRUSST benchmarks. | -| `medium.water.density` | Density of water (in kg/m³). | Tissue Properties DB.| -| `medium.water.alpha_coeff` | Attenuation coefficient for water (in dB/cm/MHz). | k-Plan documentation.| -| `medium.water.alpha_power` | Exponent for attenuation coefficient power law for water. | Tissue Properties DB.| -| `medium.water.thermal_conductivity` | Thermal conductivity of water (in W/m/°C). | Tissue Properties DB.| -| `medium.water.specific_heat_capacity` | Specific heat capacity of water [J/kg/°C]. | Tissue Properties DB.| -| `medium.water.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.water.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.skull.sound_speed` | Speed of sound in skull bone (in m/s). | ITRUSST benchmarks. | -| `medium.skull.density` | Density of skull bone (in kg/m³). | ITRUSST benchmarks. | -| `medium.skull.alpha_coeff` | Attenuation coefficient at for skull bone (in dB/cm/MHz). | Pinton et al., 2011. | -| `medium.skull.alpha_power` | Exponent for attenuation coefficient power law for skull bone. | k-Plan documentation.| -| `medium.skull.thermal_conductivity` | Thermal conductivity of skull bone (in W/m/°C). | Tissue Properties DB.| -| `medium.skull.specific_heat_capacity` | Specific heat capacity of skull bone (in J/kg/°C). | Tissue Properties DB.| -| `medium.skull.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.skull.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.brain.sound_speed` | Speed of sound in brain tissue (in m/s). | Tissue Properties DB.| -| `medium.brain.density` | Density of brain tissue (in kg/m³). | Tissue Properties DB.| -| `medium.brain.alpha_coeff` | Attenuation coefficient for brain tissue (in dB/cm/MHz). | k-Plan documentation.| -| `medium.brain.alpha_power` | Exponent for attenuation coefficient power law for brain tissue. | k-Plan documentation.| -| `medium.brain.thermal_conductivity` | Thermal conductivity of brain tissue (in W/m/°C). | Tissue Properties DB.| -| `medium.brain.specific_heat_capacity` | Specific heat capacity of brain tissue (in J/kg/°C). | Tissue Properties DB.| -| `medium.brain.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.brain.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.skin.sound_speed` | Speed of sound in skin tissue (in m/s). | ITRUSST benchmarks. | -| `medium.skin.density` | Density of skin tissue (in kg/m³). | ITRUSST benchmarks. | -| `medium.skin.alpha_coeff` | Attenuation coefficient for skin tissue (in dB/cm/MHz). | ITRUSST benchmarks. | -| `medium.skin.alpha_power` | Exponent for attenuation coefficient power law for skin tissue. | ITRUSST benchmarks. | -| `medium.skin.thermal_conductivity` | Thermal conductivity of skin tissue (in W/m/°C). | Tissue Properties DB.| -| `medium.skin.specific_heat_capacity` | Specific heat capacity of skin tissue (in J/kg/°C). | Tissue Properties DB.| -| `medium.skin.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.skin.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.skull_trabecular.sound_speed` | Speed of sound in trabecular bone (in m/s). | ITRUSST benchmarks. | -| `medium.skull_trabecular.density` | Density of trabecular bone (in kg/m³). | ITRUSST benchmarks. | -| `medium.skull_trabecular.alpha_coeff` | Attenuation coefficient for trabecular bone (in dB/cm/MHz). | Pinton et al., 2011. | -| `medium.skull_trabecular.alpha_power` | Exponent for attenuation coefficient power law for trabecular bone. | k-Plan documentation.| -| `medium.skull_trabecular.thermal_conductivity` | Thermal conductivity of trabecular bone (in W/m/°C). | Tissue Properties DB.| -| `medium.skull_trabecular.specific_heat_capacity` | Specific heat capacity of trabecular bone (in J/kg/°C). | Tissue Properties DB.| -| `medium.skull_trabecular.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.skull_trabecular.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | -| `medium.skull_cortical.sound_speed` | Speed of sound in cortical bone (in m/s). | ITRUSST benchmarks. | -| `medium.skull_cortical.density` | Density of cortical bone (in kg/m³). | ITRUSST benchmarks. | -| `medium.skull_cortical.alpha_coeff` | Attenuation coefficient for cortical bone (in dB/cm/MHz). | Pinton et al., 2011. | -| `medium.skull_cortical.alpha_power` | Exponent for attenuation coefficient power law for cortical bone. | k-Plan documentation.| -| `medium.skull_cortical.thermal_conductivity` | Thermal conductivity of cortical bone (in W/m/°C). | Tissue Properties DB.| -| `medium.skull_cortical.specific_heat_capacity` | Specific heat capacity of cortical bone (in J/kg/°C). | Tissue Properties DB.| -| `medium.skull_cortical.perfusion` | Perfusion / heat transfer rate [mL/min/kg] | Tissue Properties DB.| -| `medium.skull_cortical.absorption_fraction` | Fraction of attenuation converted into attenuation [0-1] | Pinton et al., 2012. | - - -### pseudoCT mapping to skull properties +Parameters are organised in nested structs that map directly to YAML keys. PRESTUS uses a two-layer configuration system: `default_config.yaml` defines all parameters and their default values and should not be edited. A study-specific config (e.g. `config_study.yaml`) is loaded on top and only needs to specify values that differ from the defaults. Parameters can also be set or overridden programmatically in MATLAB before calling `prestus_pipeline_start(parameters)`. Mandatory parameters have no meaningful default and must always be provided. + +--- + +
+General + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `subject_id` | Subject identifier used for file naming and folder management. | — | Mandatory. Set before calling `prestus_pipeline_start(parameters)`. | +| `platform` | Execution platform. | `'auto'` | `auto` / `slurm` / `qsub` / `matlab` | + +
+ +--- + +
+simulation — Simulation type & execution + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `medium` | Medium setup. | `'layered'` | `water` / `layered` / `phantom`. Mandatory. | +| `code_type` | k-Wave backend. | `'matlab_gpu'` | `matlab_cpu` / `matlab_gpu` / `cpp_cpu` / `cpp_gpu`. See [doc_backend.md](doc_backend.md). | +| `precision` | Computational precision for acoustic and thermal simulations. | `'single'` | `single` / `double` | +| `interactive` | Interactive mode with prompts and evolving plots. | `0` | `1 = yes`, `0 = no`. If `0`, see `overwrite_files`. | +| `debug` | Verbose debug mode with additional intermediate outputs. | `1` | `1 = yes`, `0 = no` | + +
+ +--- + +
+path — Data paths + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `anat` | Absolute path to structural input data. | — | Mandatory | +| `sim` | Absolute path to simulation outputs. | — | Mandatory | +| `seg` | Absolute path to SimNIBS segmentations (m2m folders). | — | Mandatory | +| `localite` | Path to Localite neuronavigation output folder. | — | Optional | +| `t1_pattern` | T1 image path template relative to `path.anat`. | `'sub-%1$03d_T1w.nii*'` | Supports `%03d`-style subject ID substitution | +| `t2_pattern` | T2 image path template relative to `path.anat`. | `'sub-%1$03d_T2w.nii*'` | Optional | +| `subject_subfolder` | Store outputs in subject-specific subdirectories? | `1` | `1 = yes`, `0 = no` | + +
+ +--- + +
+startup — Environment & toolbox paths + +| **Parameter** | **Description** | **Comments** | +|---|---|---| +| `simnibs_bin_path` | Absolute path to SimNIBS binaries. | Mandatory for segmentation and MNI conversion | +| `paths_to_add` | Paths to add with `addpath()`. | [cell] e.g. `{"path/to/x"}` | +| `subpaths_to_add` | Paths to add recursively with `addpath(genpath())`. | [cell]; relative to config file location | + +
+ +--- + +
+io — I/O management + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `output_affix` | Optional affix for output file names. | `''` | Differentiates outputs for the same subject/transducer (e.g. different intensities or targets) | +| `overwrite_files` | File overwrite behaviour. | `'always'` | `never` / `always` / `ask`. Does NOT apply to SimNIBS segmentations. | +| `overwrite_simnibs` | Overwrite SimNIBS segmentation results? | `0` | `1 = yes`, `0 = no` | +| `save_matrices` | Save acoustic/thermal simulation outputs as `.mat`? | `0` | `1 = yes`, `0 = no`. Set to `0` for large batch runs to save disk space. | +| `save_heatingvideo` | Save a video of incremental heating? | `0` | `1 = yes`, `0 = no` | +| `adopted_heatmap` | Path to an existing ISPPA heatmap NIfTI to reuse instead of re-running acoustics. | — | Optional. Used for sequential multi-target runs. | +| `adopted_cem43` | Path to an existing CEM43 heatmap NIfTI to accumulate heating across runs. | — | Optional. Used for sequential multi-target runs. | + +**Runtime-derived (set by `path_log_setup`):** + +| **Field** | **Description** | +|---|---| +| `output_dir` | Resolved per-subject output directory. Set from `path.sim` (+ subject subfolder if `path.subject_subfolder = 1`). | +| `debug_dir` | Debug subfolder within `output_dir`. Created automatically. | +| `filename_output_table` | Path to the per-subject CSV results table. | +| `kwave_source_filename` | Path to cached k-Wave source `.mat` file. Set by `source_sensor_setup`. | + +
+ +--- + +
+modules — Pipeline module flags + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `run_grid_setup` | Setup grid and run head processing. | `1` | Mandatory for simulations. | +| `run_medium_setup` | Map medium acoustic properties. | `1` | Mandatory for simulations. | +| `run_source_setup` | Set up acoustic source. | `1` | Mandatory for simulations. | +| `run_acoustic_sims` | Run acoustic simulations. | `1` | | +| `run_acoustic_analysis` | Run acoustic analysis. | `1` | | +| `run_heating_sims` | Run thermal simulations. | `0` | Enable after acoustic results are validated. | +| `run_thermal_analysis` | Run thermal analysis. | `1` | | +| `run_nifti_creation` | Export results as NIfTI files. | `1` | | +| `run_posthoc_water_sims` | Run free-water reference simulations after head simulations. | `1` | | +| `generate_report` | Generate self-contained HTML simulation report. | `1` | | + +All flags: `1 = yes`, `0 = no`. + +
+ +--- + +
+transducer — Transducer specification + +see [doc_transducer.md](doc_transducer.md) + +All fields are mandatory and have no defaults — they must be set in the study config. + +| **Parameter** | **Description** | **Comments** | +|---|---|---| +| `source_freq_hz` | Central frequency of the acoustic source [Hz]. | | +| `n_elements` | Number of transducer elements. | | +| `Elements_ID_mm` | Inner diameter of each element [mm]. | | +| `Elements_OD_mm` | Outer diameter of each element [mm]. | | +| `curv_radius_mm` | Radius of curvature of the transducer bowl [mm]. | | +| `dist_to_plane_mm` | Distance from geometric focus to transducer plane [mm]. | | +| `source_amp` | Pressure amplitude [Pa]. | Must be calibrated. | +| `source_phase_deg` | Source phase [degrees]. | Must be calibrated. | +| `source_phase_rad` | Source phase [radians]. | Must be calibrated. | +| `trans_pos` | Transducer bowl position (XYZ, T1 grid voxel space). | | +| `focus_pos` | Stimulation target position (XYZ, T1 grid voxel space). | | +| `expected_focal_distance_ep` | Expected distance from transducer exit plane to focus [mm]. | Alternative to specifying `trans_pos`/`focus_pos`. Either `expected_focal_distance_ep`, `expected_focal_distance_bowl`, or both pos fields must be set. | +| `expected_focal_distance_bowl` | Expected distance from transducer bowl to focus [mm]. | | + +
+ +--- + +
+placement — Transducer placement + +see [doc_transducer.md](doc_transducer.md) + +#### `placement.localite` + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `enabled` | Load transducer position from Localite neuronavigation files? | `0` | `1 = yes`, `0 = no` | +| `reference_distance_mm` | Distance from tracker to transducer exit plane [mm]. | `15` | Corrects for varying tracker-to-exit-plane distances. Only applies when `enabled=1`. | + +#### `placement.heuristic` + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `save_localite_t1` | Save Localite-aligned T1 output for header correction? | `false` | See [doc_placement_heuristic.md](doc_placement_heuristic.md). | +| `dist_close` | Distance from target considered sufficiently close [mm]. | `[]` | | +| `ear_radius` | Radius of the ear exclusion zone [mm]. | `35` | | +| `left_ear_center` | Approximate coordinates for left ear [image voxels]. | `[]` | Optional | +| `right_ear_center` | Approximate coordinates for right ear [image voxels]. | `[]` | Optional | +| `criterion_intersection` | Max fraction of exit plane intersecting skin. | `0.05` | 5% | +| `criterion_skin_mean` | Mean distance of EP voxels from skin [quantile]. | `[]` | | +| `criterion_skull_mean` | Mean distance of EP voxels from skull [quantile]. | `[]` | | +| `criterion_skin_var` | Variance of EP voxel distance from skin [quantile]. | `[]` | | +| `criterion_skull_var` | Variance of EP voxel distance from skull [quantile]. | `[]` | | +| `expand_step` | Expansion step for skin intersection criterion. | `0.01` | 1% | + +
+ +--- + +
+grid — Simulation grid + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `resolution_mm` | Grid resolution (must be isotropic) [mm]. | `0.5` | | +| `default_dims` | Requested grid dimensions [voxels per dimension]. | `[144, 144, 400]` | Directly sets the simulation grid for `water` and `phantom` media. For `layered`, the grid is determined by head preprocessing and this value is not used. | +| `axisymmetric` | Run axisymmetric 2D simulation (`kspaceFirstOrderAS`). | `0` | `1 = yes`, `0 = no`. See [doc_simulations-acoustic.md](doc_simulations-acoustic.md). | +| `pml_size` | Perfectly Matched Layer (PML) size [voxels]. | `10` | Absorbs waves at boundaries. Recommended for 3D. See [k-Wave docs](http://www.k-wave.org/documentation/example_na_controlling_the_pml.php). | +| `source_ppw` | Points per wavelength. | `[]` | Calculated internally if not set. | +| `source_cfl` | Courant-Friedrichs-Lewy fraction. | `0.15` | | +| `source_limit_fraction` | Fraction of the stability limit to use for time step. | `0.9` | `0` = do not use stability limit | +| `max_expand` | Maximum grid expansion for prime-number FFT optimisation [voxels]. | `40` | | +| `use_kWaveArray` | Use the kWaveArray class for transducer modelling? | `1` | `1 = yes`, `0 = no` | + +
+ +--- + +
+headmodel — Head model & segmentation preprocessing + +see [doc_preproc.md](doc_preproc.md) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `head_pad_mm` | Symmetric padding applied to the cropped head grid prior to transducer + PML setup [mm]. | `0` | | +| `csf_expansion` | Dilation of the CSF brain mask into surrounding head regions [grid voxels]. | `40` | | +| `smooth_method` | Mask smoothing filter type. | `'gaussian'` | `gaussian` / `box` | +| `smooth_threshold_skull` | Binarisation threshold for skull mask; higher = thinner mask. | `0.5` | | +| `smooth_threshold_other` | Binarisation threshold for other masks; higher = thinner mask. | `0.5` | | +| `smooth_fwhm_mm` | FWHM of smoothing kernel [mm]. | `1` | | +| `smooth_properties` | Apply smoothing to acoustic property maps as well? | `false` | | +| `skull_fill_method` | Method for filling holes in the skull. | `'rubberwrap'` | `rubberwrap` / `imclose` | +| `skull_wrap_radius` | Rubber-wrap radius [grid voxels]. | `10` | Larger = tighter wrap ignoring bigger dents. Recommended: 2–10. | +| `skull_wrap_visualize` | Visualize rubber-wrap result? | `0` | `1 = yes`, `0 = no`. Avoid on HPC. | + +
+ +--- + +
+segmentation — Segmentation settings + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `use_qform` | Force qform reorientation before charm segmentation? | `0` | Set to `1` if charm reports a qform/sform mismatch error. | +| `debug` | Pass `--debug` to charm for verbose segmentation output? | `0` | `1 = yes`, `0 = no` | + +
+ +--- + +
+pct — pseudo-CT skull property mapping + see [doc_pseudoCT.md](doc_pseudoCT.md) -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-------------------------------------------------------------------|----------------------| -| `use_pseudoCT` | Use (pseudo-)CT based mapping? (`1` = yes / `0` = no (default)) | Map (pseudo-)HU to tissue properties in the skull layer? | -| `pct_mapping_density` | Density mapping algorithm (`k-plan`(default)/`k-wave`/`marsac`/`aubry`/`none`) | see [documentation](doc_pseudoCT.md#mapping-skull-density) | -| `pct_mapping_soundspeed` | Sound speed mapping algorithm (`k-plan`(default)/`marsac`/`aubry`/`none`) | see [documentation](doc_pseudoCT.md#mapping-skull-sound-speed) | -| `pct_mapping_attenuation` | Attenuation mapping algorithm (`k-plan`(default)/`mueller`/`aubry`/`none`) | see [documentation](doc_pseudoCT.md#mapping-skull-attenuation) | - - -### Sequence timing and baseline temperature for heating simulations -see [doc_simulations-acoustic.md](doc_simulations-thermal.md) - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-----------------------------------------------------------------------------------|----------------------| -| `thermal.pd` | Pulse Duration (PD) [seconds] | Duty cycle (DC) inside a Pulse Train [%] is internally calculated as `pd`/`pri`. | | -| `thermal.pri` | Pulse Repetition Interval (PRI) [seconds] (i.e., an on/off cycle during stimulation). | Pulse Repetition Frequency (PRF) is internally calculated as 1/`pri`.| -| `thermal.ptd` | Pulse Train Duration (PTD) [seconds] | | -| `thermal.pt_timestep` | Modeling time steps inside a PT [seconds] | | -| `thermal.ptri` | Pulse Train Repetition Interval (PTRI) [seconds] | Duration of OFF period within a PTRI is internally calculated as `ptri`-`ptd`. | -| `thermal.ptrd` | Pulse Train Repetition Duration (PTRD) [seconds] | | -| `thermal.post_ptri_dur` | Post-PTRI Steady-state Duration [seconds] | | -| `thermal.post_pt_timestep` | Modeling time steps following PT & PTRI [seconds] | | -| `thermal.equal_step_duration` | Whether simulation step durations are equal for on and off cycles (`1 = yes, 0 = no`). | | -| `thermal.cem43_iso` | Calculate CEM43 according to ISO norm (`1`) or kWaveDiffusion (`0`). | | -| `thermal.temp_0.water` | Initial temperature of water medium before simulation (in °C). | | -| `thermal.temp_0.skull` | Initial temperature of skull medium before simulation (in °C). | | -| `thermal.temp_0.brain` | Initial temperature of brain medium before simulation (in °C). | | -| `thermal.temp_0.skin` | Initial temperature of skin medium before simulation (in °C). | | -| `thermal.temp_0.skull_trabecular` | Initial temperature of trabecular skull medium before simulation (in °C). | | -| `thermal.temp_0.skull_cortical` | Initial temperature of cortical skull medium before simulation (in °C). | | -| `thermal.sensor_xy_halfsize` | Maximum size of the sensor window for temperature recording (in grid units). | | -| `thermal.record_t_at_every_step` | Whether to record temperature at every time step for the whole sensor window (`1 = yes, 0 = no`). | | -| `heatingvideo` | Save a video of incremental heating? (`1 = yes, 0 = no`) | | - - -### GPU/HPC options -see [doc_backend.md](doc_backend.md) [doc_hpc.md](doc_hpc.md) - -| **Parameter** | **Description** | **Comments** | -|-----------------------------------|-------------------------------------------------------------------------------------------|----------------------| -| `code_type` | Type of k-Wave code to run (`matlab_cpu`, `matlab_gpu`, `cpp_cpu`, or `cpp_gpu`). | See [doc_backend.md](doc_backend.md). | -| `platform` | Simulation submit mode | `slurm` (recommended), `matlab`, `qsub` | -| `hpc_gpu` | Request a specific GPU. [Optional] | Not recommended by default, rely on automatic GPU detection instead. May be useful when benchmarking specific GPUs. E.g.,```"nvidia_a100-sxm4-40gb:1"```. ```scontrol show nodes \| egrep -o gres/gpu:.*=[0-9] \| egrep -o 'nvidia_.*=' \| sort \| uniq \| sed 's/=//'``` lists available GPU types. | -| `hpc_partition` | Request a dedicated GPU partition. [Optional] | The Donders HCP provides a ```gpu40g``` partition that consists of nodes with GPU with vRAM > 40 GB. This is the recommended default for thermal simulations of longer protocols. | -| `hpc_reservation` | Request a reserved cue. [Optional] | | -| `ld_library_path` | Path to LD_LIBRARY used during SimNIBS installation. [Optional] | If you experience an `undefined symbol` error in `create_mesh_surf.cpython-39-x86_64-linux-gnu.so`, specify the LD_LIBRARY location e.g., `/opt/gcc/7.2.0/lib64` [torque] or ```/home/'group'/'user'/.conda/envs/simnibs_env/lib/python3.9/site-packages/simnibs/mesh_tools/cgal/../../external/lib/linux``` [slurm] | - -### Transducer calibration -see [doc_calibration.md](doc_calibration.md) - -For transducer calibration, a separate `calibration_config.yaml` applies that should be loaded as `parameters.calibration`. - -| **Parameter** | **Description** | **Comments** | -|---------------------------------|---------------------------------------------------------|---------------------------------------| -| `path_input_axial` | Directory containing axial profiles | | -| `path_input_phase` | Directory containing phase data | | -| `path_output` | Directory for saving free-water simulation results | | -| `path_output_profiles` | Directory for saving optimized profile data | | -| `filename_calibrated_CSV` | Filename of calibrated CSV data | Mandatory only when not generated within standalone script based on equipment name. | -| `save_in_calibration_folder` | `TRUE` (default): save in `path_output` ; `FALSE`: save outputs in `sim_path` (see regular config). | Note: If TRUE calibration results are also appended to existing calibration for this equipment instead of overwritten. | -| `combinations` | Equipment combinations (must refer to equipment in `equipment_config.yaml`) | Multiple combinations can be specified. Every [] specification will be performed for the corresponding (ordered) index equipment combination. Example: all foci within the first [] will be executed for the first equipment combination. If array is empty ([]), all focal depths of available characterization data will be used. | -| `focal_depths_wrt_exit_plane` | List of focal depths (in mm) to be characterized. | Multiple combinations can be specified. | -| `desired_intensities` | Desired free-water intensities [W/cm^2] | Multiple combinations can be specified. | -| `add_FDO` | Append Focal Distance Offset (distance from transducer bowl to exit plane). Set to `1` if zero point in provided profiles reflects the exit plane, and not as expected the transducer bowl. | The profile is padded (between bowl and exit plane) with zero. This can stabilize the fitting procedure in the near field. | -| `axisymmetric2D` | Overwrite default 3D simulation to perform axisymmetric 2D. | (`1 = yes, 0 = no`) | -| `force_kwavearray` | Force to run free-water simulations with kwavearray? | If active, free-water simulations always use kwavearray. If set to `0`, simulations use the setting in the default or study-specific config. | -| `opt_method` | `FEXminimize` (open source subtoolbox) or `GlobalSearch` (MATLAB's Global Optimization Toolbox) | | -| `opt_limits` | Distance limits for optimization [mm] | | -| `opt_weights` | Weighting of the original profile during fitting (1 = equal weighting, > 1 Gaussian weighting, increasingly narrow with larger weights) | | -| `opt_seed` | Random seed for optimization | | -| `skip_front_peak_mm` | Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts. | Used only for calculating the peak distance and FWHM. | - -### Heuristic transducer placement - -| **Parameter** | **Description** | **Comments** | -|---------------------------------|---------------------------------------------------------|---------------------------------------| -| `tp_dist_close` | Distance from target considered sufficiently close [mm] | | -| `tp_ear_radius` | Radius of the nogo zone [mm] | [Optional] for ear exclusion | -| `tp_left_ear_center` | Approximate coordinates for left ear [img voxels] | [Optional] for ear exclusion | -| `tp_right_ear_center` | Approximate coordinates for right ear [img voxels] | [Optional] for ear exclusion | -| `tp_criterion_intersection` | Criterion: intersection with skin [fraction] | Default: `0.05` (5%) | -| `tp_criterion_skin_mean` | Criterion: mean distance of EP voxels from skin [quantile] | Default: `NaN` | -| `tp_criterion_skull_mean` | Criterion: mean distance of EP voxels from skull [quantile] | Default: `NaN` | -| `tp_criterion_skin_var` | Criterion: variance of EP voxel distance from skin [quantile] | Default: `NaN` | -| `tp_criterion_skull_var` | Criterion: variance of EP voxel distance from skull [quantile] | Default: `NaN` | -| `tp_expand_step` | Expansion step for skin intersection | Default: `0.01` (1%) | -| `tp_save_localiteT1` | Save localite T1 | [Optional] Requires `localite_path` | +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `enabled` | Use (pseudo-)CT to inform skull medium properties? | `0` | `1 = yes`, `0 = no` | +| `mapping_density` | HU-to-density mapping algorithm. | `'k-plan'` | `k-plan` / `k-wave` / `marsac` / `aubry` / `none`. See [doc](doc_pseudoCT.md#mapping-skull-density). | +| `mapping_soundspeed` | HU-to-sound-speed mapping algorithm. | `'k-plan'` | `k-plan` / `marsac` / `aubry` / `none`. See [doc](doc_pseudoCT.md#mapping-skull-sound-speed). | +| `mapping_attenuation` | HU-to-attenuation mapping algorithm. | `'k-plan'` | `k-plan` / `mueller` / `aubry` / `none`. See [doc](doc_pseudoCT.md#mapping-skull-attenuation). | + +
+ +--- + +
+layers — Simulation tissue compartments + +see [doc_preproc.md](doc_preproc.md) + +Maps tissue compartment names to their SimNIBS charm label indices. Compartments are modelled in listed order; any voxel label not assigned to a named compartment is treated as water. Charm label values are hardcoded in `charm_seg_labels()`. Layers can be removed or added. + +| **Layer** | **Default charm labels** | **Comments** | +|---|---|---| +| `water` | `[0, 3, 6, 9, 10]` | Baseline layer; all unassigned voxels are treated as water | +| `brain` | `[1, 2]` | White matter + grey matter | +| `skin` | `[5]` | | +| `skull` | `[4]` | Single skull layer; used when cortical/trabecular split is not needed | +| `skull_cortical` | `[7]` | Cortical bone; used in multi-layer skull model | +| `skull_trabecular` | `[8]` | Trabecular bone; used in multi-layer skull model | + +
+ +--- + +
+medium — Tissue acoustic & thermal properties + +see [doc_medium.md](doc_medium.md) + +Each tissue compartment (`water`, `brain`, `skin`, `skull`, `skull_trabecular`, `skull_cortical`) carries the following fields: + +| **Field** | **Description** | **Units / Reference** | +|---|---|---| +| `sound_speed` | Speed of sound. | m/s — ITRUSST benchmarks | +| `density` | Density. | kg/m³ — ITRUSST benchmarks | +| `alpha_coeff` | Attenuation coefficient. | dB/cm/MHz | +| `alpha_power` | Attenuation power law exponent. | — | +| `thermal_conductivity` | Thermal conductivity. | W/m/°C — Tissue Properties DB | +| `specific_heat_capacity` | Specific heat capacity. | J/kg/°C — Tissue Properties DB | +| `perfusion` | Perfusion / heat transfer rate. | mL/min/kg — Tissue Properties DB | +| `absorption_fraction` | Fraction of attenuation converted to heat. | [0–1] — Pinton et al., 2012 | + +
+ +--- + +
+timing — Sonication timing protocol + +see [doc_simulations-thermal.md](doc_simulations-thermal.md) + +Protocol duration fields must be set for thermal simulations. + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `pd` | Pulse Duration (PD) [s]. | `NaN` | Duty cycle = `pd`/`pri`. | +| `pri` | Pulse Repetition Interval (PRI) [s]. | `NaN` | PRF = 1/`pri`. | +| `ptd` | Pulse Train Duration (PTD) [s]. | `NaN` | | +| `pt_timestep` | Modelling time step within a pulse train [s]. | `0.02` | | +| `ptri` | Pulse Train Repetition Interval (PTRI) [s]. | `NaN` | OFF duration = `ptri` − `ptd`. | +| `ptrd` | Pulse Train Repetition Duration (PTRD) [s]. | `NaN` | | +| `post_ptri_dur` | Post-PTRI steady-state duration [s]. | `NaN` | | +| `post_pt_timestep` | Modelling time step following PT & PTRI [s]. | `1` | | +| `equal_step_duration` | Equal step durations for on and off cycles? | `0` | `1 = yes`, `0 = no` | + +
+ +--- + +
+thermal — Thermal simulation settings + +see [doc_simulations-thermal.md](doc_simulations-thermal.md) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `cem43_iso` | Calculate CEM43 per ISO norm (`1`) or kWaveDiffusion (`0`)? | `0` | | +| `temp_0.{tissue}` | Initial temperature per tissue compartment [°C]. | `37` | Tissues: `water`, `skull`, `brain`, `skin`, `skull_trabecular`, `skull_cortical` | +| `sensor_xy_halfsize` | Sensor window half-size for temperature recording [grid units]. | `100` | | +| `record_t_at_every_step` | Record temperature at every time step for the full sensor window? | `0` | Memory-intensive; disable if out-of-memory errors occur. | + +
+ +--- + +
+analysis — Output analysis + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `focus_area_radius` | Radius around the focus in which ISPPA is averaged for outputs [mm]. | `5` | | + +
+ +--- + +
+hpc — High-performance computing + +see [doc_backend.md](doc_backend.md) [doc_hpc.md](doc_hpc.md) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `gpu` | Request a specific GPU. | `''` | e.g. `"nvidia_a100-sxm4-40gb:1"` | +| `partition` | Request a dedicated queue partition. | `''` | | +| `reservation` | Request a reserved queue. | `''` | | +| `wait_for_job` | Block MATLAB until the submitted HPC job completes? | `false` | | +| `timelimit` | Job time limit. | `'04:00:00'` | | +| `memorylimit` | Memory limit [GB]. | `20` | | +| `ld_library_path` | LD_LIBRARY path for SimNIBS installation. | `''` | Set if you see `undefined symbol` errors. e.g. `/opt/gcc/7.2.0/lib64` | +| `job_prefix` | Prefix string for HPC job names. | `'PRESTUS'` | Overridden to `'TP'` for transducer positioning jobs. | +| `max_wait_checks` | Maximum number of job status checks when `wait_for_job = true`. | `540` | At 1 check/20 s, `540` ≈ 3 hours. | + +
+ +--- + +
+calibration — Transducer calibration + +see [doc_calibration.md](doc_calibration.md) + +A separate `calibration_config.yaml` applies for calibration workflows and is loaded as `parameters.calibration`. + +| **Parameter** | **Description** | **Comments** | +|---|---|---| +| `path_input_axial` | Directory containing axial intensity profiles. | | +| `path_input_phase` | Directory containing phase data. | | +| `path_output` | Directory for saving free-water simulation results. | | +| `path_output_profiles` | Directory for saving optimised profile data. | | +| `filename_calibrated_CSV` | Filename of calibrated CSV data. | Mandatory only when not generated within standalone script. | +| `save_in_calibration_folder` | Save in `path_output` (`TRUE`) or `sim_path` (`FALSE`). | If `TRUE`, results are appended to existing calibration data. | +| `combinations` | Equipment combinations (must refer to entries in `equipment_config.yaml`). | Multiple combinations can be specified. | +| `focal_depths_wrt_exit_plane` | List of focal depths to characterise [mm]. | | +| `desired_intensities` | Desired free-water intensities [W/cm²]. | | +| `add_FDO` | Append Focal Distance Offset (bowl-to-exit-plane distance)? | Set to `1` if zero point in profiles reflects exit plane rather than bowl. | +| `axisymmetric2D` | Use axisymmetric 2D instead of default 3D free-water simulations? | `1 = yes`, `0 = no` | +| `force_kwavearray` | Force use of kWaveArray for free-water simulations? | If `0`, uses the setting in the default/study config. | +| `opt_method` | Optimisation method. | `FEXminimize` (open source) / `GlobalSearch` (MATLAB Global Optimization Toolbox) | +| `opt_limits` | Distance limits for optimisation [mm]. | | +| `opt_weights` | Weighting of original profile during fitting. | `1` = equal; `>1` = Gaussian (narrower with larger values) | +| `opt_seed` | Random seed for optimisation. | | +| `skip_front_peak_mm` | Distance from profile start to ignore [mm]. | Avoids near-field artefacts in peak/FWHM calculations. | + +
diff --git a/documentation/doc_preproc.md b/documentation/doc_preproc.md index 700a6d2c..35b24f72 100644 --- a/documentation/doc_preproc.md +++ b/documentation/doc_preproc.md @@ -24,7 +24,7 @@ Multiple smoothing algorithms are available: 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 conrolled by `skullfillmethod`. Potential holes are converted to (cortical) skull. +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 diff --git a/documentation/doc_troubleshooting.md b/documentation/doc_troubleshooting.md index 081c76eb..28dacfe6 100644 --- a/documentation/doc_troubleshooting.md +++ b/documentation/doc_troubleshooting.md @@ -12,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/examples/calibration_standalone.m b/examples/calibration_standalone.m index 847049b6..24a88d13 100644 --- a/examples/calibration_standalone.m +++ b/examples/calibration_standalone.m @@ -202,7 +202,7 @@ ' F ', num2str(desired_focal_distance_ep), ' I ', num2str(desired_intensity)]) % Set to always overwrite existing calibration files - parameters.overwrite_files = 'always'; + parameters.io.overwrite_files = 'always'; % perform the calibration calibration_transducer(... diff --git a/examples/demo_localite.m b/examples/demo_localite.m index 1cbeb14f..b0ff2ed7 100644 --- a/examples/demo_localite.m +++ b/examples/demo_localite.m @@ -36,10 +36,10 @@ addpath(genpath(pn.tuSIM_tools)); % [Optional, MNI transform] 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.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: % (1a) [TriggerMarkers] Localite trigger position files in: diff --git a/functions/acoustic/acoustic_analysis.m b/functions/acoustic/acoustic_analysis.m index 73e06745..b441c6b5 100644 --- a/functions/acoustic/acoustic_analysis.m +++ b/functions/acoustic/acoustic_analysis.m @@ -32,29 +32,29 @@ 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); @@ -79,18 +79,18 @@ masked_max_3d(acoustic_isppa, 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))); @@ -104,7 +104,7 @@ 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); + half_max_ISPPA_volume_brain = sum(half_max(:))*(parameters.grid.resolution_mm^3); [results.max_pressure_brain] = masked_max_3d(acoustic_pressure, mask.brain); [results.max_MI_brain] = masked_max_3d(acoustic_MI, mask.brain); @@ -132,13 +132,13 @@ 'trans_pos_vox', 'focus_pos_vox', ... 'isppa_at_target', 'avg_isppa_around_target', ... 'halfmax_ISPPA_volume_brain_mm3'}), ... - parameters.filename_output_table); + 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); + parameters.io.filename_output_table); end % Plot intensity on the segmented image (up to 2 transducers) @@ -155,7 +155,7 @@ 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, ... segmentation, ... @@ -176,9 +176,9 @@ highlighted_pos); end - output_plot = fullfile(parameters.output_dir, ... + output_plot = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_%s_isppa_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)); 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 143f1153..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 = char(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 = char(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 = char(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 = char(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 ea6e9ef4..38245c53 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -56,50 +56,50 @@ % 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; % Force water medium - sim_param.simulation_medium = 'water'; + sim_param.simulation.medium = 'water'; % Force save result matrices - sim_param.savemat = 1; + 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.n_sim_dims = 2; - parameters.axisymmetric = 1; - if numel(parameters.default_grid_dims)==3 - parameters.default_grid_dims(2) = []; + parameters.grid.axisymmetric = 1; + if numel(parameters.grid.default_dims)==3 + parameters.grid.default_dims(2) = []; end end % Force deactivate interactive mode - sim_param.interactive = 0; + sim_param.simulation.interactive = 0; % Run the simulation based on the submission method - sim_param.hpc_wait_for_job = true; - prestus_pipeline_start(sim_id, sim_param); + 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_'; @@ -160,8 +160,9 @@ opt_param.transducer.source_phase_deg = opt_source_phase_deg; opt_param.results_filename_affix = '_optimized'; - opt_param.hpc_wait_for_job = true; - prestus_pipeline_start(sim_id, opt_param); + 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', ... diff --git a/functions/calibration/extract_simulated_profile.m b/functions/calibration/extract_simulated_profile.m index 7b3dc773..3e28890e 100644 --- a/functions/calibration/extract_simulated_profile.m +++ b/functions/calibration/extract_simulated_profile.m @@ -35,14 +35,14 @@ figure; % Convert grid indices to physical distances in millimeters. - p_distance = (1:size(p_max, 1)) * parameters.grid_step_mm; - p_width = (1:size(p_max, parameters.n_sim_dims)) * parameters.grid_step_mm; + 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 parameters.n_sim_dims == 2 + if numel(parameters.grid.dims) == 2 % 2D simulation -> directly available pressure field. p_axialprofile = squeeze(p_max(:, :))'; - elseif parameters.n_sim_dims == 3 + 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 @@ -50,10 +50,10 @@ end % Location of the transducer bowl, exit plane, and focus (in mm) - i_bowl = parameters.transducer.trans_pos(end)*parameters.grid_step_mm; - i_ep = round(parameters.transducer.trans_pos(end)*parameters.grid_step_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_step_mm; + i_focus = parameters.transducer.focus_pos(end)*parameters.grid.resolution_mm; % Plot the pressure map imagesc(p_distance, p_width, p_axialprofile); @@ -83,14 +83,14 @@ %% Extract the simulated pressure profile along the focal axis % This section isolates the 1D pressure distribution along the acoustic beam. - if parameters.n_sim_dims == 2 + 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 parameters.n_sim_dims == 3 + 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); @@ -109,9 +109,9 @@ %% Collect profile and distance % caluclated distances in mm in the grid - axial_trans_pos_mm = parameters.transducer.trans_pos(end)* parameters.grid_step_mm; - axial_end_pos_mm = parameters.default_grid_dims(end) * parameters.grid_step_mm; - axial_position_sim_mm = axial_trans_pos_mm:parameters.grid_step_mm:axial_end_pos_mm; + 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); diff --git a/functions/calibration/scale_real_intensity_profile.m b/functions/calibration/scale_real_intensity_profile.m index d0866219..d1834416 100644 --- a/functions/calibration/scale_real_intensity_profile.m +++ b/functions/calibration/scale_real_intensity_profile.m @@ -37,7 +37,7 @@ profile_target.axial_distance_bowl = profile_empirical.axial_distance_bowl'; % Calculate size of the simulation domain - sim_axis_mm = (parameters.default_grid_dims(end)-parameters.pml_size-1)*parameters.grid_step_mm; + 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 @@ -47,10 +47,10 @@ 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_step_mm); + 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_step_mm; + (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 diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index c53374b7..824d1ddc 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -23,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 @@ -106,7 +106,7 @@ % Section 6: pseudoCT [Optional] (collapsed by default) try - if isfield(parameters, 'use_pseudoCT') && parameters.use_pseudoCT + 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'); @@ -133,7 +133,7 @@ % 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 @@ -143,7 +143,7 @@ % 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 @@ -351,7 +351,7 @@ end html = [html '
Subjectsub-%03d
Medium%s Layered (Realistic Head) pseudo-CT (Continuous Skull)
Medium%s Layered (Realistic Head)
Medium%s Water / Free-field
']; html = [html sprintf('', subject_id)]; - if is_layered && isfield(parameters, 'use_pseudoCT') && parameters.use_pseudoCT + if is_layered && isfield(parameters, 'pct') && isfield(parameters.pct, 'enabled') && parameters.pct.enabled html = [html sprintf('', html_escape(medium))]; elseif is_layered html = [html sprintf('', html_escape(medium))]; @@ -363,7 +363,7 @@ end html = [html sprintf('', datestr(now, 'yyyy-mm-dd HH:MM:SS'))]; if isfield(parameters, 'output_dir') - html = [html sprintf('', html_escape(parameters.output_dir))]; + html = [html sprintf('', html_escape(parameters.io.output_dir))]; end html = [html '
Subjectsub-%03d
Medium%s Layered (Realistic Head) pseudo-CT (Continuous Skull)
Medium%s Layered (Realistic Head)
Generated%s
Output dir%s
Output dir%s
']; html = [html '']; @@ -389,7 +389,7 @@ end % Thermal summary if available - if is_layered && 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 @@ -446,10 +446,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) @@ -515,19 +515,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 @@ -588,7 +588,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)); @@ -647,7 +647,7 @@ html = [html '
']; for t = 1:n_trans % ISPPA on segmentation - img_path = fullfile(parameters.output_dir, ... + img_path = fullfile(parameters.io.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)); @@ -656,7 +656,7 @@ end % ISPPA on T1 - img_path = fullfile(parameters.output_dir, ... + img_path = fullfile(parameters.io.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)); @@ -706,7 +706,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) @@ -716,7 +716,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

', ... @@ -730,7 +730,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.

'; @@ -781,7 +781,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'; @@ -851,21 +851,21 @@ html = ''; % Extract from parameters - debug_dir = parameters.output_dir; + debug_dir = parameters.io.output_dir; affix = ''; - if isfield(parameters, 'results_filename_affix') - affix = parameters.results_filename_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, '
']; - fields = {'pct_mapping_density', 'pct_mapping_soundspeed', 'pct_mapping_attenuation'}; + seg_fields = {'density', 'soundspeed', 'attenuation'}; labels = {'Density', 'Sound speed', 'Attenuation'}; - + for i = 1:3 - if isfield(parameters, fields{i}) - val = html_escape(char(parameters.(fields{i}))); + 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 @@ -889,7 +889,7 @@ % 3. KPLAN mapping - ONLY when pct_mapping_density == 'k-plan' lightbox_idx = 2; - if isfield(parameters, 'pct_mapping_density') && strcmp(parameters.pct_mapping_density, 'k-plan') + 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('
')]; @@ -904,7 +904,7 @@ end % 4. k-Wave mapping - ONLY when pct_mapping_density == 'k-wave' - if isfield(parameters, 'pct_mapping_density') && strcmp(parameters.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('
')]; @@ -1062,12 +1062,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]); @@ -1142,8 +1142,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 @@ -1187,7 +1187,7 @@ end % pCT (pCT only) - if isfield(parameters, 'use_pseudoCT') && parameters.use_pseudoCT + if isfield(parameters, 'pct') && isfield(parameters.pct, 'enabled') && parameters.pct.enabled html = [html 'pseudo-CT']; end @@ -1195,10 +1195,10 @@ 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 39d313a4..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'), ... @@ -100,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, 'sim_path') && ~strcmp(parameters.sim_path, '') - javaFileObj = java.io.File(parameters.sim_path); % 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.sim_path); + parameters.path.sim = fullfile(parameters.path.sim); else - parameters.sim_path = fullfile(parameters.data_path, parameters.sim_path); + 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, 'tussim'); + 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) || strcmp(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, 'use_pseudoCT') - parameters.use_pseudoCT = 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?', ... @@ -194,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 e472e4ea..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,50 +52,52 @@ % return to PRESTUS path cd(prestus_path); + subject_id = parameters.subject_id; + % [SIMULATION OUTPUT] 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)); - if ~exist(parameters.output_dir); mkdir(parameters.output_dir); end; - else - parameters.output_dir = parameters.sim_path; + 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, 'localite_path') && ~isempty(parameters.localite_path)) && ... - isfield(parameters,'subject_subfolder') && parameters.subject_subfolder == 1 - parameters.localite_path = fullfile(parameters.localite_path, sprintf('sub-%03d', subject_id)); - if ~exist(parameters.localite_path); mkdir(parameters.localite_path); end; + 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, 'localite_path') || isempty(parameters.localite_path) - parameters.localite_path = parameters.output_dir; + 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); @@ -103,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'); @@ -123,4 +122,4 @@ end % set initial time, RAM, GB state - log_timer('start','prestus_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 index 062a46f2..0db5bcf0 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -1,21 +1,26 @@ -function prestus_pipeline_start(subject_id, parameters, options) +function prestus_pipeline_start(parameters, options) %% PRESTUS_PIPELINE_START Universal PRESTUS pipeline launcher % -% prestus_pipeline_start(subject_id, parameters, options) +% 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: -% subject_id - Subject number (double) -% parameters - Struct with sim_path, platform, hpc_* settings +% parameters - Struct; must contain parameters.subject_id % options - Struct with sequential_configs (default: empty) arguments - subject_id double 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) @@ -23,7 +28,7 @@ function prestus_pipeline_start(subject_id, parameters, options) end % ========== PLATFORM SELECTION ========== - if ~isfield(parameters, 'platform') || strcmp(parameters.platform, 'auto') + 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)); @@ -31,21 +36,21 @@ function prestus_pipeline_start(subject_id, parameters, options) 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(subject_id, parameters, options); - + 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, subject_id); - + hpc_setup_temp_files(parameters); + % Create job files - save(temp_data_path, 'subject_id', 'parameters'); + save(temp_data_path, 'parameters'); % Generate MATLAB call fid = fopen(temp_m_path, 'w+'); @@ -54,36 +59,36 @@ function prestus_pipeline_start(subject_id, parameters, options) if ismember(fieldnames(options), 'sequential_configs') sequential_configs = options.sequential_configs; save(temp_data_path, 'sequential_configs', '-append'); - fprintf(fid, 'prestus_pipeline(subject_id, parameters, options);\n'); + fprintf(fid, 'prestus_pipeline(parameters, options);\n'); else - fprintf(fid, 'prestus_pipeline(subject_id, parameters);\n'); + 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, subject_id); - + job_name = hpc_job_name(parameters); + % Submit job - job_id = hpc_submit_job(platform, temp_m_file, parameters, subject_id, log_dir); - + 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, subject_id, ... - parameters.hpc_memorylimit, parameters.hpc_timelimit, log_dir, true); - + 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 + 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); + 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.job_id = job_id; - + parameters.hpc.job_id = job_id; + otherwise error('Unknown platform: %s. Use ''matlab'', ''slurm'', ''qsub'', or ''auto''.', ... parameters.platform); diff --git a/functions/core/print_parameter_summary.m b/functions/core/print_parameter_summary.m index b5f78d12..b23609d6 100644 --- a/functions/core/print_parameter_summary.m +++ b/functions/core/print_parameter_summary.m @@ -18,45 +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, '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_ep', '%.1f'); -print_if_field(parameters, 'expected_focal_distance_bowl', '%.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) @@ -82,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.simulation.medium); tissue_list = tissue_fields(startsWith(tissue_fields, 'tissue_') | ... startsWith(tissue_fields, 'brain') | ... startsWith(tissue_fields, 'csf')); @@ -98,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'); @@ -135,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); @@ -223,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..429ad647 100644 --- a/functions/core/simulation_nifti.m +++ b/functions/core/simulation_nifti.m @@ -18,20 +18,20 @@ 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 + if parameters.state.acoustics_available == 1 data_types = [data_types, "isppa","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; @@ -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,7 +109,7 @@ 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, "isppa") && ~strcmp(parameters.simulation.medium, 'phantom') max_plots = min(2, numel(parameters.transducer)); if numel(parameters.transducer) > max_plots @@ -157,36 +157,28 @@ 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, ... + output_plot_filename = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_%s_isppa_t1_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 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..e3c4b9a9 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: @@ -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,19 +101,19 @@ 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_isppa = fullfile(data_dir, sprintf('%s_final_isppa_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); @@ -124,11 +123,7 @@ function create_group_MNI_plots(subject_list, parameters, options) 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 @@ -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,19 +241,19 @@ 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)); + pattern_isppa = fullfile(data_dir, sprintf('%s_final_isppa_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_isppa = dir(pattern_isppa); files_segmented = dir(pattern_segmented); @@ -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); @@ -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_isppa_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_isppa_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_isppa_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 d6eb1d56..0c3179e9 100644 --- a/functions/head/head_smooth_and_crop.m +++ b/functions/head/head_smooth_and_crop.m @@ -13,14 +13,14 @@ % 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. - grid_step_mm = parameters.grid_step_mm; + 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'); @@ -32,14 +32,14 @@ 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 @@ -49,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_crop_eCSF.m b/functions/head/preproc_crop_eCSF.m index ab48aec6..6a4943c8 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.simulation.medium), '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 24ab90a6..ebdbec30 100644 --- a/functions/head/preproc_crop_grid.m +++ b/functions/head/preproc_crop_grid.m @@ -8,8 +8,8 @@ % Optimizes grid dimensions for FFT performance. Computes skull edges and adjustments. % % Inputs: -% parameters - Struct with .pad_mm (symmetric padding mm), .grid_step_mm, .pml_size, -% .csf_mask_expansion_factor, .grid_max_expand, .debug, etc. +% 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). % segmentation - 3D array: Original segmentation/pseudoCT. % bone_img - 3D array: Bone property image. @@ -27,10 +27,10 @@ % % === USER SYMMETRIC PADDING (BOTH SIDES) === -if ~isfield(parameters, 'pad_mm') || isempty(parameters.pad_mm) - parameters.pad_mm = 0; % mm +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.pad_mm / parameters.grid_step_mm); +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 @@ -48,19 +48,15 @@ end % 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 +[medium_masks] = preproc_crop_eCSF(parameters, medium_masks, segmentation, trans_pos_grid); % Set PML buffer as crop margin -crop_margin = parameters.pml_size + 1; +crop_margin = parameters.grid.pml_size + 1; % Include transducer bowl geometry (safe with padding) -i_water = find(strcmp(fieldnames(parameters.medium), 'water')); +i_water = find(strcmp(fieldnames(parameters.simulation.medium), 'water')); transducer_bowl = transducer_setup(parameters.transducer(1), trans_pos_grid, ... - focus_pos_grid, size(segmentation), parameters.grid_step_mm); + focus_pos_grid, size(segmentation), parameters.grid.resolution_mm); % Compute crop bounds orig_dims = size(medium_masks); @@ -83,9 +79,9 @@ 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); +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 === @@ -103,7 +99,7 @@ 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); +parameters.grid.dims = size(medium_masks); % Final positions in cropped grid coordinates trans_pos_final = trans_pos_grid - min_dims + 1; @@ -115,9 +111,9 @@ 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('Grid: [%dx%dx%d] → [%dx%dx%d]\n', orig_dims, parameters.grid.dims); fprintf(' User padding: %.1fmm = [%d %d %d] voxels\n', ... - parameters.pad_mm, pad_pre_post); + 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); diff --git a/functions/head/preproc_head.m b/functions/head/preproc_head.m index 1845e369..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.use_pseudoCT == 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.use_pseudoCT == 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 79fd6662..6a1ca039 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.simulation.medium); 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_fwhm_mm, parameters.grid_step_mm, ... - 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_fwhm_mm, parameters.grid_step_mm, ... - 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_fwhm_mm, parameters.grid_step_mm, ... - 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 af893a35..daa0b72e 100644 --- a/functions/head/preproc_segmentation.m +++ b/functions/head/preproc_segmentation.m @@ -9,8 +9,8 @@ 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,25 +21,20 @@ 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.use_pseudoCT == 1 + (~isfield(parameters,'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 diff --git a/functions/head/segmentation_run.m b/functions/head/segmentation_run.m index 04148d10..6591c43f 100644 --- a/functions/head/segmentation_run.m +++ b/functions/head/segmentation_run.m @@ -1,56 +1,39 @@ 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 in segmentaion folder (if it does not exist) - log_dir = fullfile(parameters.seg_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 % Platform selection @@ -64,16 +47,16 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param end % Deploy on selected platform - if strcmp(parameters.platform, 'qsub') + 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], ... 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); + 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) @@ -101,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 @@ -127,17 +110,17 @@ function segmentation_run(data_path, subject_id, filename_t1, filename_t2, param elseif strcmp(parameters.platform, 'matlab') fprintf('Running segmentation locally:\n%s\n', segment_call); - if ~isfield(parameters, 'simnibs_bin_path') || isempty(parameters.simnibs_bin_path) + 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/headreco (don't rely on PATH) - full_segment_call = sprintf('%s/%s', parameters.simnibs_bin_path, segment_call); + % 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.seg_path); + cd(parameters.path.seg); [res, out] = system(full_segment_call); cd(orig_dir); diff --git a/functions/head/skull_fill_holes.m b/functions/head/skull_fill_holes.m index b3fd0bed..f2eb1299 100644 --- a/functions/head/skull_fill_holes.m +++ b/functions/head/skull_fill_holes.m @@ -34,10 +34,10 @@ % - 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. - labels_medium = fieldnames(parameters.medium); + labels_medium = fieldnames(parameters.simulation.medium); labels_requested = fieldnames(parameters.layers); - if parameters.use_pseudoCT == 0 && any(contains(labels_requested, 'skull_cortical')) + if parameters.pct.enabled == 0 && any(contains(labels_requested, 'skull_cortical')) % treat cortical bone as the base layer skull_i = find(ismember(labels_medium, {'skull_cortical'; 'skull_trabecular'})); else @@ -49,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 @@ -61,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.simulation.medium), '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 00ce86d2..c315473e 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; @@ -90,7 +90,7 @@ end tissues_available = fieldnames(parameters.layers); - medium_labels = fieldnames(parameters.medium); + medium_labels = fieldnames(parameters.simulation.medium); if ismember(tissues_available, 'brain') BRAIN_LABEL = find(strcmp(medium_labels, 'brain')); @@ -124,7 +124,7 @@ %% 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)); % --- Strict 6-neighborhood kernel (faces only) --- @@ -177,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; @@ -201,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/helper/charm_seg_labels.m b/functions/helper/charm_seg_labels.m new file mode 100644 index 00000000..997318ab --- /dev/null +++ b/functions/helper/charm_seg_labels.m @@ -0,0 +1,37 @@ +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] +% 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.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 4645c5c8..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.use_pseudoCT + 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/tissuemask_binary.m b/functions/helper/tissuemask_binary.m index ba5fdd46..39704c89 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.simulation.medium); skull_i = find(strcmp(labels, 'skull')); cortical_i = find(strcmp(labels, 'skull_cortical')); diff --git a/functions/hpc/hpc_job_info.m b/functions/hpc/hpc_job_info.m index 8d34f6cf..ffb33efb 100644 --- a/functions/hpc/hpc_job_info.m +++ b/functions/hpc/hpc_job_info.m @@ -1,4 +1,4 @@ -function display_info = hpc_job_info(platform, job_id, job_name, subject_id, ... +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 % @@ -7,8 +7,7 @@ % Inputs: % platform - 'slurm' or 'qsub' % job_id - Raw job ID from submission -% job_name - Job name string -% subject_id - Subject number +% job_name - Job name string (encodes subject ID) % memory_gb - Memory allocation (GB) % timelimit - Time limit string % log_dir - Log directory path @@ -30,7 +29,6 @@ % Common fields display_info.name = job_name; - display_info.subject_id = subject_id; display_info.memory_gb = memory_gb; display_info.timelimit = timelimit; display_info.log_dir = log_dir; @@ -40,7 +38,7 @@ fprintf('═════════════════════════════\n'); fprintf('Job ID: %s\n', display_info.id); fprintf('Job name: %s\n', display_info.name); - fprintf('Subject: sub-%03d\n', display_info.subject_id); + 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); diff --git a/functions/hpc/hpc_job_name.m b/functions/hpc/hpc_job_name.m index eaf619c8..c2d93691 100644 --- a/functions/hpc/hpc_job_name.m +++ b/functions/hpc/hpc_job_name.m @@ -1,26 +1,25 @@ -function job_name = hpc_job_name(parameters, subject_id) +function job_name = hpc_job_name(parameters) %% HPC_JOB_NAME Generate standardized HPC job name % -% job_name = hpc_job_name(parameters, subject_id) +% job_name = hpc_job_name(parameters) % % Creates job name in format: PREFIX_sub-XXX where PREFIX is -% {slurm|qsub}_job_prefix from parameters (default: PRESTUS). +% job_prefix from parameters (default: PRESTUS). +% Subject ID is read from parameters.subject_id. % % Inputs: -% parameters - Struct (may contain slurm_job_prefix/qsub_job_prefix) -% subject_id - Subject number (double) +% 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', subject_id); - prefix_field = sprintf('job_prefix'); + subj_id_string = sprintf('sub-%03d', parameters.subject_id); - if ~isfield(parameters, 'job_prefix') - parameters.job_prefix = 'PRESTUS'; + if ~isfield(parameters.hpc, 'job_prefix') + parameters.hpc.job_prefix = 'PRESTUS'; end - job_name = [parameters.job_prefix '_' subj_id_string]; + 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 index cf740e83..c4d8e269 100644 --- a/functions/hpc/hpc_setup_temp_files.m +++ b/functions/hpc/hpc_setup_temp_files.m @@ -1,24 +1,25 @@ function [log_dir, prestus_path, temp_data_path, temp_m_path, temp_m_file] = ... - hpc_setup_temp_files(parameters, subject_id) + 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_path - Path for temporary .m script file % temp_m_file - Basename of MATLAB script (no path) % -% See also HPC_WRITE_MATLAB_SCRIPT, HPC_SUBMIT_JOB. +% See also HPC_SUBMIT_JOB. % Setup output directory -if isfield(parameters, 'subject_subfolder') && parameters.subject_subfolder - output_dir = fullfile(parameters.sim_path, sprintf('sub-%03d', subject_id)); +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.sim_path; + output_dir = parameters.path.sim; end if ~isfolder(output_dir), mkdir(output_dir); end diff --git a/functions/hpc/hpc_submit_job.m b/functions/hpc/hpc_submit_job.m index 3e8bf591..462a1896 100644 --- a/functions/hpc/hpc_submit_job.m +++ b/functions/hpc/hpc_submit_job.m @@ -1,16 +1,14 @@ -function job_id = hpc_submit_job(hpc_type, temp_m_file, parameters, subject_id, log_dir) +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 (hpc_partition, hpc_gpu, etc.) -% subject_id - Subject number for naming -% parameters.hpc_timelimit - Walltime in format 'HH:MM:SS' or minutes -% parameters.hpc_memorylimit - Memory in GB +% parameters - Job parameters; must contain parameters.subject_id % log_dir - Log directory path % % Output: @@ -18,55 +16,55 @@ % % See also HPC_DETECT_SYSTEM, HPC_WAIT_FOR_COMPLETION. -subj_id_string = sprintf('sub-%03d', subject_id); +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, subject_id, temp_m_file, log_dir); + 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, subject_id, temp_m_file, log_dir); + 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, subject_id), sprintf('%d', job_id)); +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, subject_id, temp_m_file, log_dir) - subj_id_string = sprintf('sub-%03d', subject_id); - job_name = hpc_job_name(parameters, subject_id); +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, 'code_type') && any(strcmp(parameters.code_type, {'matlab_gpu', 'cpp_gpu'}))); + 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))); + 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))); + + 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))); + 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 --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); @@ -90,15 +88,15 @@ function write_slurm_script(temp_slurm_path, parameters, subject_id, temp_m_file job_id = str2double(job_ids{1}); end -function write_qsub_script(temp_qsub_path, parameters, subject_id, temp_m_file, log_dir) - subj_id_string = sprintf('sub-%03d', subject_id); - job_name = hpc_job_name(parameters, subject_id); +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); + 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); diff --git a/functions/hpc/hpc_validate_parameters.m b/functions/hpc/hpc_validate_parameters.m index 6b888ed6..b843ec42 100644 --- a/functions/hpc/hpc_validate_parameters.m +++ b/functions/hpc/hpc_validate_parameters.m @@ -12,10 +12,10 @@ function hpc_validate_parameters(parameters, hpc_type) % % See also HPC_DETECT_SYSTEM, HPC_SUBMIT_JOB. -if parameters.interactive +if parameters.simulation.interactive warning('Interactive mode disabled for %s jobs.', upper(hpc_type)); - parameters.interactive = false; + parameters.simulation.interactive = false; end -assert(matches(parameters.overwrite_files, ["always", "never"]), ... +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 index 0ede253e..240c1be8 100644 --- a/functions/hpc/hpc_wait_for_completion.m +++ b/functions/hpc/hpc_wait_for_completion.m @@ -1,10 +1,12 @@ -function hpc_wait_for_completion(job_id, hpc_type) +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; -max_checks = 540; % 3 hours checks = 0; tic_start = tic; diff --git a/functions/medium/medium_pct_density.m b/functions/medium/medium_pct_density.m index 201a7ce9..3adaefa4 100644 --- a/functions/medium/medium_pct_density.m +++ b/functions/medium/medium_pct_density.m @@ -11,8 +11,8 @@ density(skull_idx) = fit_pairwiselinear(pseudoCT(skull_idx), hounsfieldUnits, massDensity, 1); % plot the mapping - if parameters.debug == 1 - output_plot = fullfile(parameters.debug_dir, ... + 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 @@ -37,8 +37,8 @@ density(skull_idx) = hounsfield2density(pseudoCT(skull_idx), 1); % plot the mapping - if parameters.debug == 1 - output_plot = fullfile(parameters.debug_dir, ... + 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 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 cf61d980..c9749464 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -15,13 +15,13 @@ % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % 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.simulation.medium), 'water')); % Loads the medium settings from the config file - medium = parameters.medium; + medium = parameters.simulation.medium; % 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; @@ -29,18 +29,18 @@ 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.simulation.medium); % 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.use_pseudoCT == 1 && strcmp(label_name, 'skull') + if parameters.pct.enabled == 1 && strcmp(label_name, 'skull') skull_idx = find(ismember(medium_masks,medium_i)); % set skull thermal conductivity @@ -65,31 +65,31 @@ end % map skull bone density with the desired algorithm - if isfield(parameters, "pct_mapping_density") - pct_mapping_density = parameters.pct_mapping_density; + if isfield(parameters, 'pct') && isfield(parameters.pct, 'mapping_density') + pct_mapping_density = parameters.pct.mapping_density; else pct_mapping_density = 'none'; end [density] = medium_pct_density(parameters, density, pseudoCT, skull_idx, pct_mapping_density); % map skull bone sound speed with the desired algorithm - if isfield(parameters, "pct_mapping_soundspeed") - pct_mapping_soundspeed = parameters.pct_mapping_soundspeed; + 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); - + % map skull bone attenuation with the desired algorithm - if isfield(parameters, "pct_mapping_attenuation") - pct_mapping_attenuation = parameters.pct_mapping_attenuation; + 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") @@ -113,8 +113,8 @@ 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.debug_dir, ... - sprintf('pCT_histograms%s.png',parameters.results_filename_affix)); + 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 @@ -154,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; @@ -174,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 @@ -188,14 +188,14 @@ %% smooth medium masks - if isfield(parameters, 'smooth_properties') && parameters.smooth_properties == true + 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.smooth_fwhm_mm; - grid_mm = parameters.grid_step_mm; - smooth_method = parameters.smooth_method; + fwhm_mm = parameters.headmodel.smooth_fwhm_mm; + grid_mm = parameters.grid.resolution_mm; + smooth_method = parameters.headmodel.smooth_method; sound_speed = smooth_img(sound_speed, fwhm_mm, grid_mm, 0, smooth_method); density = smooth_img(density, fwhm_mm, grid_mm, 0, smooth_method); @@ -207,7 +207,7 @@ temp_0 = smooth_img(temp_0, fwhm_mm, grid_mm, 0, smooth_method); % [DEBUG] Plot unsmoothed and smoothed density - if parameters.debug == 1 + 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),:)); @@ -218,9 +218,9 @@ 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.debug_dir, ... + output_plot_filename = fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_%s_density_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); clear density_pre density_post; @@ -243,29 +243,29 @@ 'temp_0', temp_0); %% [debug] save acoustic property images - if parameters.debug == 1 + if parameters.simulation.debug == 1 % save raw medium matrices as niftis try - filename_density = fullfile(parameters.debug_dir, sprintf('matrix_density')); + filename_density = fullfile(parameters.io.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')); + 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.debug_dir, sprintf('matrix_alpha_coeff')); + 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.debug_dir, sprintf('matrix_alpha_power')); + 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.debug_dir, sprintf('matrix_alpha_coeff_fixed')); + 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.debug_dir, sprintf('matrix_perfusion')); + filename_perfusion = fullfile(parameters.io.debug_dir, sprintf('matrix_perfusion')); niftiwrite(perfusion_coeff, filename_perfusion, 'Compressed',true); pause(0.1); - filename_absorption = fullfile(parameters.debug_dir, sprintf('matrix_absorption')); + 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 % save images of assigned medium properties with proper headers - if contains(parameters.simulation_medium, {'layered'}) && (exist('planimg') & ~isempty(planimg)) + 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') @@ -277,8 +277,8 @@ end % save a pCT (if used) - if parameters.use_pseudoCT == 1 - filename_pct = fullfile(parameters.debug_dir, sprintf('pct%s', parameters.results_filename_affix)); + 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 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/position_transducer_localite.m b/functions/neuronav/position_transducer_localite.m index 04bbddfc..ea806d5c 100644 --- a/functions/neuronav/position_transducer_localite.m +++ b/functions/neuronav/position_transducer_localite.m @@ -10,7 +10,7 @@ % 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). +% * 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: @@ -22,7 +22,7 @@ %% 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). + % - '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: @@ -50,7 +50,7 @@ %% 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_bowl * reference_center_to_head; 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/plot/plot_overlay.m b/functions/plot/plot_overlay.m index 1f848c35..61c0709b 100644 --- a/functions/plot/plot_overlay.m +++ b/functions/plot/plot_overlay.m @@ -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(:)) + ... @@ -187,8 +187,8 @@ axis off; % draw transducer (if grid was properly set up) - if ~isempty(trans_pos) && parameters.run_grid_setup == 1 - options.grid_step = parameters.grid_step_mm; + 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 @@ -231,7 +231,7 @@ rect_size = options.rect_size; 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_transducer_overlay.m b/functions/plot/plot_transducer_overlay.m index 6b3a3c50..31d38286 100644 --- a/functions/plot/plot_transducer_overlay.m +++ b/functions/plot/plot_transducer_overlay.m @@ -2,7 +2,7 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, %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_step_mm, trans_pos, grid_dims} +% 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 @@ -24,7 +24,7 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, % 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_step_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) @@ -47,7 +47,7 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, 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)), ... + num2str(round((trans_pos(2)-1)*parameters.grid.resolution_mm)), ... 'Color', 'w', 'FontSize', 10, 'FontWeight', 'bold'); % GRID ONSET MARKERS @@ -55,7 +55,7 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, 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, ... + 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 @@ -67,7 +67,7 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, 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_step_mm)), ... + sprintf('%.0f mm', round(ex_plane_x_simple*parameters.grid.resolution_mm)), ... 'Color', 'w', 'FontSize', 10, 'FontWeight', 'bold'); end @@ -80,7 +80,7 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, 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_step_mm)), ... + sprintf('%.0f mm', round(max_data_pos(2)*parameters.grid.resolution_mm)), ... 'Color', 'w', 'FontSize', 10); end 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/prestus_pipeline.m b/functions/prestus_pipeline.m index c5815df8..ab9020d3 100644 --- a/functions/prestus_pipeline.m +++ b/functions/prestus_pipeline.m @@ -1,6 +1,5 @@ -function [parameters] = prestus_pipeline(subject_id, parameters, options) +function [parameters] = prestus_pipeline(parameters, options) arguments - subject_id parameters struct options struct = struct() end @@ -31,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 @@ -44,9 +43,9 @@ fprintf('========================================\n'); fprintf('SEGMENTATION \n'); fprintf('========================================\n\n'); - log_timer('start','segmentation', parameters.seg_path); + log_timer('start','segmentation', parameters.path.seg); - if contains(parameters.simulation_medium, {'layered'}) + if contains(parameters.simulation.medium, {'layered'}) parameters = preproc_segmentation(parameters); else disp('No head segmentation necessary...') @@ -62,9 +61,9 @@ fprintf('========================================\n'); fprintf('GRID SETUP & HEAD PREPROC \n'); fprintf('========================================\n\n'); - log_timer('start','preproc', parameters.output_dir); + log_timer('start','preproc', parameters.io.output_dir); - if ~isfield(parameters, 'run_grid_setup') || parameters.run_grid_setup==1 + if ~isfield(parameters.modules, 'run_grid_setup') || parameters.modules.run_grid_setup==1 % Focal distance calculation (if not specified) parameters = focal_distance_calculation(parameters); @@ -95,19 +94,19 @@ fprintf('========================================\n'); fprintf('MEDIUM PROPERTY MAPPING \n'); fprintf('========================================\n\n'); - log_timer('start','medium', parameters.output_dir); + log_timer('start','medium', parameters.io.output_dir); - if ~isfield(parameters, 'run_medium_setup') || parameters.run_medium_setup==1 - if parameters.use_pseudoCT == 1 + 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, '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) + 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 @@ -130,9 +129,9 @@ fprintf('========================================\n'); fprintf('K-WAVE SOURCE SETUP \n'); fprintf('========================================\n\n'); - log_timer('start','source', parameters.output_dir); + log_timer('start','source', parameters.io.output_dir); - if ~isfield(parameters, 'run_source_setup') || parameters.run_source_setup==1 + 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(... @@ -142,14 +141,14 @@ 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 @@ -166,16 +165,16 @@ fprintf('========================================\n'); fprintf('ACOUSTIC SIMULATION \n'); fprintf('========================================\n\n'); - log_timer('start','acoustic', parameters.output_dir); + log_timer('start','acoustic', parameters.io.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 isfield(parameters, 'run_acoustic_sims') && parameters.run_acoustic_sims &&... + 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.interactive == 0 || ... + (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] = ... @@ -190,16 +189,16 @@ 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 disp('No acoustic simulation available or requested ... skipping analysis') - parameters.acoustics_available = 0; - parameters.run_acoustic_analysis = 0; + parameters.state.acoustics_available = 0; + parameters.modules.run_acoustic_analysis = 0; end log_timer('stop', 'acoustic'); @@ -210,9 +209,9 @@ fprintf('========================================\n'); fprintf('ACOUSTIC ANALYSIS \n'); fprintf('========================================\n\n'); - log_timer('start','acoustic_analysis', parameters.output_dir); + log_timer('start','acoustic_analysis', parameters.io.output_dir); - if (~isfield(parameters, 'run_acoustic_analysis') || parameters.run_acoustic_analysis) + if (~isfield(parameters.modules, 'run_acoustic_analysis') || parameters.modules.run_acoustic_analysis) [results_acoustic, acoustic_isppa, acoustic_MI, acoustic_pressure, highlighted_pos] = ... acoustic_analysis(parameters, kwave_medium, medium_masks, sensor_data, segmentation, source_labels); else @@ -232,20 +231,20 @@ fprintf('========================================\n'); fprintf('THERMAL SIMULATIONS \n'); fprintf('========================================\n\n'); - log_timer('start','thermal', parameters.output_dir); + log_timer('start','thermal', parameters.io.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; @@ -253,7 +252,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 @@ -278,7 +277,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, ... @@ -289,19 +288,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.run_thermal_analysis = 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'); @@ -313,10 +312,10 @@ fprintf('========================================\n'); fprintf('THERMAL ANALYSIS \n'); fprintf('========================================\n\n'); - log_timer('start','thermal_analysis', parameters.output_dir); + log_timer('start','thermal_analysis', parameters.io.output_dir); - if parameters.heating_available == 1 && ... - (~isfield(parameters, 'run_thermal_analysis') || parameters.run_thermal_analysis) + 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); else @@ -332,9 +331,9 @@ fprintf('========================================\n'); fprintf('NIFTI IMAGES \n'); fprintf('========================================\n\n'); - log_timer('start','nifti', parameters.output_dir); + log_timer('start','nifti', parameters.io.output_dir); - if ~isfield(parameters, 'run_nifti_creation') || parameters.run_nifti_creation==1 + if ~isfield(parameters.modules, 'run_nifti_creation') || parameters.modules.run_nifti_creation==1 simulation_nifti(parameters, planimg, results_acoustic, ... acoustic_isppa, acoustic_MI, acoustic_pressure, ... medium_masks, results_heating, kwave_medium, highlighted_pos) @@ -362,7 +361,7 @@ disp('Pipeline finished successfully'); % Generate HTML simulation report (after all timers, before diary closes) - if isfield(parameters, 'generate_report') && parameters.generate_report + if isfield(parameters.modules, 'generate_report') && parameters.modules.generate_report generate_simulation_report(parameters); end @@ -374,8 +373,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'); @@ -385,21 +384,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 - water_parameters.hpc_timelimit = '05:00:00'; - water_parameters.hpc_memorylimit = 40; - water_parameters.hpc_wait_for_job = false; - prestus_pipeline_start(parameters.subject_id, water_parameters); + 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 @@ -420,17 +419,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); if ~isempty(fieldnames(sequential_configs)) options.sequential_configs = sequential_configs; else options = rmfield(options, 'sequential_configs'); end - prestus_pipeline_start(subject_id, parameters, options) + 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 a6b53410..44329ced 100644 --- a/functions/source/grid_axisymmetry.m +++ b/functions/source/grid_axisymmetry.m @@ -3,15 +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); + 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'; @@ -20,8 +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; + 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 eccb5f4c..1e1843b0 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 @@ -65,14 +65,14 @@ 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_bowl/parameters.grid_step_mm); + focus_pos(numel(parameters.grid.dims)) = ... + round(focus_pos(numel(parameters.grid.dims)) + ... + parameters.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 @@ -88,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 fe4cc742..7100f37a 100644 --- a/functions/thermal/thermal_simulation.m +++ b/functions/thermal/thermal_simulation.m @@ -52,12 +52,12 @@ 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; @@ -65,8 +65,8 @@ % 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.use_pseudoCT ==1 && ... - (~isfield(parameters, "pct_mapping_density") || strcmp(parameters.pct_mapping_density, '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; @@ -88,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...") @@ -132,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 @@ -154,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 e19c38ad..baa08f53 100644 --- a/functions/transducer/focal_distance_calculation.m +++ b/functions/transducer/focal_distance_calculation.m @@ -28,7 +28,7 @@ % % 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) % @@ -92,7 +92,7 @@ % 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_step_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) diff --git a/functions/transducer/tp_candidate_mesh.m b/functions/transducer/tp_candidate_mesh.m index 6adae398..0e4932a0 100644 --- a/functions/transducer/tp_candidate_mesh.m +++ b/functions/transducer/tp_candidate_mesh.m @@ -60,7 +60,7 @@ 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.tp_dist_close / pixel_size)); + 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 -------------------------------------- diff --git a/functions/transducer/tp_evaluate_candidate_positions.m b/functions/transducer/tp_evaluate_candidate_positions.m index e0f5c18d..1d18a1fc 100644 --- a/functions/transducer/tp_evaluate_candidate_positions.m +++ b/functions/transducer/tp_evaluate_candidate_positions.m @@ -2,7 +2,7 @@ %% TP_EVALUATE_CANDIDATE_POSITIONS Evaluate candidate transducer positions % SEARCH CRITERIA: % 1. Skull exterior surface points (img==0 → imdilate gradient) -% 2. Within parameters.tp_dist_close mm Euclidean distance from target +% 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 % diff --git a/functions/transducer/tp_plot_candidate_positions.m b/functions/transducer/tp_plot_candidate_positions.m index cf01c641..d5a758d7 100644 --- a/functions/transducer/tp_plot_candidate_positions.m +++ b/functions/transducer/tp_plot_candidate_positions.m @@ -42,7 +42,7 @@ function tp_plot_candidate_positions(img, target, trans_candidate, ... get_transducer_box(trans_xz, target_xz, pixel_size, parameters); % Save - output_plot = fullfile(parameters.output_dir, ... + output_plot = fullfile(parameters.io.output_dir, ... sprintf('sub-%03d_bounds_%s.png', subject_id, target_name)); saveas(h, output_plot, 'png'); close(h); diff --git a/functions/transducer/tp_plot_geometry_overlay.m b/functions/transducer/tp_plot_geometry_overlay.m index 10b7519c..25dd57bb 100644 --- a/functions/transducer/tp_plot_geometry_overlay.m +++ b/functions/transducer/tp_plot_geometry_overlay.m @@ -59,7 +59,7 @@ function tp_plot_geometry_overlay(img, target_pos, trans_pos, pixel_size, parame get_transducer_box(trans_xz, target_xz, pixel_size, parameters); % Save geometry validation plot -saveas(h, fullfile(parameters.output_dir, sprintf('sub-%03d_geometry_%s.png', subject_id, target_name)), 'png'); +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 index 451ba64b..3bc7892c 100644 --- a/functions/transducer/tp_plot_heuristic_position.m +++ b/functions/transducer/tp_plot_heuristic_position.m @@ -77,13 +77,13 @@ function tp_plot_heuristic_position(trans_pos, target_pos, img, img_header, para view([-175,0]) % save figure in output directory -output_plot = fullfile(parameters.output_dir,... +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, 'localite_path') && ~isempty(parameters.localite_path) - saveas(h, fullfile(parameters.localite_path, ... +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 diff --git a/functions/transducer/tp_remove_ear_locations.m b/functions/transducer/tp_remove_ear_locations.m index 4afc8f18..9de3f1f6 100644 --- a/functions/transducer/tp_remove_ear_locations.m +++ b/functions/transducer/tp_remove_ear_locations.m @@ -4,17 +4,20 @@ % Only runs if all three ear parameters are specified in parameters struct % Only run if all three ear parameters are specified -if isfield(parameters, {'tp_ear_radius', 'tp_left_ear_center', 'tp_right_ear_center'}) +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.tp_ear_radius) && ~isempty(parameters.tp_left_ear_center) && ~isempty(parameters.tp_right_ear_center) - + 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.tp_ear_radius; - left_ear_center = parameters.tp_left_ear_center; - right_ear_center = parameters.tp_right_ear_center; + 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 + ... diff --git a/functions/transducer/tp_select_heuristic_position.m b/functions/transducer/tp_select_heuristic_position.m index fe060bab..01ea67fe 100644 --- a/functions/transducer/tp_select_heuristic_position.m +++ b/functions/transducer/tp_select_heuristic_position.m @@ -24,19 +24,22 @@ % Default parameter values if not provided defaults = struct( ... - 'tp_criterion_intersect', 0.05, ... - 'tp_criterion_skin_mean', NaN, ... - 'tp_criterion_skull_mean', NaN, ... - 'tp_criterion_skin_var', NaN, ... - 'tp_criterion_skull_var', NaN, ... - 'tp_expand_step', 0.01 ... + '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, fields{k}) || isempty(parameters.(fields{k})) - parameters.(fields{k}) = defaults.(fields{k}); + 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 @@ -44,12 +47,12 @@ %% STEP 1 — Intersection (adaptive expansion) -if ~isnan(parameters.tp_criterion_intersect) - criterion = parameters.tp_criterion_intersect; +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.tp_expand_step; + criterion = criterion + parameters.placement.heuristic.expand_step; tppf = locs(locs.prop_intersect < criterion, :); end else @@ -57,9 +60,9 @@ end %% STEP 2 — Skin proximity -if ~isnan(parameters.tp_criterion_skin_mean) && ~isempty(tppf) +if ~isnan(parameters.placement.heuristic.criterion_skin_mean) && ~isempty(tppf) % Use quantile - skin_lim = quantile(tppf.mean_dist_skin, parameters.tp_criterion_skin_mean); + 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 @@ -67,27 +70,27 @@ %% STEP 3 — Skull variance -if ~isnan(parameters.tp_criterion_skull_var) && ~isempty(tppf) +if ~isnan(parameters.placement.heuristic.criterion_skull_var) && ~isempty(tppf) % Use quantile - skull_var_lim = quantile(tppf.var_dist_skull, parameters.tp_criterion_skull_var); + 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.tp_criterion_skull_mean) && ~isempty(tppf) +if ~isnan(parameters.placement.heuristic.criterion_skull_mean) && ~isempty(tppf) % Use quantile - skull_mean_lim = quantile(tppf.mean_dist_skull, parameters.tp_criterion_skull_mean); + 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.tp_criterion_skin_var) && ~isempty(tppf) +if ~isnan(parameters.placement.heuristic.criterion_skin_var) && ~isempty(tppf) % Use quantile - skin_var_lim = quantile(tppf.var_dist_skin, parameters.tp_criterion_skin_var); + 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; @@ -159,13 +162,13 @@ table_formatted = rows2vars(best_trans_pos); -table_path = fullfile(parameters.output_dir, sprintf('sub-%03.0f_%s.txt', subject_id, target_name)); +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, 'localite_path') && ~isempty(parameters.localite_path) - localite_path = fullfile(parameters.localite_path,... +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); diff --git a/functions/transducer/transducer_positioning.m b/functions/transducer/transducer_positioning.m index 04f74cb3..de6481ea 100644 --- a/functions/transducer/transducer_positioning.m +++ b/functions/transducer/transducer_positioning.m @@ -1,9 +1,8 @@ -function transducer_positioning(parameters, pn, subject_id, target_name, mni_targets) +function transducer_positioning(parameters, pn, target_name, mni_targets) % TRANSDUCER_POSITIONING Heuristic transducer placement for MNI targets arguments parameters struct pn struct - subject_id double target_name string mni_targets struct end @@ -14,7 +13,8 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar % add functions here to detect path setup function addpath(genpath(fullfile(currentLoc, '..'))); -[parameters] = path_log_setup(parameters, get_prestus_path, subject_id); +[parameters] = path_log_setup(parameters, get_prestus_path); +subject_id = parameters.subject_id; %% 2. LOAD SEGMENTATION DATA @@ -26,14 +26,14 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar voxel_size = mean(img_header.PixelDimensions); % [DEBUG] plot the segmentation -if parameters.debug +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.debug_dir, ... + saveas(h, fullfile(parameters.io.debug_dir, ... sprintf('sub-%03d_segmentation.png', subject_id)), 'png'); close(h); clear im_center; @@ -42,7 +42,7 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar %% heuristic positioning % specify output file -tpos_output_file = fullfile(parameters.output_dir, ... +tpos_output_file = fullfile(parameters.io.output_dir, ... sprintf('tpars_sub-%03i_%s.csv', subject_id, target_name)); if confirm_overwriting(tpos_output_file, parameters) @@ -103,11 +103,12 @@ function transducer_positioning(parameters, pn, subject_id, target_name, mni_tar %% [Optional] Save T1w image with localite-ready header -if isfield(parameters, 'tp_save_localiteT1') && parameters.tp_save_localiteT1 && ... - isfield(parameters, 'localite_path') && ~isempty(parameters.localite_path) +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.localite_path, ... + 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); diff --git a/functions/transducer/transducer_positioning_start.m b/functions/transducer/transducer_positioning_start.m index f7bdab29..ac53457c 100644 --- a/functions/transducer/transducer_positioning_start.m +++ b/functions/transducer/transducer_positioning_start.m @@ -1,6 +1,5 @@ -function transducer_positioning_start(subject_id, parameters, pn, target_name, mni_targets) +function transducer_positioning_start(parameters, pn, target_name, mni_targets) arguments - subject_id double parameters struct pn struct target_name string @@ -8,7 +7,7 @@ function transducer_positioning_start(subject_id, parameters, pn, target_name, m end % ========== PLATFORM SELECTION ========== - if ~isfield(parameters, 'platform') || strcmp(parameters.platform, 'auto') + 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)); @@ -21,41 +20,41 @@ function transducer_positioning_start(subject_id, parameters, pn, target_name, m switch parameters.platform case 'matlab' fprintf('🖥️ Running in MATLAB\n\n'); - transducer_positioning(parameters, pn, subject_id, target_name, mni_targets); + 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, subject_id); - + hpc_setup_temp_files(parameters); + % populate temp data - save(temp_data_path, "subject_id", "parameters", "pn", "target_name", "mni_targets"); - + 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, subject_id, target_name, mni_targets);\n'); + 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.job_prefix = 'TP'; - job_name = hpc_job_name(parameters, subject_id); - job_id = hpc_submit_job(platform, temp_m_file, parameters, subject_id, log_dir); - job_info = hpc_job_info(platform, job_id, job_name, subject_id, ... - parameters.hpc_memorylimit, parameters.hpc_timelimit, log_dir, 1); - - if isfield(parameters, 'hpc_wait_for_job') && parameters.hpc_wait_for_job + + 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); + 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.job_id = job_id; + parameters.hpc.job_id = job_id; otherwise error('Unknown platform: %s. Use ''matlab'', ''slurm'', ''qsub'', or ''auto''.', ... diff --git a/functions/transducer/transducer_setup.m b/functions/transducer/transducer_setup.m index cb0b91a6..23307a7e 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.resolution_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.resolution_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.resolution_mm / 2) + 1; % Outer diameter in grid points + transducer_pars.Elements_ID = 2*floor(transducer_pars.Elements_ID_mm / grid.resolution_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.resolution_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/convert_axisymmetric_to_2d.m b/functions/transform/convert_axisymmetric_to_2d.m index 385180e5..c81ce266 100644 --- a/functions/transform/convert_axisymmetric_to_2d.m +++ b/functions/transform/convert_axisymmetric_to_2d.m @@ -19,14 +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.grid.dims(2) = parameters.grid.dims(2)*2; % shift radial to x dim -parameters.grid_dims = fliplr(parameters.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'; @@ -47,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 b8e9a8d0..c9dd56e2 100644 --- a/functions/transform/convert_axisymmetric_to_3d.m +++ b/functions/transform/convert_axisymmetric_to_3d.m @@ -18,22 +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.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); @@ -46,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/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 index 50ee6d1c..78822315 100644 --- a/functions/transform/transform_coordinates.m +++ b/functions/transform/transform_coordinates.m @@ -19,7 +19,7 @@ switch input_cs case 'mni' - m2m_path = fullfile(parameters.seg_path, sprintf('m2m_sub-%03i', parameters.subject_id)); + m2m_path = fullfile(parameters.path.seg, sprintf('m2m_sub-%03i', parameters.subject_id)); % MNI -> subject RAS+ disp("Mapping MNI to subject RAS+...") diff --git a/simple_main.m b/simple_main.m index 1e5c462f..c57e5514 100644 --- a/simple_main.m +++ b/simple_main.m @@ -6,18 +6,17 @@ % Set medium, submit medium, subject number and configuration file clear; close all; -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 = 'layered'; % water or layered -parameters.code_type = 'matlab_cpu'; -parameters.hpc_submit_medium = 'matlab'; % or 'auto' -parameters.interactive = 1; % for interactive debugging -parameters.overwrite_files = 'always'; % overwrite? +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? -prestus_pipeline_start(subject_id, parameters) \ No newline at end of file +prestus_pipeline_start(parameters) \ No newline at end of file From 69f45a8f1552fb5474e05ac6c646736c196a47f5 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 19 Mar 2026 23:46:15 +0100 Subject: [PATCH 135/144] [parameters] segmentation_only mode --- configs/default_config.yaml | 1 + documentation/doc_getting-started.md | 2 +- documentation/doc_modules.md | 61 ++++++++++++++++++++++++++++ documentation/doc_parameters.md | 1 + functions/prestus_pipeline.m | 7 ++++ mkdocs.yaml | 1 + 6 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 documentation/doc_modules.md diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 41311983..42529886 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -66,6 +66,7 @@ modules: run_nifti_creation: 1 # Run nifti creation? run_posthoc_water_sims: 1 # Run simulations in free water with identical setup? generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? + segmentation_only: 0 # Stop after segmentation (skip grid setup and all simulations)? 1=yes, 0=no #------------ High-performance computing diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index 9485910d..3070f8e9 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -60,7 +60,7 @@ See [Placement](doc_placement.md). It may be desirable to run an intial SimNIBS call prior to running the full pipeline incl. segmentation postprocessing, source setup, acoustic and thermal simulations. By default, existing segmentations will be reused and not overwritten unless explicitly requested with `overwrite_simnibs` (regardless of `overwrite_files`). -To this end, `run_grid_setup`, `run_medium_setup`, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, `run_nifti_creation`, and `run_posthoc_water_sims` can be deactivated (set to `0`). This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). +Set `modules.segmentation_only = 1` to stop the pipeline immediately after segmentation, skipping all subsequent steps. This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). ### [Optional] Create a pseudoCT diff --git a/documentation/doc_modules.md b/documentation/doc_modules.md new file mode 100644 index 00000000..4b0f2d96 --- /dev/null +++ b/documentation/doc_modules.md @@ -0,0 +1,61 @@ +# Modules + +`prestus_pipeline` is composed of discrete, independently toggleable modules. Each module corresponds to a stage of the TUS simulation workflow and can be enabled or disabled via `modules.*` flags in the config. This allows partial runs — for example, re-running only analysis after changing output thresholds, or stopping after segmentation before committing to a full simulation. + +--- + +## Pipeline stages + +``` +load_parameters + │ + ▼ +[segmentation] SimNIBS charm — structural MRI → tissue labels + │ modules.segmentation_only = 1 → stop here + ▼ +[grid setup] modules.run_grid_setup + │ Crop & orient head, position transducer & target + ▼ +[medium setup] modules.run_medium_setup + │ Map tissue labels → acoustic/thermal properties + ▼ +[source setup] modules.run_source_setup + │ Construct k-Wave source from transducer geometry + ▼ +[acoustic simulation] modules.run_acoustic_sims + │ k-Wave pressure field simulation + ▼ +[acoustic analysis] modules.run_acoustic_analysis + │ Extract Isppa, Ispta, focal metrics; write CSV + ▼ +[thermal simulation] modules.run_heating_sims + │ k-Wave heat diffusion; requires acoustic results + ▼ +[thermal analysis] (run automatically when heating results available) + │ + ▼ +[NIfTI export] modules.run_nifti_creation + │ Write pressure/temperature maps in subject space + ▼ +[report] modules.generate_report + │ Self-contained HTML report + ▼ +[free-water reference] modules.run_posthoc_water_sims + Re-run acoustics in water for Isppa normalisation +``` + +--- + +## Common partial-run patterns + +| Goal | Settings | +|---|---| +| Run SimNIBS segmentation only | `modules.segmentation_only = 1` | +| Re-run from acoustic simulation onwards | `modules.run_grid_setup = 0`, `run_medium_setup = 0`, `run_source_setup = 0` | +| Add thermal simulation to completed acoustic run | `modules.run_heating_sims = 1`; all others can remain `1` | +| Regenerate analysis outputs only | `run_acoustic_sims = 0`, `run_heating_sims = 0` | +| Skip free-water reference | `modules.run_posthoc_water_sims = 0` | + +> **Note:** Downstream modules depend on outputs from upstream ones. If intermediate files already exist on disk (e.g. from a prior run), PRESTUS reuses them subject to `io.overwrite_files`. If they do not exist, disabling an upstream module while enabling a downstream one will error. + +--- diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 95e0a5e1..b9e18554 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -102,6 +102,7 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST | `run_nifti_creation` | Export results as NIfTI files. | `1` | | | `run_posthoc_water_sims` | Run free-water reference simulations after head simulations. | `1` | | | `generate_report` | Generate self-contained HTML simulation report. | `1` | | +| `segmentation_only` | Stop after segmentation; skip grid setup and all simulations. | `0` | Only has effect when `simulation.medium = 'layered'`. | All flags: `1 = yes`, `0 = no`. diff --git a/functions/prestus_pipeline.m b/functions/prestus_pipeline.m index ab9020d3..fdddd856 100644 --- a/functions/prestus_pipeline.m +++ b/functions/prestus_pipeline.m @@ -52,6 +52,13 @@ end log_timer('stop','segmentation'); + 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 + % ==================================================================== %% GRID: PREPROCESS structural MRI & POSITION transducer + target % ==================================================================== diff --git a/mkdocs.yaml b/mkdocs.yaml index 44a67519..986c5631 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -57,6 +57,7 @@ nav: - 📚 References: - Parameters: doc_parameters.md - Functions: doc_functions.md + - Modules: doc_modules.md - 🖥️ Advanced: - Advanced Workflows: doc_advanced.md - Backend: doc_backend.md From ed2e7fd01870ff72f571b7c602447705dbb56e3a Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Sat, 21 Mar 2026 17:50:48 +0100 Subject: [PATCH 136/144] [parameters] hotfixes, further parameter rename --- configs/default_config.yaml | 17 +++--- configs/tmp_test_n-transducers.yaml | 54 ------------------- documentation/doc_medium.md | 2 +- documentation/doc_parameters.md | 4 +- .../calibration/compute_oneil_solution.m | 6 +-- .../calibration/extract_simulated_profile.m | 4 +- .../calibration/fit_velocity_to_intensity.m | 6 +-- .../calibration/phase_optimization_annulus.m | 4 +- .../phase_optimization_annulus_full_curve.m | 6 +-- .../calibration/recompute_oneil_solution.m | 6 +-- .../scale_real_intensity_profile.m | 4 +- functions/calibration/set_real_phases.m | 2 +- functions/core/print_parameter_summary.m | 4 +- functions/head/head_smooth_and_crop.m | 2 +- functions/head/preproc_crop_eCSF.m | 2 +- functions/head/preproc_crop_grid.m | 2 +- functions/head/preproc_medium_mask.m | 2 +- functions/head/preproc_segmentation.m | 2 +- functions/head/skull_fill_holes.m | 4 +- functions/head/skull_rubber_wrap.m | 2 +- functions/helper/tissuemask_binary.m | 2 +- functions/medium/medium_pct_attenuation.m | 18 +++---- functions/medium/medium_pct_density.m | 12 ++--- functions/medium/medium_pct_soundspeed.m | 12 ++--- functions/medium/medium_setup.m | 6 +-- functions/source/grid_transducer_location.m | 2 +- functions/transducer/transducer_setup.m | 10 ++-- 27 files changed, 71 insertions(+), 126 deletions(-) delete mode 100644 configs/tmp_test_n-transducers.yaml diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 42529886..0c1e7654 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -132,7 +132,13 @@ grid: use_kWaveArray: 1 # Use the kwaveArray class for transducer modeling? axisymmetric: 0 # Use 2D axisymmetric simulation mode? -#------------ Head model / Segmentation processing +#------------ Segmentation + +segmentation: + use_qform: 0 # Force qform reorientation before charm segmentation? Set to 1 if charm reports qform/sform mismatch. + debug: 0 # Pass --debug flag to charm segmentation for verbose output? + +#------------ Head model processing headmodel: head_pad_mm: 0 # Symmetric padding applied to the cropped head grid prior to transducer + PML setup [mm] @@ -148,12 +154,6 @@ headmodel: skull_wrap_radius: 10 # Rubber-wrap radius [grid voxels]; larger = tighter rubber ignoring bigger dents (recommendation: 2–10) skull_wrap_visualize: 0 # Visualize rubber-wrap result? (Currently causes issues on HPC) -#------------ Segmentation - -segmentation: - use_qform: 0 # Force qform reorientation before charm segmentation? Set to 1 if charm reports qform/sform mismatch. - debug: 0 # Pass --debug flag to charm segmentation for verbose output? - #------------ (pseudo-)CT skull property mapping pct: @@ -174,7 +174,6 @@ layers: skull_cortical: [7] skull_trabecular: [8] - #------------ Sonication timing protocol (no defaults - must be specified for thermal simulations) timing: @@ -205,7 +204,7 @@ thermal: #------------ Medium properties # References: ITRUSST benchmarks (https://arxiv.org/abs/2202.04552); Tissue Properties DB (https://itis.swiss/virtual-population/tissue-properties/overview/); # Note: skull attenuation and absorption values remain uncertain, use defaults with caution! -medium: +medium_properties: water: sound_speed: 1500 density: 994 diff --git a/configs/tmp_test_n-transducers.yaml b/configs/tmp_test_n-transducers.yaml deleted file mode 100644 index 00b5b073..00000000 --- a/configs/tmp_test_n-transducers.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# The configuration is structured using YAML (https://yaml.org/). -# Note that YAML uses spaces, not tabs, for indentation. - -# !!! Important !!! -# This file is a user config derived from default_config.yaml + base_config_BFTUS.yaml - -# Paths to the data location and SimNIBS environment (please use full paths) -data_path: /project/2425076.01/sims/data/ -seg_path: /project/2425076.01/mri/seg/ -simnibs_bin_path: /opt/simnibs/4.0.0/simnibs_env/bin/ - -# Paths to T1 and T2 relative to data_path; all string substitutions will be done using subject_id -t1_path_template: sub-%1$03d_T1.nii.gz -t2_path_template: sub-%1$03d_T2.nii.gz -# default examples in dev: -# t1_path_template: sub-%1$03d_orig_T1w.nii.gz -# t2_path_template: sub-%1$03d_orig_T2w.nii.gz - -simulation_medium: layered - -# Parameters differ for each transducer and can be obtained from the manufacturer -transducer: - - name: right - n_elements: 10 - Elements_ID_mm: [10.0, 22.1, 29.8, 36.0, 41.4, 46.3, 50.7, 54.9, 58.7, 62.4] - Elements_OD_mm: [21.1, 28.8, 35.0, 40.4, 45.3, 49.7, 53.9, 57.8, 61.5, 65.0] - curv_radius_mm: 100 - dist_to_plane_mm: 92.7 - source_amp: 200608 - source_phase_deg: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - source_freq_hz: 300e3 - trans_pos: [186, 209, 189] - focus_pos: [131, 185, 171] - - - name: left - n_elements: 10 - Elements_ID_mm: [10.0, 22.1, 29.8, 36.0, 41.4, 46.3, 50.7, 54.9, 58.7, 62.4] - Elements_OD_mm: [21.1, 28.8, 35.0, 40.4, 45.3, 49.7, 53.9, 57.8, 61.5, 65.0] - curv_radius_mm: 100 - dist_to_plane_mm: 92.7 - source_amp: 200608 - source_phase_deg: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - source_freq_hz: 300e3 - trans_pos: [42, 213, 189] - focus_pos: [131, 185, 171] - -# Should output be ordered based on subject id? -subject_subfolder: 1 - -# Optional affix for result files names -results_filename_affix: 'tmp_test_n-transducers' - -# Grid resolution (must be isotropic) -grid.resolution_mm: 0.9 # [mm] diff --git a/documentation/doc_medium.md b/documentation/doc_medium.md index db3685c5..dcfca3ae 100644 --- a/documentation/doc_medium.md +++ b/documentation/doc_medium.md @@ -33,7 +33,7 @@ PRESTUS supports homo- and heterogeneous medium configurations. These can be spe ### Medium acoustic properties -![acoustic_properties](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/acoustic_properties.png) +![medium_properties](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/medium_properties.png) Accurate modeling of wave propagation through heterogeneous media requires valid assignment of acoustic parameters such as speed of sound, density, and attenuation to the separate tissue media. PRESTUS provides defaults based on the literature, while providing flexibility to alter assigned parameter values, e.g., to implement more conservative or liberal assumptions. diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index b9e18554..ea5bf306 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -21,7 +21,7 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| -| `medium` | Medium setup. | `'layered'` | `water` / `layered` / `phantom`. Mandatory. | +| `medium` | Simulation medium type. | `'layered'` | `water` / `layered` / `phantom`. Mandatory. | | `code_type` | k-Wave backend. | `'matlab_gpu'` | `matlab_cpu` / `matlab_gpu` / `cpp_cpu` / `cpp_gpu`. See [doc_backend.md](doc_backend.md). | | `precision` | Computational precision for acoustic and thermal simulations. | `'single'` | `single` / `double` | | `interactive` | Interactive mode with prompts and evolving plots. | `0` | `1 = yes`, `0 = no`. If `0`, see `overwrite_files`. | @@ -259,7 +259,7 @@ Maps tissue compartment names to their SimNIBS charm label indices. Compartments ---
-medium — Tissue acoustic & thermal properties +medium_properties — Tissue acoustic & thermal properties see [doc_medium.md](doc_medium.md) diff --git a/functions/calibration/compute_oneil_solution.m b/functions/calibration/compute_oneil_solution.m index 0cad5066..a78e5cd2 100644 --- a/functions/calibration/compute_oneil_solution.m +++ b/functions/calibration/compute_oneil_solution.m @@ -31,12 +31,12 @@ 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 pressure to intensities [W/cm^2] - 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; % Plot intensity along the beam axis figure('Position', [10, 10, 900, 500]); diff --git a/functions/calibration/extract_simulated_profile.m b/functions/calibration/extract_simulated_profile.m index 3e28890e..5d92e44e 100644 --- a/functions/calibration/extract_simulated_profile.m +++ b/functions/calibration/extract_simulated_profile.m @@ -100,11 +100,11 @@ end % Convert pressure to intensities [W/cm^2] - pred_axial_intensity = pred_axial_pressure .^ 2 / (2 * parameters.medium.water.sound_speed * parameters.medium.water.density) * 1e-4; + 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.water.density * parameters.medium.water.sound_speed); + (parameters.medium_properties.water.density * parameters.medium_properties.water.sound_speed); %% Collect profile and distance diff --git a/functions/calibration/fit_velocity_to_intensity.m b/functions/calibration/fit_velocity_to_intensity.m index 6507d239..d18de809 100644 --- a/functions/calibration/fit_velocity_to_intensity.m +++ b/functions/calibration/fit_velocity_to_intensity.m @@ -32,12 +32,12 @@ repmat(opt_velocity, 1, parameters.transducer.n_elements), ... [opt_phases], ... 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 to intensity [W/cm^2] - I_axial = p_axial .^ 2 / (2 * parameters.medium.water.sound_speed * parameters.medium.water.density) * 1e-4; + 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; 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 967a6fac..c3d34424 100644 --- a/functions/calibration/phase_optimization_annulus_full_curve.m +++ b/functions/calibration/phase_optimization_annulus_full_curve.m @@ -43,12 +43,12 @@ [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 diff --git a/functions/calibration/recompute_oneil_solution.m b/functions/calibration/recompute_oneil_solution.m index ad37572f..77edeebd 100644 --- a/functions/calibration/recompute_oneil_solution.m +++ b/functions/calibration/recompute_oneil_solution.m @@ -26,13 +26,13 @@ repmat(opt_velocity, 1, parameters.transducer.n_elements), ... [opt_phases], ... 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 to intensity i_axial_oneil = profile_oneil.axial_intensity; - i_axial_oneil_opt = p_axial_oneil_opt .^ 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_properties.water.sound_speed * parameters.medium_properties.water.density) * 1e-4; % Plot comparison of profiles figure('Position', [10, 10, 900, 500]); diff --git a/functions/calibration/scale_real_intensity_profile.m b/functions/calibration/scale_real_intensity_profile.m index d1834416..594a88c4 100644 --- a/functions/calibration/scale_real_intensity_profile.m +++ b/functions/calibration/scale_real_intensity_profile.m @@ -22,8 +22,8 @@ % 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 diff --git a/functions/calibration/set_real_phases.m b/functions/calibration/set_real_phases.m index a6444a16..3d159f9d 100644 --- a/functions/calibration/set_real_phases.m +++ b/functions/calibration/set_real_phases.m @@ -21,7 +21,7 @@ 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, focal_distance_ep, 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/print_parameter_summary.m b/functions/core/print_parameter_summary.m index b23609d6..6cd005d3 100644 --- a/functions/core/print_parameter_summary.m +++ b/functions/core/print_parameter_summary.m @@ -29,7 +29,7 @@ function print_parameter_summary(parameters) %% 2. Simulation Type fprintf('⚙️ SIMULATION TYPE\n'); -print_if_field(parameters, 'medium', '%s'); +print_if_field(parameters.simulation, 'medium', '%s'); print_if_field(parameters.grid, 'n_dims', '%dD'); print_flag(parameters.grid, 'axisymmetric'); print_modules(parameters.modules); @@ -87,7 +87,7 @@ function print_parameter_summary(parameters) % Handle 'tissues' wildcard → all tissue props if strcmp(tissue, 'tissues') - tissue_fields = fieldnames(parameters.simulation.medium); + tissue_fields = fieldnames(parameters.medium_properties); tissue_list = tissue_fields(startsWith(tissue_fields, 'tissue_') | ... startsWith(tissue_fields, 'brain') | ... startsWith(tissue_fields, 'csf')); diff --git a/functions/head/head_smooth_and_crop.m b/functions/head/head_smooth_and_crop.m index 0c3179e9..13b35961 100644 --- a/functions/head/head_smooth_and_crop.m +++ b/functions/head/head_smooth_and_crop.m @@ -11,7 +11,7 @@ % 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. + % Tissue masks will contain IDs according to the order of tissues in parameters.medium_properties. grid.resolution_mm = parameters.grid.resolution_mm; diff --git a/functions/head/preproc_crop_eCSF.m b/functions/head/preproc_crop_eCSF.m index 6a4943c8..924a7325 100644 --- a/functions/head/preproc_crop_eCSF.m +++ b/functions/head/preproc_crop_eCSF.m @@ -9,7 +9,7 @@ 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.simulation.medium), 'water')); + medium_masks(~csf_mask_expanded) = find(strcmp(fieldnames(parameters.medium_properties), 'water')); % [DEBUG] Visualize CSF expansion at transducer y-slice if parameters.simulation.debug == 1 h = figure; diff --git a/functions/head/preproc_crop_grid.m b/functions/head/preproc_crop_grid.m index ebdbec30..14290719 100644 --- a/functions/head/preproc_crop_grid.m +++ b/functions/head/preproc_crop_grid.m @@ -54,7 +54,7 @@ crop_margin = parameters.grid.pml_size + 1; % Include transducer bowl geometry (safe with padding) -i_water = find(strcmp(fieldnames(parameters.simulation.medium), 'water')); +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); diff --git a/functions/head/preproc_medium_mask.m b/functions/head/preproc_medium_mask.m index 6a1ca039..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.simulation.medium); + medium_labels = fieldnames(parameters.medium_properties); medium_masks = zeros(size(segmented_img)); % === SKULL CONFIGURATION DETECTION === diff --git a/functions/head/preproc_segmentation.m b/functions/head/preproc_segmentation.m index daa0b72e..2839eb30 100644 --- a/functions/head/preproc_segmentation.m +++ b/functions/head/preproc_segmentation.m @@ -27,7 +27,7 @@ % Run segmentation (if necessary) if confirm_overwriting(filename_segmented, parameters) && ... - (~isfield(parameters,'overwrite_simnibs') || parameters.io.overwrite_simnibs || ~exist(filename_segmented,'file')) + (~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."); diff --git a/functions/head/skull_fill_holes.m b/functions/head/skull_fill_holes.m index f2eb1299..689bf3e0 100644 --- a/functions/head/skull_fill_holes.m +++ b/functions/head/skull_fill_holes.m @@ -34,7 +34,7 @@ % - 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. - labels_medium = fieldnames(parameters.simulation.medium); + labels_medium = fieldnames(parameters.medium_properties); labels_requested = fieldnames(parameters.layers); if parameters.pct.enabled == 0 && any(contains(labels_requested, 'skull_cortical')) @@ -79,7 +79,7 @@ if isfield(seg_labels, 'eye') eye_i = seg_labels.eye; eye = segmented_img == eye_i; - i_water = find(strcmp(fieldnames(parameters.simulation.medium), 'water')); + i_water = find(strcmp(fieldnames(parameters.medium_properties), 'water')); medium_masks(eye ~= 0) = i_water; % Default to water end diff --git a/functions/head/skull_rubber_wrap.m b/functions/head/skull_rubber_wrap.m index c315473e..a0196f27 100644 --- a/functions/head/skull_rubber_wrap.m +++ b/functions/head/skull_rubber_wrap.m @@ -90,7 +90,7 @@ end tissues_available = fieldnames(parameters.layers); - medium_labels = fieldnames(parameters.simulation.medium); + medium_labels = fieldnames(parameters.medium_properties); if ismember(tissues_available, 'brain') BRAIN_LABEL = find(strcmp(medium_labels, 'brain')); diff --git a/functions/helper/tissuemask_binary.m b/functions/helper/tissuemask_binary.m index 39704c89..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.simulation.medium); + labels = fieldnames(parameters.medium_properties); skull_i = find(strcmp(labels, 'skull')); cortical_i = find(strcmp(labels, 'skull_cortical')); diff --git a/functions/medium/medium_pct_attenuation.m b/functions/medium/medium_pct_attenuation.m index 211a7a43..33113bec 100644 --- a/functions/medium/medium_pct_attenuation.m +++ b/functions/medium/medium_pct_attenuation.m @@ -8,12 +8,12 @@ % 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.skull.alpha_coeff ~= kPlan_alpha || ... - parameters.medium.skull.alpha_power ~= kPlan_alpha_power + 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.skull.alpha_coeff; - alpha_power(skull_idx) = parameters.medium.skull.alpha_power; + alpha_coeff(skull_idx) = parameters.medium_properties.skull.alpha_coeff; + alpha_power(skull_idx) = parameters.medium_properties.skull.alpha_power; case 'mueller' @@ -27,10 +27,10 @@ % 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.skull.alpha_power; + 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.skull.alpha_power); + alpha_coeff(skull_idx) = alpha_pseudoCT(skull_idx)./(0.5^parameters.medium_properties.skull.alpha_power); case 'aubry' @@ -40,12 +40,12 @@ 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.skull.alpha_power; + alpha_power(skull_idx) = parameters.medium_properties.skull.alpha_power; case 'none' - alpha_coeff(skull_idx) = parameters.medium.skull.alpha_coeff; - alpha_power(skull_idx) = parameters.medium.skull.alpha_power; + 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.") diff --git a/functions/medium/medium_pct_density.m b/functions/medium/medium_pct_density.m index 3adaefa4..41d45a29 100644 --- a/functions/medium/medium_pct_density.m +++ b/functions/medium/medium_pct_density.m @@ -19,7 +19,7 @@ close(gcf); % regularize minimum to density in water - density(skull_idx) = max(parameters.medium.water.density, density(skull_idx)); + density(skull_idx) = max(parameters.medium_properties.water.density, density(skull_idx)); case 'k-wave' @@ -45,7 +45,7 @@ close(gcf); % regularize minimum density to water density - density(skull_idx) = max(density(skull_idx),parameters.medium.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); @@ -65,7 +65,7 @@ % regularize maximum pHU to pHU_max pseudoCT(skull_idx) = min(pseudoCT(skull_idx),HU_max); - rho_water = parameters.medium.water.density; % density [kg/m^3] + 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 @@ -75,8 +75,8 @@ case 'aubry' - rho_water = parameters.medium.water.density; - rho_bone = parameters.medium.skull.density; + 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) + ... @@ -84,7 +84,7 @@ case 'none' - density(skull_idx) = parameters.medium.skull.density; + density(skull_idx) = parameters.medium_properties.skull.density; otherwise error("Specified CT density mapping is not supported.") diff --git a/functions/medium/medium_pct_soundspeed.m b/functions/medium/medium_pct_soundspeed.m index 5aeffc3c..250dd938 100644 --- a/functions/medium/medium_pct_soundspeed.m +++ b/functions/medium/medium_pct_soundspeed.m @@ -6,13 +6,13 @@ sound_speed(skull_idx) = 1.33.*density(skull_idx) + 167; % regularize minimum to sound speed in water - sound_speed(skull_idx) = max(parameters.medium.water.sound_speed, sound_speed(skull_idx)); + sound_speed(skull_idx) = max(parameters.medium_properties.water.sound_speed, sound_speed(skull_idx)); case 'marsac' - c_water = parameters.medium.water.sound_speed; % sound speed [m/s] + 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.water.density; % density [kg/m^3] + 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) * ... @@ -20,8 +20,8 @@ case 'aubry' - c_water = parameters.medium.water.sound_speed; - c_bone = parameters.medium.skull.sound_speed; + 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) + ... @@ -31,7 +31,7 @@ case 'none' - sound_speed(skull_idx) = parameters.medium.skull.sound_speed; + sound_speed(skull_idx) = parameters.medium_properties.skull.sound_speed; otherwise error("Specified CT sound speed mapping is not supported.") diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index c9749464..2ba3c86f 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -15,10 +15,10 @@ % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % % Set residual unmapped medium voxels to water - medium_masks(medium_masks==0) = find(strcmp(fieldnames(parameters.simulation.medium), 'water')); + medium_masks(medium_masks==0) = find(strcmp(fieldnames(parameters.medium_properties), 'water')); % Loads the medium settings from the config file - medium = parameters.simulation.medium; + medium = parameters.medium_properties; % Create empty matrices for medium properties empty_grid = NaN(parameters.grid.dims); @@ -34,7 +34,7 @@ % Get layer and medium labels layer_labels = fieldnames(parameters.layers); - medium_labels = fieldnames(parameters.simulation.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) diff --git a/functions/source/grid_transducer_location.m b/functions/source/grid_transducer_location.m index 1e1843b0..f8bfb8e2 100644 --- a/functions/source/grid_transducer_location.m +++ b/functions/source/grid_transducer_location.m @@ -67,7 +67,7 @@ focus_pos = trans_pos; focus_pos(numel(parameters.grid.dims)) = ... round(focus_pos(numel(parameters.grid.dims)) + ... - parameters.expected_focal_distance_bowl/parameters.grid.resolution_mm); + 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) diff --git a/functions/transducer/transducer_setup.m b/functions/transducer/transducer_setup.m index 23307a7e..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.resolution_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.resolution_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.resolution_mm / 2) + 1; % Outer diameter in grid points - transducer_pars.Elements_ID = 2*floor(transducer_pars.Elements_ID_mm / grid.resolution_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.resolution_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 From 22d87b27371207f8311250247e9bf4add0710fd1 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 25 Mar 2026 16:58:20 +0100 Subject: [PATCH 137/144] [test] add initial tests --- documentation/doc_testing.md | 89 +++++++ mkdocs.yaml | 1 + tests/README.md | 87 +++++++ tests/fixtures/make_minimal_parameters.m | 78 ++++++ tests/fixtures/make_synthetic_segmentation.m | 41 ++++ tests/run_all_tests.m | 85 +++++++ tests/test_head_preprocessing.m | 134 ++++++++++ tests/test_helper.m | 246 +++++++++++++++++++ tests/test_integration_headpreproc.m | 105 ++++++++ tests/test_integration_pipeline.m | 193 +++++++++++++++ tests/test_integration_water.m | 74 ++++++ tests/test_load_parameters.m | 112 +++++++++ tests/test_thermal_parameters.m | 80 ++++++ tests/test_transform.m | 53 ++++ 14 files changed, 1378 insertions(+) create mode 100644 documentation/doc_testing.md create mode 100644 tests/README.md create mode 100644 tests/fixtures/make_minimal_parameters.m create mode 100644 tests/fixtures/make_synthetic_segmentation.m create mode 100644 tests/run_all_tests.m create mode 100644 tests/test_head_preprocessing.m create mode 100644 tests/test_helper.m create mode 100644 tests/test_integration_headpreproc.m create mode 100644 tests/test_integration_pipeline.m create mode 100644 tests/test_integration_water.m create mode 100644 tests/test_load_parameters.m create mode 100644 tests/test_thermal_parameters.m create mode 100644 tests/test_transform.m 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/mkdocs.yaml b/mkdocs.yaml index 986c5631..f77e1594 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -63,6 +63,7 @@ nav: - 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 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..04937092 --- /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_isppa_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..126a661c --- /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_isppa_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)); + isppa_col = tbl{:, contains(tbl.Properties.VariableNames, 'Isppa', 'IgnoreCase', true)}; + tc.verifyGreaterThan(isppa_col(1), 0, 'Isppa 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 From 25626513d6338243e0cfa8abf5684a4b79f664dd Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 25 Mar 2026 17:23:09 +0100 Subject: [PATCH 138/144] [reporting] add MI transcranial --- functions/acoustic/acoustic_analysis.m | 12 ++++++++++-- functions/core/generate_simulation_report.m | 3 ++- functions/helper/charm_seg_labels.m | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/functions/acoustic/acoustic_analysis.m b/functions/acoustic/acoustic_analysis.m index b441c6b5..ed65d6ca 100644 --- a/functions/acoustic/acoustic_analysis.m +++ b/functions/acoustic/acoustic_analysis.m @@ -118,16 +118,24 @@ [results.max_pressure_skin] = masked_max_3d(acoustic_pressure, mask.skin); [results.max_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.max_MI_tc] = masked_max_3d(acoustic_MI, intracranial_mask); + else + results.max_MI_tc = NaN; % intracranial tissues not modelled + end + 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.max_MI_brain, results.max_MI_tc, 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', ... + 'max_MI_skin', 'max_MI_skull', 'max_MI_brain', 'max_MI_tc', ... 'Ix_brain_vox', 'Iy_brain_vox', 'Iz_brain_vox', ... 'trans_pos_vox', 'focus_pos_vox', ... 'isppa_at_target', 'avg_isppa_around_target', ... diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index 824d1ddc..24894700 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -217,6 +217,7 @@ limits = struct(); % MI limits (ITRUSST: MI <= 1.9) + limits.max_MI_tc = struct('label', 'MI (transcranial)', 'limit', 1.9, 'unit', ''); 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', ''); @@ -1031,7 +1032,7 @@ % ======================================================================== function cols = get_acoustic_columns() - cols = {'max_MI_brain', 'max_MI_skull', 'max_MI_skin', ... + cols = {'max_MI_tc', 'max_MI_brain', 'max_MI_skull', 'max_MI_skin', ... 'max_Isppa_brain', 'max_Isppa_skull', 'max_Isppa_skin'}; end diff --git a/functions/helper/charm_seg_labels.m b/functions/helper/charm_seg_labels.m index 997318ab..97bfa1b2 100644 --- a/functions/helper/charm_seg_labels.m +++ b/functions/helper/charm_seg_labels.m @@ -9,6 +9,7 @@ % % 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] @@ -22,6 +23,7 @@ % 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; From f90bc2f99c182bcf7fc74f223080fd845ff7184f Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 25 Mar 2026 18:47:24 +0100 Subject: [PATCH 139/144] [output] standardize intensity and pressure names --- documentation/doc_outputs.md | 264 +++++++++++------- functions/acoustic/acoustic_analysis.m | 115 ++++---- .../calibration/fit_velocity_to_intensity.m | 4 +- functions/core/generate_simulation_report.m | 74 ++--- functions/core/simulation_nifti.m | 28 +- functions/group/create_group_MNI_plots.m | 90 +++--- functions/plot/plot_overlay.m | 22 +- functions/plot/plot_overlay_2d.m | 26 +- functions/prestus_pipeline.m | 6 +- tests/test_integration_pipeline.m | 2 +- tests/test_integration_water.m | 6 +- 11 files changed, 364 insertions(+), 273 deletions(-) diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index 8b6da0ea..0a51194d 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -1,95 +1,169 @@ -# Simulation outputs - -PRESTUS provides multiple outputs. Some of these outputs are optional (or can be deactivated upon request to save space.) - -#### Summary table - -Filename: `sub-XXX__output_table.csv` - -This output table provides an overview of key metrics. - -**Acoustic simulation** - -| Parameter | Description | -|-------------------------------|---------------------------------------------------| -| `subject_id` | Unique identifier for the subject | -| `max_Isppa` | Maximum intensity [W/cm²] | -| `max_Isppa_after_exit_plane` | Maximum intensity after the exit plane [W/cm²] | -| `real_focal_distance` | Empirical focal distance (distance between transducer and max. intensity in brain medium (if modelled) or in any medium beyond the exit plane) [mm] | -| `max_Isppa_skin` | Maximum intensity (skin medium) [W/cm²] | -| `max_Isppa_skull` | Maximum intensity (skull medium) [W/cm²] | -| `max_Isppa_brain` | Maximum intensity (brain medium) [W/cm²] | -| `max_pressure_skin` | Maximum pressure (skin medium) [Pa] | -| `max_pressure_skull` | Maximum pressure (skull medium) [Pa] | -| `max_pressure_brain` | Maximum pressure (brain medium) [Pa] | -| `max_MI_skin` | Maximum Mechanical Index (skin medium) | -| `max_MI_skull` | Maximum Mechanical Index (skull medium) | -| `max_MI_brain` | Maximum Mechanical Index (brain medium) | -| `Ix_brain` | X-coordinate of maximum intensity (brain medium) | -| `Iy_brain` | Y-coordinate of maximum intensity (brain medium) | -| `Iz_brain` | Z-coordinate of maximum intensity (brain medium) | -| `trans_pos_final_1` | X-coordinate of final transducer position | -| `trans_pos_final_2` | Y-coordinate of final transducer position | -| `trans_pos_final_3` | Z-coordinate of final transducer position | -| `focus_pos_final_1` | X-coordinate of final focus position | -| `focus_pos_final_2` | Y-coordinate of final focus position | -| `focus_pos_final_3` | Z-coordinate of final focus position | -| `isppa_at_target` | Intensity value at the target location [W/cm²] | -| `avg_isppa_around_target` | Average intensity around the target [W/cm²] | -| `half_max_ISPPA_volume_brain` | Volume of brain with half maximum intensity | - -**Thermal simulation** - -| Parameter | Description | -|--------------------|------------------------------------------------------| -| `maxT` | Maximum temperature (global medium) [°C] | -| `maxCEM43` | Maximum CEM43 thermal dose (global medium) | -| `maxT_brain` | Maximum temperature (brain medium) [°C] | -| `maxT_skull` | Maximum temperature (skull medium) [°C] | -| `maxT_skin` | Maximum temperature (skin medium) [°C] | -| `riseT_brain` | Temperature rise (brain medium) [°C] | -| `riseT_skull` | Temperature rise (skull medium) [°C] | -| `riseT_skin` | Temperature rise (skin medium) [°C] | -| `CEM43_brain` | CEM43 Thermal dose (brain medium) [mins] | -| `CEM43_skull` | CEM43 Thermal dose (skull medium) [mins] | -| `CEM43_skin` | CEM43 Thermal dose (skin medium) [mins] | - -#### 2D/3D NIFTI images - -PRESTUS outputs 2D or 3D images (depending on the input) of the following metrics, which can be used to calculat additional metrics post-hoc. -Images are provided in subject-space (```_orig_coord_```) and in MNI-152 space (```_MNI```). - -- medium_masks -- isppa [acoustic] -- MI [acoustic] -- pressure [acoustic] -- heating [thermal] -- heatrise [thermal] -- CEM43 [thermal] - -#### MATLAB structures - -PRESTUS saves an overview of the parameters used to run the simulation and by default saves structures from acoustic and heating simulations. If these are detected in the results folder (and overwriting is deactivated via `overwrite_files = never`), they will be loaded instead of performing the calculation. Matrix saving increases HDD space demands and can be globally deactivated via `savemat = 0`. Saving is automatically active as a part of free-water transducer calibration. - -| Output | Description | -|-------------------------------------------------------|---------------------------------------| -| `sub-XXX__parameters.mat` | Simulation parameters [mandatory] | -| `sub-XXX_after_rotating_and_scaling.mat` | Head after grid scaling | -| `sub-XXX_after_cropping_and_smoothing.mat` | Cropped head incl. medium masks | -| `sub-XXX__kwave_source.mat` | k-Wave source | -| `sub-XXX__results.mat` | Acoustic simulation outputs | -| `sub-XXX__heating_res.mat` | Thermal simulation outputs | - -#### Figures - -PRESTUS provides multiple figures for quick visual inspection and debugging. For example, an overview of estimated thermal effects: - -![PRESTUS_fig_example_thermal](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/thermal_fig_examples.png) - -#### HTML - -PRESTUS provides a summary HTML that provides safety-relevant parameters in a dashboard, summarizes parameters and medium-specific acoustic properties, prints the full log, and summarizes figures. - -![PRESTUS_html](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/html_report.png) - -A full example report on a phantom can be found here: [🔗 Download Example HTML](https://raw.githubusercontent.com/jkosciessa/PRESTUS_bin/main/examples/example_report.html) +# Simulation outputs + +PRESTUS provides multiple outputs. Some of these outputs are optional (or can be deactivated upon request to save space.) + +--- + +## Summary table + +Filename: `sub-XXX__output_table.csv` + +All quantitative metrics are written to a single CSV file. Acoustic metrics are written first; thermal metrics are appended if heating simulations were run. Tissue-specific columns are only present for layered simulations. + +### Intensity nomenclature + +All intensity values are **pulse-average intensity (IPA)**, computed from the steady-state peak pressure amplitude as: + +``` +I = p² / (2ρc) [W/cm²] +``` + +where `p` is the peak pressure amplitude, `ρ` is density, and `c` is sound speed. The cycle-averaging factor of 2 reflects that `p` is the amplitude of a sinusoid — this yields the time-average intensity *during* the pulse, i.e. IPA, not the temporal peak (ITP, which would lack the factor of 2) and not the temporal average over the full repetition interval (ISPTA, which would additionally apply the duty cycle PD/PRI). + +The **spatial peak** of IPA is ISPPA. When IPA is reported at a specific location (target point, radius average) rather than at the spatial maximum, it is labelled IPA. + +The **mechanical index** at each voxel is defined as MI = p⁻ / √f, where p⁻ is the peak negative pressure in MPa and f is the centre frequency in MHz. Note: PRESTUS uses `p_max_all` (peak positive pressure) as a proxy for p⁻; a direct negative-pressure map would require additional sensor configuration. + +--- + +### Acoustic metrics + +#### Global + +| Column | Unit | Description | +|---|---|---| +| `subject_id` | — | Subject identifier | +| `freq_Hz` | Hz | Transducer centre frequency | +| `Isppa` | W/cm² | ISPPA — maximum pulse-average intensity across the entire simulation grid | +| `Isppa_after_exitplane` | W/cm² | Maximum IPA beyond the transducer exit plane (or within brain if modelled) | +| `real_focal_distance_mm` | mm | Distance from the transducer face to the location of maximum IPA | +| `Ipa_target` | W/cm² | IPA at the specified target coordinate | +| `Ipa_target_radius` | W/cm² | Mean IPA within a sphere of radius `analysis.focus_area_radius` mm centred on the target | +| `trans_pos_vox` | voxels | Transducer position in simulation grid coordinates | +| `focus_pos_vox` | voxels | Target focus position in simulation grid coordinates | + +Water/free-field simulations additionally include `Psptp` (spatial peak temporal peak pressure) and `Ptp_target` (temporal peak pressure at the target). + +#### Tissue-specific *(layered simulations only)* + +| Column | Unit | Description | +|---|---|---| +| `Isppa_brain` | W/cm² | Maximum IPA within brain tissue (GM + WM) | +| `Isppa_skull` | W/cm² | Maximum IPA within skull bone | +| `Isppa_skin` | W/cm² | Maximum IPA within skin | +| `Psptp_brain` | Pa | Spatial peak temporal peak pressure within brain tissue | +| `Psptp_skull` | Pa | Spatial peak temporal peak pressure within skull bone | +| `Psptp_skin` | Pa | Spatial peak temporal peak pressure within skin | +| `Ptp_target` | Pa | Temporal peak pressure at the target coordinate | +| `MI_brain` | — | Maximum mechanical index within brain tissue (GM + WM) | +| `MI_skull` | — | Maximum mechanical index within skull bone | +| `MI_skin` | — | Maximum mechanical index within skin | +| `MI_tc` | — | **Transcranial MI** — maximum mechanical index across all intracranial voxels (WM, GM, CSF, blood; skull bone excluded). Primary safety metric for cavitation risk at the sonication target. ITRUSST limit: 1.9 | +| `Ix_brain_vox`, `Iy_brain_vox`, `Iz_brain_vox` | voxels | Grid coordinates of peak IPA within brain | +| `halfmax_ISPPA_volume_brain_mm3` | mm³ | Volume of the −6 dB focal region within brain (voxels where IPA ≥ 50 % of `Isppa_brain`) | + +--- + +### Thermal metrics *(only written when heating simulations are run)* + +Temperature is simulated over the full sonication protocol (on/off cycles) using the Pennes bioheat equation. All values are spatial maxima within the respective tissue. + +#### Global + +| Column | Unit | Description | +|---|---|---| +| `maxT` | °C | Global maximum temperature reached at any point during sonication | +| `endT` | °C | Global maximum temperature at the end of the last sonication pulse | +| `maxCEM43` | min | Global maximum CEM43 over the full protocol | +| `maxCEM43end` | min | Global maximum CEM43 at end of last pulse | + +#### Tissue-specific + +| Column | Unit | Description | +|---|---|---| +| `maxT_brain/skull/skin` | °C | Maximum temperature in each tissue during sonication. ITRUSST limit: 39 °C | +| `endT_brain/skull/skin` | °C | Maximum temperature in each tissue at end of last pulse | +| `riseT_brain/skull/skin` | °C | Maximum temperature rise above baseline (maxT − baseline). ITRUSST limit: 2 °C | +| `rise_endT_brain/skull/skin` | °C | Temperature rise above baseline at end of last pulse | +| `CEM43_brain/skull/skin` | min | Maximum CEM43 in each tissue. ITRUSST limits: brain 2 min, skull 16 min, skin 21 min | +| `CEM43_end_brain/skull/skin` | min | CEM43 in each tissue at end of last pulse | + +--- + +### Non-significant risk limits + +The HTML report flags each metric against the ITRUSST consensus safety limits for transcranial ultrasound (Aubry et al., 2025, Brain Stimulation): + +| Metric | Limit | +|---|---| +| MI transcranial | 1.9 | +| Temperature rise (a) | 2 °C | +| Maximum temperature (b) | 39 °C | +| CEM43 (brain) (c) | 2 min | +| CEM43 (skull) (c) | 16 min | +| CEM43 (skin) (c) | 21 min | + +For thermal estimates, non-significant risk can be characterized by a, b, or c. NSR limits above and in the HTML are provided for quick reference. Always check the original reference for details and check for possible consensus updates. PRESTUS does not make independent recommendations. + +--- + +## NIfTI images + +PRESTUS exports 3D volumes (or 2D for axisymmetric simulations) in NIfTI format, provided in subject-native space (`_orig_coord`) and MNI-152 space (`_MNI`). + +Filename pattern: `sub-XXX_final__MNI.nii.gz` + +| Type | Description | +|---|---| +| `medium_masks` | Tissue label mask (integer indices) | +| `intensity` | Pulse-average intensity (IPA) [W/cm²] | +| `MI` | Mechanical index | +| `pressure` | Peak pressure [Pa] | +| `heating` | Maximum temperature during sonication [°C] | +| `heating_end` | Temperature at end of last pulse [°C] | +| `heatrise` | Temperature rise above baseline [°C] | +| `heatrise_end` | Temperature rise above baseline at end of last pulse [°C] | +| `CEM43` | CEM43 thermal dose [min] | +| `CEM43_end` | CEM43 at end of last pulse [min] | + +--- + +## MATLAB structures + +PRESTUS saves simulation parameters and, by default, intermediate matrices. If matrices are detected in the output folder and `io.overwrite_files` is set to `never`, they are loaded instead of recomputing. Matrix saving can be disabled globally via `io.save_matrices = 0`. + +| File | Description | +|---|---| +| `sub-XXX__parameters.mat` | Full parameters struct used for the simulation | +| `sub-XXX_after_rotating_and_scaling.mat` | Head volume after grid scaling | +| `sub-XXX_after_cropping_and_smoothing.mat` | Cropped head including medium masks | +| `sub-XXX__kwave_source.mat` | k-Wave source definition | +| `sub-XXX__results.mat` | Acoustic simulation sensor data | +| `sub-XXX__heating_res.mat` | Thermal simulation results | + +--- + +## Figures + +PRESTUS saves PNG overlays of intensity and temperature on the segmentation for quick visual inspection. + +![PRESTUS_fig_example_thermal](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/thermal_fig_examples.png) + +--- + +## HTML report + +A self-contained HTML report is written to the output directory (`sub-XXX__report.html`). It includes: + +- **Safety dashboard** — color-coded cards for all ITRUSST-limited metrics (green / amber / red) +- **Simulation summary** — key acoustic and thermal values at a glance +- **Configuration summary** — parameters used for the run +- **Medium properties** — per-tissue acoustic and thermal properties (layered only) +- **Acoustic results** — intensity overlay images and the full acoustic metrics table +- **Thermal results** — temperature and CEM43 plots (if heating simulations were run) +- **Post-hoc water simulation** — free-field reference results (if run) +- **Log** — full pipeline execution log + +![PRESTUS_html](https://github.com/jkosciessa/PRESTUS_bin/raw/main/img/html_report.png) + +A full example report on a phantom can be found here: [🔗 Download Example HTML](https://raw.githubusercontent.com/jkosciessa/PRESTUS_bin/main/examples/example_report.html) diff --git a/functions/acoustic/acoustic_analysis.m b/functions/acoustic/acoustic_analysis.m index ed65d6ca..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)); @@ -56,14 +56,15 @@ % convert the radius from mm to voxels 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,11 +73,11 @@ % 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 numel(parameters.grid.dims)==3 @@ -93,66 +94,74 @@ 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; + [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.max_pressure_brain] = masked_max_3d(acoustic_pressure, mask.brain); - [results.max_MI_brain] = masked_max_3d(acoustic_MI, mask.brain); - + [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); + [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.max_MI_tc] = masked_max_3d(acoustic_MI, intracranial_mask); + [results.MI_tc] = masked_max_3d(acoustic_MI, intracranial_mask); else - results.max_MI_tc = NaN; % intracranial tissues not modelled + results.MI_tc = NaN; % intracranial tissues not modelled end - 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, results.max_MI_tc, 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', 'max_MI_tc', ... + 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.io.filename_output_table); - else + 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'}), ... + 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 @@ -165,7 +174,7 @@ if numel(parameters.grid.dims)==3 [~,~,~,~,~,~,~,h]=plot_overlay(... - acoustic_isppa, ... + acoustic_Ipa, ... segmentation, ... source_labels, ... parameters, ... @@ -175,7 +184,7 @@ highlighted_pos); else h = plot_overlay_2d(... - acoustic_isppa, ... + acoustic_Ipa, ... segmentation, ... source_labels, ... after_exit_plane_mask, ... @@ -184,9 +193,11 @@ highlighted_pos); end + trans_suffix = ''; + if n_plots > 1; trans_suffix = sprintf('_T%02d', ti); end output_plot = fullfile(parameters.io.output_dir, ... - sprintf('sub-%03d_%s_isppa_T%02d%s.png', ... - parameters.subject_id, parameters.simulation.medium, ti, parameters.io.output_affix)); + 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/calibration/fit_velocity_to_intensity.m b/functions/calibration/fit_velocity_to_intensity.m index d18de809..3793c388 100644 --- a/functions/calibration/fit_velocity_to_intensity.m +++ b/functions/calibration/fit_velocity_to_intensity.m @@ -8,7 +8,7 @@ % % Because opt_source_amp divides by simulated_analytical_scaling, the % analytical target must be desired_intensity * scaling so that the final -% simulation's max_Isppa equals desired_intensity. +% simulation's Isppa equals desired_intensity. % % Arguments: % parameters - Structure with transducer and medium parameters. @@ -79,6 +79,6 @@ 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 max_Isppa in simulation: %.2f W/cm^2\n', desired_intensity); + fprintf('Expected Isppa in simulation: %.2f W/cm^2\n', desired_intensity); end diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index 24894700..7fb792c0 100644 --- a/functions/core/generate_simulation_report.m +++ b/functions/core/generate_simulation_report.m @@ -217,10 +217,10 @@ limits = struct(); % MI limits (ITRUSST: MI <= 1.9) - limits.max_MI_tc = struct('label', 'MI (transcranial)', 'limit', 1.9, 'unit', ''); - 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']); @@ -238,17 +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.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) @@ -301,7 +301,7 @@ end % Special handling for pressure: use dynamic unit scaling - if strcmp(name, 'max_pressure_Pa') && ~isnan(value) + if strcmp(name, 'Psptp') && ~isnan(value) [value, display_unit] = scale_pressure(value); end @@ -376,17 +376,17 @@ 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 - [p_val, p_unit] = scale_pressure(csv_value(csv_table, 'max_pressure_Pa')); - html = [html summary_card('Max pressure', p_val, p_unit)]; + [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 @@ -621,13 +621,13 @@ if ~isempty(avail_cols) sub_table = csv_table(:, avail_cols); % Scale pressure values dynamically (Pa -> kPa/MPa based on magnitude) - if ismember('max_pressure_Pa', sub_table.Properties.VariableNames) - pressure_vals = sub_table{:, 'max_pressure_Pa'}; + 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{:, 'max_pressure_Pa'} = scaled_vals; + sub_table{:, 'Psptp'} = scaled_vals; % Rename column header to reflect actual unit - sub_table.Properties.VariableNames{'max_pressure_Pa'} = ['max_pressure_' display_unit]; + sub_table.Properties.VariableNames{'Psptp'} = ['Psptp_' display_unit]; end end html = [html table2html(sub_table, struct(), {})]; @@ -639,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); @@ -647,20 +647,23 @@ html = [html '
']; for t = 1:n_trans - % ISPPA on segmentation + 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_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)); + 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 + % Intensity on T1 img_path = fullfile(parameters.io.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)); + 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 @@ -1032,8 +1035,9 @@ % ======================================================================== function cols = get_acoustic_columns() - cols = {'max_MI_tc', '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() @@ -1047,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() diff --git a/functions/core/simulation_nifti.m b/functions/core/simulation_nifti.m index 429ad647..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) @@ -22,7 +22,7 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, data_types = "medium_masks"; if parameters.state.acoustics_available == 1 - data_types = [data_types, "isppa","MI","pressure"]; + data_types = [data_types, "intensity","MI","pressure"]; end if parameters.state.heating_available == 1 data_types = [data_types, "heating", "heating_end", "heatrise", "heatrise_end", "CEM43", "CEM43_end"]; @@ -35,8 +35,8 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, 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") @@ -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,9 +157,11 @@ function simulation_nifti(parameters, planimg, results_acoustic, acoustic_isppa, 'overlay_threshold_high', max_val, ... 'rotation', 0); % rotation = 90 not implemented for transducer overlay + 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_isppa_t1_T%02d%s.png', ... - parameters.subject_id, parameters.simulation.medium, ti, ... + 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); diff --git a/functions/group/create_group_MNI_plots.m b/functions/group/create_group_MNI_plots.m index e3c4b9a9..a26522a8 100644 --- a/functions/group/create_group_MNI_plots.m +++ b/functions/group/create_group_MNI_plots.m @@ -35,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 @@ -60,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 @@ -74,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; @@ -109,14 +109,14 @@ function create_group_MNI_plots(subject_list, parameters, options) end % -- Construct filename patterns (with possible wildcards) -- - pattern_isppa = fullfile(data_dir, sprintf('%s_final_isppa_MNI%s.nii.gz', file_base, parameters.io.output_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); @@ -126,7 +126,7 @@ function create_group_MNI_plots(subject_list, parameters, options) 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 @@ -143,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); @@ -154,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); @@ -166,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'); @@ -174,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'); @@ -196,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') @@ -248,21 +248,21 @@ function create_group_MNI_plots(subject_list, parameters, options) end % -- Patterns for batch processing, as before -- - pattern_isppa = fullfile(data_dir, sprintf('%s_final_isppa_MNI%s.nii.gz', file_base, parameters.io.output_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_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_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_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; @@ -271,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); @@ -298,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); @@ -320,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') @@ -344,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 @@ -366,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; @@ -386,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(), ... @@ -397,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, ... @@ -418,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.io.output_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,9 +441,9 @@ 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.io.output_affix, options.outputs_suffix),... - sprintf('final_isppa_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.io.output_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/plot/plot_overlay.m b/functions/plot/plot_overlay.m index 61c0709b..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). % @@ -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 @@ -202,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); 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/prestus_pipeline.m b/functions/prestus_pipeline.m index fdddd856..1841cda2 100644 --- a/functions/prestus_pipeline.m +++ b/functions/prestus_pipeline.m @@ -219,12 +219,12 @@ log_timer('start','acoustic_analysis', parameters.io.output_dir); if (~isfield(parameters.modules, 'run_acoustic_analysis') || parameters.modules.run_acoustic_analysis) - [results_acoustic, acoustic_isppa, acoustic_MI, acoustic_pressure, highlighted_pos] = ... + [results_acoustic, acoustic_Ipa, acoustic_MI, acoustic_pressure, highlighted_pos] = ... acoustic_analysis(parameters, kwave_medium, medium_masks, sensor_data, segmentation, source_labels); else disp('No acoustic simulation results available (or requested). Skipping analysis...') results_acoustic = []; - acoustic_isppa = []; + acoustic_Ipa = []; acoustic_MI = []; acoustic_pressure = []; highlighted_pos = []; @@ -342,7 +342,7 @@ if ~isfield(parameters.modules, 'run_nifti_creation') || parameters.modules.run_nifti_creation==1 simulation_nifti(parameters, planimg, results_acoustic, ... - acoustic_isppa, acoustic_MI, acoustic_pressure, ... + acoustic_Ipa, acoustic_MI, acoustic_pressure, ... medium_masks, results_heating, kwave_medium, highlighted_pos) else disp('No nifti creation requested...') diff --git a/tests/test_integration_pipeline.m b/tests/test_integration_pipeline.m index 04937092..322bbc48 100644 --- a/tests/test_integration_pipeline.m +++ b/tests/test_integration_pipeline.m @@ -141,7 +141,7 @@ function test_acoustic_output_file_created(tc) expected_file = fullfile(p.path.sim, ... sprintf('sub-%03d', tc.DEMO_SUBJECT_ID), ... - sprintf('sub-%03d_%s%s_isppa_orig_coord.nii.gz', ... + 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.'); diff --git a/tests/test_integration_water.m b/tests/test_integration_water.m index 126a661c..a245847e 100644 --- a/tests/test_integration_water.m +++ b/tests/test_integration_water.m @@ -50,14 +50,14 @@ function test_nifti_output_created(tc) tc.verifyNotEmpty(nii_files, 'Expected at least one NIfTI output file'); end - function test_isppa_positive(tc) + 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)); - isppa_col = tbl{:, contains(tbl.Properties.VariableNames, 'Isppa', 'IgnoreCase', true)}; - tc.verifyGreaterThan(isppa_col(1), 0, 'Isppa should be positive'); + 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) From 4cb295a257214b204955e23e87ab2261dc9e03e0 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 25 Mar 2026 18:53:53 +0100 Subject: [PATCH 140/144] [doc] fix formatting --- documentation/doc_parameters.md | 119 +++++++++----------------------- 1 file changed, 31 insertions(+), 88 deletions(-) diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index ea5bf306..037ef9cb 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -4,20 +4,16 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST --- -
-General +### General | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| | `subject_id` | Subject identifier used for file naming and folder management. | — | Mandatory. Set before calling `prestus_pipeline_start(parameters)`. | | `platform` | Execution platform. | `'auto'` | `auto` / `slurm` / `qsub` / `matlab` | -
- --- -
-simulation — Simulation type & execution +### `simulation` — Simulation type & execution | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -27,12 +23,9 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST | `interactive` | Interactive mode with prompts and evolving plots. | `0` | `1 = yes`, `0 = no`. If `0`, see `overwrite_files`. | | `debug` | Verbose debug mode with additional intermediate outputs. | `1` | `1 = yes`, `0 = no` | -
- --- -
-path — Data paths +### `path` — Data paths | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -44,12 +37,9 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST | `t2_pattern` | T2 image path template relative to `path.anat`. | `'sub-%1$03d_T2w.nii*'` | Optional | | `subject_subfolder` | Store outputs in subject-specific subdirectories? | `1` | `1 = yes`, `0 = no` | -
- --- -
-startup — Environment & toolbox paths +### `startup` — Environment & toolbox paths | **Parameter** | **Description** | **Comments** | |---|---|---| @@ -57,12 +47,9 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST | `paths_to_add` | Paths to add with `addpath()`. | [cell] e.g. `{"path/to/x"}` | | `subpaths_to_add` | Paths to add recursively with `addpath(genpath())`. | [cell]; relative to config file location | -
- --- -
-io — I/O management +### `io` — I/O management | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -71,7 +58,7 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST | `overwrite_simnibs` | Overwrite SimNIBS segmentation results? | `0` | `1 = yes`, `0 = no` | | `save_matrices` | Save acoustic/thermal simulation outputs as `.mat`? | `0` | `1 = yes`, `0 = no`. Set to `0` for large batch runs to save disk space. | | `save_heatingvideo` | Save a video of incremental heating? | `0` | `1 = yes`, `0 = no` | -| `adopted_heatmap` | Path to an existing ISPPA heatmap NIfTI to reuse instead of re-running acoustics. | — | Optional. Used for sequential multi-target runs. | +| `adopted_heatmap` | Path to an existing intensity heatmap NIfTI to reuse instead of re-running acoustics. | — | Optional. Used for sequential multi-target runs. | | `adopted_cem43` | Path to an existing CEM43 heatmap NIfTI to accumulate heating across runs. | — | Optional. Used for sequential multi-target runs. | **Runtime-derived (set by `path_log_setup`):** @@ -83,12 +70,9 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST | `filename_output_table` | Path to the per-subject CSV results table. | | `kwave_source_filename` | Path to cached k-Wave source `.mat` file. Set by `source_sensor_setup`. | -
- --- -
-modules — Pipeline module flags +### `modules` — Pipeline module flags | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -106,14 +90,11 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST All flags: `1 = yes`, `0 = no`. -
- --- -
-transducer — Transducer specification +### `transducer` — Transducer specification -see [doc_transducer.md](doc_transducer.md) +See [doc_transducer.md](doc_transducer.md). All fields are mandatory and have no defaults — they must be set in the study config. @@ -133,14 +114,11 @@ All fields are mandatory and have no defaults — they must be set in the study | `expected_focal_distance_ep` | Expected distance from transducer exit plane to focus [mm]. | Alternative to specifying `trans_pos`/`focus_pos`. Either `expected_focal_distance_ep`, `expected_focal_distance_bowl`, or both pos fields must be set. | | `expected_focal_distance_bowl` | Expected distance from transducer bowl to focus [mm]. | | -
- --- -
-placement — Transducer placement +### `placement` — Transducer placement -see [doc_transducer.md](doc_transducer.md) +See [doc_transducer.md](doc_transducer.md). #### `placement.localite` @@ -165,12 +143,9 @@ see [doc_transducer.md](doc_transducer.md) | `criterion_skull_var` | Variance of EP voxel distance from skull [quantile]. | `[]` | | | `expand_step` | Expansion step for skin intersection criterion. | `0.01` | 1% | -
- --- -
-grid — Simulation grid +### `grid` — Simulation grid | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -184,14 +159,11 @@ see [doc_transducer.md](doc_transducer.md) | `max_expand` | Maximum grid expansion for prime-number FFT optimisation [voxels]. | `40` | | | `use_kWaveArray` | Use the kWaveArray class for transducer modelling? | `1` | `1 = yes`, `0 = no` | -
- --- -
-headmodel — Head model & segmentation preprocessing +### `headmodel` — Head model & segmentation preprocessing -see [doc_preproc.md](doc_preproc.md) +See [doc_preproc.md](doc_preproc.md). | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -206,26 +178,20 @@ see [doc_preproc.md](doc_preproc.md) | `skull_wrap_radius` | Rubber-wrap radius [grid voxels]. | `10` | Larger = tighter wrap ignoring bigger dents. Recommended: 2–10. | | `skull_wrap_visualize` | Visualize rubber-wrap result? | `0` | `1 = yes`, `0 = no`. Avoid on HPC. | -
- --- -
-segmentation — Segmentation settings +### `segmentation` — Segmentation settings | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| | `use_qform` | Force qform reorientation before charm segmentation? | `0` | Set to `1` if charm reports a qform/sform mismatch error. | | `debug` | Pass `--debug` to charm for verbose segmentation output? | `0` | `1 = yes`, `0 = no` | -
- --- -
-pct — pseudo-CT skull property mapping +### `pct` — Pseudo-CT skull property mapping -see [doc_pseudoCT.md](doc_pseudoCT.md) +See [doc_pseudoCT.md](doc_pseudoCT.md). | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -234,14 +200,11 @@ see [doc_pseudoCT.md](doc_pseudoCT.md) | `mapping_soundspeed` | HU-to-sound-speed mapping algorithm. | `'k-plan'` | `k-plan` / `marsac` / `aubry` / `none`. See [doc](doc_pseudoCT.md#mapping-skull-sound-speed). | | `mapping_attenuation` | HU-to-attenuation mapping algorithm. | `'k-plan'` | `k-plan` / `mueller` / `aubry` / `none`. See [doc](doc_pseudoCT.md#mapping-skull-attenuation). | -
- --- -
-layers — Simulation tissue compartments +### `layers` — Simulation tissue compartments -see [doc_preproc.md](doc_preproc.md) +See [doc_preproc.md](doc_preproc.md). Maps tissue compartment names to their SimNIBS charm label indices. Compartments are modelled in listed order; any voxel label not assigned to a named compartment is treated as water. Charm label values are hardcoded in `charm_seg_labels()`. Layers can be removed or added. @@ -254,14 +217,11 @@ Maps tissue compartment names to their SimNIBS charm label indices. Compartments | `skull_cortical` | `[7]` | Cortical bone; used in multi-layer skull model | | `skull_trabecular` | `[8]` | Trabecular bone; used in multi-layer skull model | -
- --- -
-medium_properties — Tissue acoustic & thermal properties +### `medium_properties` — Tissue acoustic & thermal properties -see [doc_medium.md](doc_medium.md) +See [doc_medium.md](doc_medium.md). Each tissue compartment (`water`, `brain`, `skin`, `skull`, `skull_trabecular`, `skull_cortical`) carries the following fields: @@ -276,14 +236,11 @@ Each tissue compartment (`water`, `brain`, `skin`, `skull`, `skull_trabecular`, | `perfusion` | Perfusion / heat transfer rate. | mL/min/kg — Tissue Properties DB | | `absorption_fraction` | Fraction of attenuation converted to heat. | [0–1] — Pinton et al., 2012 | -
- --- -
-timing — Sonication timing protocol +### `timing` — Sonication timing protocol -see [doc_simulations-thermal.md](doc_simulations-thermal.md) +See [doc_simulations-thermal.md](doc_simulations-thermal.md). Protocol duration fields must be set for thermal simulations. @@ -299,14 +256,11 @@ Protocol duration fields must be set for thermal simulations. | `post_pt_timestep` | Modelling time step following PT & PTRI [s]. | `1` | | | `equal_step_duration` | Equal step durations for on and off cycles? | `0` | `1 = yes`, `0 = no` | -
- --- -
-thermal — Thermal simulation settings +### `thermal` — Thermal simulation settings -see [doc_simulations-thermal.md](doc_simulations-thermal.md) +See [doc_simulations-thermal.md](doc_simulations-thermal.md). | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -315,25 +269,19 @@ see [doc_simulations-thermal.md](doc_simulations-thermal.md) | `sensor_xy_halfsize` | Sensor window half-size for temperature recording [grid units]. | `100` | | | `record_t_at_every_step` | Record temperature at every time step for the full sensor window? | `0` | Memory-intensive; disable if out-of-memory errors occur. | -
- --- -
-analysis — Output analysis +### `analysis` — Output analysis | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| -| `focus_area_radius` | Radius around the focus in which ISPPA is averaged for outputs [mm]. | `5` | | - -
+| `focus_area_radius` | Radius around the focus in which IPA is averaged for outputs [mm]. | `5` | | --- -
-hpc — High-performance computing +### `hpc` — High-performance computing -see [doc_backend.md](doc_backend.md) [doc_hpc.md](doc_hpc.md) +See [doc_backend.md](doc_backend.md) and [doc_hpc.md](doc_hpc.md). | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -347,14 +295,11 @@ see [doc_backend.md](doc_backend.md) [doc_hpc.md](doc_hpc.md) | `job_prefix` | Prefix string for HPC job names. | `'PRESTUS'` | Overridden to `'TP'` for transducer positioning jobs. | | `max_wait_checks` | Maximum number of job status checks when `wait_for_job = true`. | `540` | At 1 check/20 s, `540` ≈ 3 hours. | -
- --- -
-calibration — Transducer calibration +### `calibration` — Transducer calibration -see [doc_calibration.md](doc_calibration.md) +See [doc_calibration.md](doc_calibration.md). A separate `calibration_config.yaml` applies for calibration workflows and is loaded as `parameters.calibration`. @@ -377,5 +322,3 @@ A separate `calibration_config.yaml` applies for calibration workflows and is lo | `opt_weights` | Weighting of original profile during fitting. | `1` = equal; `>1` = Gaussian (narrower with larger values) | | `opt_seed` | Random seed for optimisation. | | | `skip_front_peak_mm` | Distance from profile start to ignore [mm]. | Avoids near-field artefacts in peak/FWHM calculations. | - -
From 28db2ffd68c0ca3ddbcb8ba233ccb1b4791ab2dc Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 25 Mar 2026 19:01:25 +0100 Subject: [PATCH 141/144] [doc] fix formatting --- documentation/doc_parameters.md | 58 ++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 037ef9cb..4445e5f1 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -13,7 +13,7 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST --- -### `simulation` — Simulation type & execution +### Simulation type (`simulation`) | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -25,7 +25,7 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST --- -### `path` — Data paths +### Data paths (`path`) | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -39,7 +39,7 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST --- -### `startup` — Environment & toolbox paths +### Environment & toolbox paths (`startup`) | **Parameter** | **Description** | **Comments** | |---|---|---| @@ -49,7 +49,7 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST --- -### `io` — I/O management +### I/O management (`io`) | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -72,7 +72,7 @@ Parameters are organised in nested structs that map directly to YAML keys. PREST --- -### `modules` — Pipeline module flags +### Pipeline modules (`modules`) | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -92,7 +92,7 @@ All flags: `1 = yes`, `0 = no`. --- -### `transducer` — Transducer specification +### Transducer specification (`transducer`) See [doc_transducer.md](doc_transducer.md). @@ -116,12 +116,14 @@ All fields are mandatory and have no defaults — they must be set in the study --- -### `placement` — Transducer placement +### Transducer placement (`placement`) -See [doc_transducer.md](doc_transducer.md). +See [doc_placement.md](doc_placement.md). #### `placement.localite` +See [doc_placement_neuronav.md](doc_placement_neuronav.md). + | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| | `enabled` | Load transducer position from Localite neuronavigation files? | `0` | `1 = yes`, `0 = no` | @@ -129,6 +131,8 @@ See [doc_transducer.md](doc_transducer.md). #### `placement.heuristic` +See [doc_placement_heuristic.md](doc_placement_heuristic.md). + | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| | `save_localite_t1` | Save Localite-aligned T1 output for header correction? | `false` | See [doc_placement_heuristic.md](doc_placement_heuristic.md). | @@ -145,7 +149,7 @@ See [doc_transducer.md](doc_transducer.md). --- -### `grid` — Simulation grid +### Simulation grid (`grid`) | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -161,7 +165,16 @@ See [doc_transducer.md](doc_transducer.md). --- -### `headmodel` — Head model & segmentation preprocessing +### Segmentation (`segmentation`) + +| **Parameter** | **Description** | **Default** | **Comments** | +|---|---|---|---| +| `use_qform` | Force qform reorientation before charm segmentation? | `0` | Set to `1` if charm reports a qform/sform mismatch error. | +| `debug` | Pass `--debug` to charm for verbose segmentation output? | `0` | `1 = yes`, `0 = no` | + +--- + +### Head model & segmentation preprocessing (`headmodel`) See [doc_preproc.md](doc_preproc.md). @@ -180,16 +193,7 @@ See [doc_preproc.md](doc_preproc.md). --- -### `segmentation` — Segmentation settings - -| **Parameter** | **Description** | **Default** | **Comments** | -|---|---|---|---| -| `use_qform` | Force qform reorientation before charm segmentation? | `0` | Set to `1` if charm reports a qform/sform mismatch error. | -| `debug` | Pass `--debug` to charm for verbose segmentation output? | `0` | `1 = yes`, `0 = no` | - ---- - -### `pct` — Pseudo-CT skull property mapping +### Pseudo-CT skull property mapping (`pct`) See [doc_pseudoCT.md](doc_pseudoCT.md). @@ -202,7 +206,7 @@ See [doc_pseudoCT.md](doc_pseudoCT.md). --- -### `layers` — Simulation tissue compartments +### Tissue layers (`layers`) See [doc_preproc.md](doc_preproc.md). @@ -219,7 +223,7 @@ Maps tissue compartment names to their SimNIBS charm label indices. Compartments --- -### `medium_properties` — Tissue acoustic & thermal properties +### Tissue acoustic & thermal properties (`medium_properties`) See [doc_medium.md](doc_medium.md). @@ -238,7 +242,7 @@ Each tissue compartment (`water`, `brain`, `skin`, `skull`, `skull_trabecular`, --- -### `timing` — Sonication timing protocol +### Sonication timing (`timing`) See [doc_simulations-thermal.md](doc_simulations-thermal.md). @@ -258,7 +262,7 @@ Protocol duration fields must be set for thermal simulations. --- -### `thermal` — Thermal simulation settings +### Thermal settings (`thermal`) See [doc_simulations-thermal.md](doc_simulations-thermal.md). @@ -271,7 +275,7 @@ See [doc_simulations-thermal.md](doc_simulations-thermal.md). --- -### `analysis` — Output analysis +### Output analysis (`analysis`) | **Parameter** | **Description** | **Default** | **Comments** | |---|---|---|---| @@ -279,7 +283,7 @@ See [doc_simulations-thermal.md](doc_simulations-thermal.md). --- -### `hpc` — High-performance computing +### High-performance computing (`hpc`) See [doc_backend.md](doc_backend.md) and [doc_hpc.md](doc_hpc.md). @@ -297,7 +301,7 @@ See [doc_backend.md](doc_backend.md) and [doc_hpc.md](doc_hpc.md). --- -### `calibration` — Transducer calibration +### Transducer calibration (`calibration`) See [doc_calibration.md](doc_calibration.md). From 2c908aaf84db86ca464d19986602c315665ecd4c Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Thu, 26 Mar 2026 10:25:13 +0100 Subject: [PATCH 142/144] [doc] refer to external article --- documentation/doc_medium.md | 13 ++++++++----- documentation/doc_outputs.md | 11 ++++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/documentation/doc_medium.md b/documentation/doc_medium.md index dcfca3ae..72b942eb 100644 --- a/documentation/doc_medium.md +++ b/documentation/doc_medium.md @@ -50,8 +50,11 @@ The following default medium properties applt in a full layered simulation. Para > **Note: Absorption Fraction.** For thermal simulations, PRESTUS models the fraction of attenuation that is assumed to be absorbed in the bioheat equation. The current default estimate is informed as follows: The bulk attenuation coefficient includes contributions from both longitudinal and shear waves. However, longitudinal waves contribute most (~ 80%, Wang et al., 2018) to absorption and heating in the skull (Pinton et al., 2012; White et al., 2006). For longitudinal and shear waves, absorption values of 2.7 dB/cm2/MHz, and 5.4 dB/cm2/MHz have been reported (Pinton et al., 2012), with a bulk attenuation of 13.3 dB/cm2/MHz. As such, we assume: absorption fraction = (longitudinal wave absorption + 0.2 * shear wave absorption) / bulk attenuation. This yields (2.7+0.2*5.4)/13.3 = 0.28. This estimate of the bone absorption fraction (28%) is substantially lower than the default of 100% implemented in k-Plan, but larger than the default implemented in BabelBrain (16%). More measurements are needed to decrease uncertainty about the relative acoustic energy absorption in the skull layer. -*References* - -- Pinton G, Aubry J, Bossy E, Muller M, Pernot M, Tanter M. Attenuation, scattering, and absorption of ultrasound in the skull bone. Méd Phys 2012;39:299–307. https://doi.org/10.1118/1.3668316. -- Wang X-D, Lin W-J, Su C, Wang X-M. Influence of mode conversions in the skull on transcranial focused ultrasound and temperature fields utilizing the wave field separation method: A numerical study. Chin Phys B 2018;27:024302. https://doi.org/10.1088/1674-1056/27/2/024302. -- White PJ, Clement GT, Hynynen K. Longitudinal and shear mode ultrasound propagation in human skull bone. Ultrasound Med Biol 2006;32:1085–96. https://doi.org/10.1016/j.ultrasmedbio.2006.03.015. +
+ + References:
+ Pinton, G., Aubry, J., Bossy, E., Muller, M., Pernot, M., & Tanter, M. Attenuation, scattering, and absorption of ultrasound in the skull bone. Méd Phys 39, 299–307 (2012). https://doi.org/10.1118/1.3668316.
+ Wang, X.-D., Lin, W.-J., Su, C., & Wang, X.-M. Influence of mode conversions in the skull on transcranial focused ultrasound and temperature fields utilizing the wave field separation method: A numerical study. Chin Phys B 27, 024302 (2018). https://doi.org/10.1088/1674-1056/27/2/024302.
+ White, P. J., Clement, G. T., & Hynynen, K. Longitudinal and shear mode ultrasound propagation in human skull bone. Ultrasound Med Biol 32, 1085–1096 (2006). https://doi.org/10.1016/j.ultrasmedbio.2006.03.015. +
+
\ No newline at end of file diff --git a/documentation/doc_outputs.md b/documentation/doc_outputs.md index 0a51194d..735708d9 100644 --- a/documentation/doc_outputs.md +++ b/documentation/doc_outputs.md @@ -24,7 +24,16 @@ The **spatial peak** of IPA is ISPPA. When IPA is reported at a specific locatio The **mechanical index** at each voxel is defined as MI = p⁻ / √f, where p⁻ is the peak negative pressure in MPa and f is the centre frequency in MHz. Note: PRESTUS uses `p_max_all` (peak positive pressure) as a proxy for p⁻; a direct negative-pressure map would require additional sensor configuration. ---- +An overview of the nomenclature is provided in [Darmani et al. (2022, Clinical Neurophysiology)](https://doi.org/10.1016/j.clinph.2021.12.010). + +![intensity_nomenclature](https://ars.els-cdn.com/content/image/1-s2.0-S1388245721008920-gr2.jpg) + +
+ + References:
+ Darmani, G., Bergmann, T. O., Butts Pauly, K., Caskey, C. F., de Lecea, L., Fomenko, A., Fouragnan, E., Legon, W., Murphy, K. R., Nandi, T., Phipps, M. A., Pinton, G., Ramezanpour, H., Sallet, J., Yaakub, S. N., Yoo, S. S., & Chen, R. (2022). Non‑invasive transcranial ultrasound stimulation for neuromodulation. Clinical Neurophysiology, 133(7), 167–177. https://doi.org/10.1016/j.clinph.2022.03.011 +
+
### Acoustic metrics From 551116263a7fe06718796d3b8e9767a2bf112637 Mon Sep 17 00:00:00 2001 From: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:09:49 +0200 Subject: [PATCH 143/144] Squashed commit of the following: commit 6b4f8c10c5f082f2d0866ba63a630216068d042f Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Tue Mar 31 19:13:57 2026 +0200 updated docs & fix steering bug commit bc7cb3377c138b01226a33e033d6952ceead68bc Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Mon Mar 30 10:10:23 2026 +0200 merge bug fixes commit 61d046498d04685ef259e4cc366270bccb085abd Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Fri Mar 27 10:00:23 2026 +0100 merge bugfixes commit 949232c8fa54cd5a27d74cd209749b7c5ec8ba88 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Thu Mar 26 17:39:22 2026 +0100 update slurm and snellius compatibility commit efc9861bf3af2b6512742bdb2ab13a82cdb6062f Author: Julian Kosciessa Date: Thu Mar 26 10:25:13 2026 +0100 [doc] refer to external article commit 945d79223243833d37d9ccc8dfce2eb0c8f5fb95 Author: Julian Kosciessa Date: Wed Mar 25 19:01:25 2026 +0100 [doc] fix formatting commit 0772f85a2830de550f68b36689d64bca688c7a85 Author: Julian Kosciessa Date: Wed Mar 25 18:53:53 2026 +0100 [doc] fix formatting commit 57c879843e6b511c0276ae0189dfcbf91501c878 Author: Julian Kosciessa Date: Wed Mar 25 18:47:24 2026 +0100 [output] standardize intensity and pressure names commit edb3e7f3f0af0c10866ad2eb5d0b38e352a38410 Author: Julian Kosciessa Date: Wed Mar 25 17:23:09 2026 +0100 [reporting] add MI transcranial commit 3b6821af1361089e72b087bc55881b0ea9a8be63 Author: Julian Kosciessa Date: Wed Mar 25 16:58:20 2026 +0100 [test] add initial tests commit 30aec4e714251ede9ec8c65d05beef8283569aba Author: Julian Kosciessa Date: Sat Mar 21 17:50:48 2026 +0100 [parameters] hotfixes, further parameter rename commit 5eb57303ed222fa6b59211ab0a1bafba0244b5fd Author: Julian Kosciessa Date: Thu Mar 19 23:46:15 2026 +0100 [parameters] segmentation_only mode commit d312257f9e204c669e5af7e1c34a77e5982a8331 Author: Julian Kosciessa Date: Thu Mar 19 23:31:34 2026 +0100 [parameter] refactor parameters commit 556daa232d2ad483764b6502fdbd0a28a9d17275 Author: Julian Kosciessa Date: Tue Mar 17 13:14:16 2026 +0100 [pct] kplan: regularize min. skull density to water commit aa1d59a995f255b8f4811d878b2ff21811241d91 Author: Julian Kosciessa Date: Tue Mar 17 12:07:26 2026 +0100 [hpc] fix job formatting commit f11624bf9138f2557ab67f969915ae6b7b000ab2 Author: Julian Kosciessa Date: Tue Mar 17 11:33:36 2026 +0100 [hpc] hotfix: wait for job commit 9826dcfb5581b85f59ea97aa78d73e285a8afe77 Author: Julian Kosciessa Date: Mon Mar 16 16:38:04 2026 +0100 [hotfix] unify hpc_wait_for_job commit d1a7fc1f5b8441d9dd087921483a80126578bb06 Author: Julian Kosciessa Date: Mon Mar 16 15:30:02 2026 +0100 [segmentation] fix log directory to segmentation folder commit 08fd90a1f6eb64f9f7ae3ccc033686c647df0bc0 Author: Julian Kosciessa Date: Mon Mar 16 15:28:54 2026 +0100 [hpc] hotfix cpu/gpu choice commit 1cb5aee410f8ed123aad16313350ebccd5060d74 Author: Julian Kosciessa Date: Mon Mar 16 14:57:03 2026 +0100 [calibration] ensure transducer distances are present commit 2e9eadc817bcce4fc1bfcd18ab03225181c6be8b Author: Julian Kosciessa Date: Sat Mar 14 19:15:29 2026 +0100 [pct doc] add comment on kPlan densities commit 130e57247131127aacf18fe5b10ec0ca923f4d6a Author: Julian Kosciessa Date: Sat Mar 14 19:12:16 2026 +0100 [pct] regularize kplan skull sound speed mapping to 1500 commit 97b9c92675a6ab12de8d5b891d696710a0d4c817 Author: Julian Kosciessa Date: Sat Mar 14 18:36:55 2026 +0100 [doc] refactor neuronav placement and coordinates commit b0a6acdf920e1eb046dfea778164bb5c25876c26 Author: Julian Kosciessa Date: Sat Mar 14 18:04:36 2026 +0100 [doc] reformat table commit f113dc161eb22d48ed2091aaf3c3b2c185fbc2f3 Author: Julian Kosciessa Date: Sat Mar 14 18:00:48 2026 +0100 [doc] add example localite output table commit 5949d3bb895c95410ed489f03b83c9ce10ee5b1d Author: Julian Kosciessa Date: Sat Mar 14 17:48:54 2026 +0100 [heuristic placement] add and document additional criteria, convert to RAS mm, reformat txt commit 5ed4b79fbb13055bcdd82a55cc7c6603d3194810 Author: Julian Kosciessa Date: Sat Mar 14 15:01:22 2026 +0100 Merge pull request #109 from sirmrmarty/development [fix & enhancement] localite_path fallback, slurm submission formate error, padding makeBowl, transducer position plot error, wrong parameter passing to recompute Oneil and nii_image gpuArray/cpuArray issue commit 11e3d0b5aa1d476edc0b5fcabaf10c4aff76eb96 Author: sirmrmarty Date: Sat Mar 14 13:44:35 2026 +0100 [fix] nii_image wasnt available for cpu as it was still a gpuArray, adjusted in other instances as well commit 55c9cdbbe50b3f39db7e89458930f8606b3a86de Author: sirmrmarty Date: Sat Mar 14 08:35:46 2026 +0100 [fix] nii_image wasnt available for cpu as it was still a gpuArray commit db9b2199960c347945402e39bcdeab69a69c6918 Author: sirmrmarty Date: Sat Mar 14 07:25:47 2026 +0100 [fix] the unoptimized parameters were passed on for recomputing Oneil commit 5b45e88d5eab1fdbdb2cf79886f5a0a3f59b0d6c Author: sirmrmarty Date: Fri Mar 13 22:08:56 2026 +0100 [fix] variable img wasn't passed between tp_plot and transducer_positioning.m commit 75725e012aae9ebb0684975bba15f5ddd65ba609 Author: sirmrmarty Date: Fri Mar 13 21:00:42 2026 +0100 [fix] if localite_path isn't defined the fallback should be the output_dir commit cdcfda4d45da6d297fe1ee07452962a48ff0b751 Author: sirmrmarty Date: Fri Mar 13 20:50:44 2026 +0100 [fix] the slurm submission error due to sbatch format error commit fd688fd80f8b74f0b2383b5e8ae9f5539548c615 Author: sirmrmarty Date: Fri Mar 13 16:11:21 2026 +0100 adding padding when makeBowl is used and transducer is out of bounds commit 7f39d3c97859bdd55a025d7330834a5af6824f22 Author: Julian Kosciessa Date: Fri Mar 13 14:43:02 2026 +0100 Merge remote-tracking branch 'sirmrmarty/development' into development commit c2043d98215c92ec8e82db4cbb0b3d660863a0a4 Author: Julian Kosciessa Date: Fri Mar 13 14:33:46 2026 +0100 [doc] draft documentation for heuristic transducer placement commit 034d4b309eab2514be6a74f21a9cd3ad9362cd3b Author: Julian Kosciessa Date: Fri Mar 13 14:05:36 2026 +0100 [heuristic feature, refactor, doc] added ear removal to heuristic, localite T1 output commit 610c00093ca7d6aab91becf2ecf92ce62f106e50 Author: Julian Kosciessa Date: Fri Mar 13 12:12:56 2026 +0100 [tp] further refactoring, add ear exclusion commit 27c59a56e1f45f86c2f7a734cad7b5815783eb89 Author: Julian Kosciessa Date: Thu Mar 12 17:42:48 2026 +0100 [tp] reorder steps, idenitfy missing plot inputs commit 0a698cdbfcdddb6e8e543dc74a0558aded8beb64 Author: Julian Kosciessa Date: Thu Mar 12 17:03:21 2026 +0100 [hotfix] fix PRESTUS path loading commit 41eac0c92fad1721c22d7047543fad93e3b87cb2 Author: Julian Kosciessa Date: Thu Mar 12 16:50:08 2026 +0100 [tp-hpc, default] fix tp hpc submission, fix check for expected sim path commit 3de52c41ffa6b08c07a30a896180d314155bec96 Author: Julian Kosciessa Date: Thu Mar 12 16:29:03 2026 +0100 [transducer placement] continue refactoring commit bb9964c60b06fbbd026517946185834dd878a979 Author: Julian Kosciessa Date: Thu Mar 12 14:02:27 2026 +0100 [map] central function for coordinate mapping commit 053771fa06ce161b23bb4c202d0bde25f21261d4 Author: Julian Kosciessa Date: Thu Mar 12 10:57:43 2026 +0100 [transducer positioning] draft refactoring, hpc integration commit 8d6546ed83545eb8dee50bd77797afe153131672 Author: Julian Kosciessa Date: Wed Mar 11 21:50:17 2026 +0100 [beauty] simplify variable passing commit 7bc831adb8627c5a71ea3047f66115d40c5e22a5 Author: Julian Kosciessa Date: Wed Mar 11 21:28:11 2026 +0100 [hotfix] commit 0b42972955ad8d91ecb9d10a7684970b49256a6a Author: Julian Kosciessa Date: Wed Mar 11 20:55:53 2026 +0100 [platform] rename submit medium to platform, remove special case for calibration commit fd55568724d008f0d9614b901b5ed94984fd818f Author: Julian Kosciessa Date: Wed Mar 11 20:31:43 2026 +0100 [refactor] pipeline renamed, hpc submission refactored commit b480266b8cfc2d2374bdd593a4068dc2e581d4ee Author: Julian Kosciessa Date: Wed Mar 11 17:25:33 2026 +0100 [hotfix] commit 14676a5ab91d5184b6055b20f4673c5137f136b6 Author: Julian Kosciessa Date: Wed Mar 11 17:19:14 2026 +0100 [pipeline] allow to turn off individual modules, comment on run_acoustic commit a1017f1ed5e5f7129e8a6f08f3b783f5d5e406d1 Author: Julian Kosciessa Date: Wed Mar 11 17:18:36 2026 +0100 [pct] add header to html commit a1418c7213639a16f11aabaadb6c4482e914a8d1 Author: sirmrmarty Date: Wed Mar 11 16:36:51 2026 +0100 [fix] adjust the velocity to match the max_isppa commit b63eef9e2f328cd4e339c0d4120344013c721bc1 Author: Julian Kosciessa Date: Wed Mar 11 16:35:53 2026 +0100 [pct, param] allow to turn off nifti creation, include pCT in html if used commit 2b105583698605d9df7534f8b31d7087840f0254 Author: sirmrmarty Date: Wed Mar 11 16:02:04 2026 +0100 [feature] fitting velocity exactly to desired intensity commit 6b09def301698c350867c88c5042507e042b7f14 Author: Julian Kosciessa Date: Wed Mar 11 13:33:18 2026 +0100 [hcp] refactor slurm submission, relabel temp files commit e99d9b7839b4acbd71d617a05b175b1a9facedbd Author: Julian Kosciessa Date: Wed Mar 11 12:11:44 2026 +0100 [smooth, param] smoothing changed to FWHM mm, parameter name changed commit e145f9679a15876fa14507067be05c10bb3f5aa7 Author: Julian Kosciessa Date: Wed Mar 11 11:32:26 2026 +0100 [bug] skull expansion labels could mismatch when removing layers commit ad857000daaaeeadb601436893401879c045a845 Author: Julian Kosciessa Date: Tue Mar 10 19:17:19 2026 +0100 [hotfix] correctly grab labels from medium not layers bug notable when dropping requested layers commit 40d251db38f6662c9a13f078f57646eb7549a0f9 Author: Julian Kosciessa Date: Tue Mar 10 18:31:07 2026 +0100 [pct] hotfix commit d70e8b0848bfb64af77ac1190193970933e2cdff Author: Julian Kosciessa Date: Tue Mar 10 18:21:44 2026 +0100 [neuronav] update localite readin for GUM files commit 69bebb6f0af35672620a4cea2939c3648f94b163 Author: Julian Kosciessa Date: Tue Mar 10 15:53:19 2026 +0100 [pct] hotfix for debug plot commit b60db2964c920f3123249d0baddbf2871f201d70 Author: Julian Kosciessa Date: Tue Mar 10 15:20:31 2026 +0100 [pct] hotfix: uncomment key script parts commit 78500ad2d5c68a29575199d05b0e18a00e9d5d99 Author: Julian Kosciessa Date: Tue Mar 10 15:11:40 2026 +0100 [doc] update quick start guide commit bf45458a43482ecb00185f64d068bc3c7c6fe20c Author: Julian Kosciessa Date: Tue Mar 10 14:55:14 2026 +0100 [doc] update link to (outdated) tutorial commit 00da4df91450059415066038aa35a1e7990c95c3 Author: Julian Kosciessa Date: Tue Mar 10 14:50:02 2026 +0100 remove outdated examples commit 745153f9f6aa3dc5c064d0d147c5da4dd6880638 Author: Julian Kosciessa Date: Tue Mar 10 12:35:22 2026 +0100 [cpp] prevent string error for variable passing commit 76a9d66165dc8ac9bb8f43d7e4f114d6e75c0dad Author: Julian Kosciessa Date: Tue Mar 10 12:34:37 2026 +0100 [calibration] example: force create output folder commit 2f84e922006ab265698e7e28b2c65e872165a566 Author: Julian Kosciessa Date: Tue Mar 10 12:10:17 2026 +0100 [segmentation] add option to call segementation within matlab commit 4dec41766cc1327e859c316f7eabff7f8d3e4903 Author: Julian Kosciessa Date: Tue Mar 10 11:34:09 2026 +0100 [calibration] fix distance encoding for opt oneil commit 44200159fa6c9be6ca755f0218a7eb538e8d34fb Author: Julian Kosciessa Date: Tue Mar 10 11:24:43 2026 +0100 [FD] throw warning not error if focal distance is missing (e.g., to allow only segmentation run) commit 41edd26431417bd6fc3bef01097a61e83d6e9d71 Author: Julian Kosciessa Date: Tue Mar 10 11:08:59 2026 +0100 [calibration] encourage use of project-specific config folder Provide option to specify a config folder in example, if not provided this will default back to the PRESTUS config folder, but it is not recommended to edit files there. commit c5497ed9a1e002ad88988e08f4b51bfca5ed26a2 Author: sirmrmarty Date: Tue Mar 10 10:15:55 2026 +0100 fix the axial_profile issue in plot_sim as well commit 5cc9d4f3b4d26dc660ba2437f2eae448f5431a92 Author: sirmrmarty Date: Tue Mar 10 09:33:36 2026 +0100 [fix] use profile_target.axial_distance_bowl as x-axis in compute_oneil_solution and recompute_oneil_solution. Fixes size mismatch error when plotting the desired profile: axial_position (from simulation grid) and profile_target.axial_intensity can have different lengths. commit 40a01e67ac40c15428243925c8e985835ca7ed25 Author: Julian Kosciessa Date: Mon Mar 9 19:16:16 2026 +0100 [feature] implement smoothing of acoustic property maps, redefine smoothing kernels commit 3ccec9fd3597589a844f6f73713d69bfdbfa1d9d Author: Julian Kosciessa Date: Mon Mar 9 17:54:12 2026 +0100 [clean] medium mapping commit 6a74a0bd2d71af132ce59b1bf2e834c40c56041f Author: Julian Kosciessa Date: Mon Mar 9 17:28:47 2026 +0100 [doc] update commit 159e4b8070523830b1ad70156ee4dfab781caf69 Author: Julian Kosciessa Date: Mon Mar 9 17:25:31 2026 +0100 [grid] optional grid padding to prevent transducer out-of-bounds commit 83c23e6bfb268b0d7296e6f377cf7c7214260967 Author: Julian Kosciessa Date: Mon Mar 9 16:45:11 2026 +0100 [pipeline] add more parameters to turn off pipeline steps important: this will impact the default parameters...add new fields commit 0f642ef029217f57c87664cce032a6951f34cb9a Author: Julian Kosciessa Date: Mon Mar 9 16:01:10 2026 +0100 [doc] update parameters commit 30f3c5c5003e34a21ac15b331f576e9bd3029f13 Author: Julian Kosciessa Date: Mon Mar 9 15:57:50 2026 +0100 [doc] update parameters commit 78ce611c7b074349f48990bf4327ea5cf6614da3 Author: Julian Kosciessa Date: Mon Mar 9 15:41:06 2026 +0100 [pct, doc] make a note w.r.t. defaults commit 64740cc761f03ae020f6f0497393a63b7eb94d49 Author: Julian Kosciessa Date: Mon Mar 9 15:37:23 2026 +0100 [pct] change use_pseudoCT param name, update doc commit 53a2dedf1ce63ecc2dde3a803cba2d7080fd49ed Author: Julian Kosciessa Date: Mon Mar 9 15:27:07 2026 +0100 [pct] individual media mapping flags and functions, updated doc commit ac772297dccdcbe91eb0677de79971baa20c6ac3 Author: Julian Kosciessa Date: Fri Mar 6 22:59:35 2026 +0100 [log] track SimNIBS version used for segmentation commit 66b0e27c84dd5105ea19fdf5ea7c25b7b2d54d7c Author: Julian Kosciessa Date: Fri Mar 6 17:47:04 2026 +0100 [seg] hotfix commit bb8f3cea6d7affa214c50f272ba5b5fb1d5a27d0 Author: Julian Kosciessa Date: Fri Mar 6 17:04:37 2026 +0100 [doc] update simnibs installation guide commit fb61199047731631c8c8d7c980af3df9f28e925f Author: Julian Kosciessa Date: Fri Mar 6 15:16:26 2026 +0100 [bug] fix exit plane visualization, fix bowl distance param readin commit fa4fe451840c39e84138156765ef073c3467ad71 Author: Julian Kosciessa Date: Fri Mar 6 12:59:58 2026 +0100 [doc] separate transducer and calibration doc commit 6f27a4c7aac2069896961700ea8ea13b1dfe40a2 Author: Julian Kosciessa Date: Fri Mar 6 12:49:36 2026 +0100 [calibration, doc] document calibration config commit 149e239786974d435cb1a456b6d29a6d732852dd Author: Julian Kosciessa Date: Fri Mar 6 12:14:37 2026 +0100 [doc] formula formatting commit eb41765d7609426c6c11f415a60509faa9425cd0 Author: Julian Kosciessa Date: Fri Mar 6 12:08:21 2026 +0100 [doc] fix formatting commit b7f7acf5aad9702e041a946d8d12cc814cd1d9e9 Author: Julian Kosciessa Date: Fri Mar 6 12:03:33 2026 +0100 [calibration] hotfixes, documentation commit ac69f28ad30428f7f7f1f983fcf1be8731a972a9 Author: Julian Kosciessa Date: Fri Mar 6 10:42:53 2026 +0100 [calibration] hotfixes commit 9a1ca4c1f095f3c0bc82ea6a37ea2e38889da1b4 Author: Julian Kosciessa Date: Thu Mar 5 21:56:34 2026 +0100 [calibration] hotfix commit fa45f1be7d7d09d93a2d776eddca7a2498c11a33 Author: Julian Kosciessa Date: Thu Mar 5 21:37:18 2026 +0100 [calibration] hotfixes + outsource force overwrite commit 7c75bbd08f891f6900216666984599960393bb81 Author: Julian Kosciessa Date: Thu Mar 5 21:21:05 2026 +0100 [hotfix] uncomment calibration commit 78cf1e6d1aaeed763e4a225deb3b0e271d07adf4 Author: Julian Kosciessa Date: Thu Mar 5 21:19:23 2026 +0100 [calibration] refactor, facilitate structure passing major refatcoring of calibration code to ease comparison between targeted, simulated, and analytical profiles, major internal variable renaming, adjustment of empirical target profile to length of simulation axis, fitting constraint to min/max depth of non-NaN intensities commit 0c2927b37255fb93857a32898fbe7562153d6d99 Author: Julian Kosciessa Date: Thu Mar 5 13:48:02 2026 +0100 [doc] update formatting commit 200bfc01697a7b0e371ce751b2501ae8b11ac861 Author: Julian Kosciessa Date: Thu Mar 5 13:38:03 2026 +0100 [doc] add changelog commit 41f554226bddbb1d0bf3fa34f8ead3813276cc9b Author: Julian Kosciessa Date: Thu Mar 5 12:48:07 2026 +0100 Merge branch 'development' of github.com:Donders-Institute/PRESTUS into development commit 940a0e52acfb91efe2ee0b92821cea5d29c228c2 Author: Julian Kosciessa Date: Thu Mar 5 12:47:43 2026 +0100 [doc] update contributions commit d276b223a79d6af8eab449b1afbcd39fb15696fc Author: Julian Kosciessa Date: Thu Mar 5 12:21:30 2026 +0100 Merge pull request #108 from sirmrmarty/development [html] proper unit to display pressure in the dashboard commit c045c98db35a4d163ef93cc50d11c334120291c0 Author: sirmrmarty Date: Thu Mar 5 12:08:00 2026 +0100 change the pressure units for the acoustic section as well commit d15faf9357f56615951a28661f06949afd8ad50e Author: sirmrmarty Date: Thu Mar 5 11:57:00 2026 +0100 change the pressure units for the safety dashboard as well commit 575f95c64fd7701a67662034caf7e6efb72e0d34 Author: sirmrmarty Date: Thu Mar 5 11:42:26 2026 +0100 added dynamic pressure display in the html report and remove CEM and temperature from water stimulation report commit e13f284516b904aeea2e87c5a874fe0fb09f5b20 Author: Julian Kosciessa Date: Wed Mar 4 19:52:38 2026 +0100 [doc] add links commit ec14e7af30483325ba06fa7166b9d59c4b3c5f0d Author: Julian Kosciessa Date: Wed Mar 4 19:40:01 2026 +0100 [doc] add pct image commit c01ab9d3e2ef43cb3352005c52c8085d894fc0ac Author: Julian Kosciessa Date: Wed Mar 4 19:33:47 2026 +0100 [doc] reorder start guide commit 0e9d222dd06fbfd1352629bd5b9220e87887e10e Author: Julian Kosciessa Date: Wed Mar 4 19:30:24 2026 +0100 [doc] update pages commit 788644daa614e98247a3a843c74b64d02b155fb9 Author: Julian Kosciessa Date: Wed Mar 4 18:52:49 2026 +0100 [doc] update documentation, starting guide commit 81b109748f47dffe7fb446ee3d83d8b5f4d7c7c6 Author: Julian Kosciessa Date: Wed Mar 4 18:17:44 2026 +0100 [doc] attempt to link html commit 2ffdaf1df23c0221029d3f394ac6500674730d03 Author: Julian Kosciessa Date: Wed Mar 4 18:09:11 2026 +0100 [doc] attempt to link html commit ab7507cd1c36df0a37b54dc4f2f1801dfa5d79dd Author: Julian Kosciessa Date: Wed Mar 4 18:07:37 2026 +0100 [doc] include examples commit b93065385df9604dad06b9499f7baa0214a4c0db Author: Julian Kosciessa Date: Wed Mar 4 17:30:01 2026 +0100 [doc] update documentation pages commit c9d44ad28a64a9e14f76707b3ad5def81c908075 Author: Julian Kosciessa Date: Wed Mar 4 17:29:24 2026 +0100 [pct] add example creation script commit 3722539c7a11a15cae9ba1f7c2d8535b46314921 Author: Julian Kosciessa Date: Wed Mar 4 17:01:11 2026 +0100 [doc] beauty update commit 24c14b8e5589918fde24abcddeedaceb01674c8b Author: Julian Kosciessa Date: Wed Mar 4 16:59:53 2026 +0100 [doc] hotfixes commit 77260e68f76a2146a697f6da5afea9c8a9ef32cd Author: Julian Kosciessa Date: Wed Mar 4 16:57:12 2026 +0100 [doc] update documentation pages commit 182aff251e11a906ee5cebf28ee4cdd6d00ae8cd Author: Julian Kosciessa Date: Wed Mar 4 14:53:01 2026 +0100 [doc] image integration commit e250a122c67a1448978aa3fec62305d89873e27c Author: Julian Kosciessa Date: Wed Mar 4 13:46:34 2026 +0100 [doc] update readmes commit 99949654ee886eb21a3fadc49ce2ec96f5e2676c Author: Julian Kosciessa Date: Wed Mar 4 13:32:16 2026 +0100 [doc] install reorder commit 8df1fafbefb3b13bfeef4def69010adadb3d6e92 Author: Julian Kosciessa Date: Wed Mar 4 13:29:22 2026 +0100 [doc] duplicate content where necessary commit 875951bb0207125a3b63bb6cabc639647eb6a963 Author: Julian Kosciessa Date: Wed Mar 4 13:22:00 2026 +0100 [doc] relink root files commit bbc232a173a312ff57c0661a31dc295f7e10c503 Author: Julian Kosciessa Date: Wed Mar 4 13:20:31 2026 +0100 [doc] relink root files commit 0c6270860996fa8c812a4f9a642ab32c1d2dfde2 Author: Julian Kosciessa Date: Wed Mar 4 13:17:24 2026 +0100 [doc] remove index commit c0dcf17a803e0e400de962f9378e1d1154e800d9 Author: Julian Kosciessa Date: Wed Mar 4 13:12:56 2026 +0100 [doc] add requirements commit a6dabd736ac96855352b4727a92546824a90ab06 Author: Julian Kosciessa Date: Wed Mar 4 13:08:57 2026 +0100 [doc] update requirements commit e2d87d39d65559868734f816152a7f57d08927a5 Author: Julian Kosciessa Date: Wed Mar 4 13:05:04 2026 +0100 [doc] include readme commit 39c292f908d937eda3240c86a80bd35d234a9ae1 Author: Julian Kosciessa Date: Wed Mar 4 13:03:51 2026 +0100 [doc] include readme commit c7e8f1cabcd8a81e1746bfb1b6315b16425f88b6 Author: Julian Kosciessa Date: Wed Mar 4 13:03:00 2026 +0100 [doc] include readme commit abc96794352598f2619294aa64e93fc9c646ca46 Author: Julian Kosciessa Date: Wed Mar 4 12:50:43 2026 +0100 [doc] format outputs commit 7f9cc949e4f7543cb4324b950b36e9a6ce859560 Author: Julian Kosciessa Date: Wed Mar 4 12:43:31 2026 +0100 [doc] gh action commit ee06f3d793843ab7017a247d2cc3abd628e6eb3a Author: Julian Kosciessa Date: Wed Mar 4 12:40:38 2026 +0100 [doc] update commit 4682af3014d59508ce0e3ff7344b72e84653e86e Author: Julian Kosciessa Date: Wed Mar 4 12:36:02 2026 +0100 [doc] fix links commit 12905a8558abdee2cf241f53af6ea500f2fd9014 Author: Julian Kosciessa Date: Wed Mar 4 12:27:33 2026 +0100 [doc] fix syntax commit 84ba03e18b8746e38426e170dcd18ba7e8c83113 Author: Julian Kosciessa Date: Wed Mar 4 12:25:09 2026 +0100 [doc] update outputs commit 38e0111408e78025a2e5d7e6678d19a57db9add5 Author: Julian Kosciessa Date: Wed Mar 4 12:18:50 2026 +0100 [doc] update output commit 7df3a1e0e8920447250692b14b8b21b2bb379957 Author: Julian Kosciessa Date: Wed Mar 4 12:11:42 2026 +0100 [doc] update outputs commit c1ddc1d3b159e83107704a5757249a31cafdb22f Author: Julian Kosciessa Date: Wed Mar 4 12:06:40 2026 +0100 [doc] mkdocs deployment commit b558560d4cd34c09e0e742d5c6a4f301410c930a Author: Julian Kosciessa Date: Wed Mar 4 12:00:38 2026 +0100 [doc] workflow commit d12d830104fe6abf4f2bc6dac53bdda75d265358 Author: Julian Kosciessa Date: Wed Mar 4 11:56:40 2026 +0100 [doc] minor commit e91abb0da6861bb9c6cacd58cc09c7603ed4f02e Author: Julian Kosciessa Date: Wed Mar 4 11:53:42 2026 +0100 [docs] automate pages commit 689cbecb3e5ecbe6a1f364116ac8b4d61e17c74a Author: Julian Kosciessa Date: Wed Mar 4 11:49:13 2026 +0100 [docs] automate pages commit f008edb1a3faf5b0a04689d34fc2a4de2148bdd0 Author: Julian Kosciessa Date: Wed Mar 4 11:42:30 2026 +0100 [doc] create mkdocs file commit 2e617dd3e08d07ced5f7b6de5c42942b9a7f8e96 Author: Julian Kosciessa Date: Wed Mar 4 10:45:48 2026 +0100 Merge branch 'development' of github.com:Donders-Institute/PRESTUS into development commit 903251973b45a6a62eaed6779be1dc5d0af93760 Author: Julian Kosciessa Date: Wed Mar 4 10:45:23 2026 +0100 [calibration] optimize functions commit f468f26d91475adadae2f5b7497074aac409ef36 Author: Julian Kosciessa Date: Tue Mar 3 18:37:57 2026 +0100 [calibration] introduce optional parameter for upper verlocity commit 3c15b8871a840e91b340408ef410f4ab78f97ae4 Author: Julian Kosciessa Date: Tue Mar 3 18:06:14 2026 +0100 [calibration] update settings and naming commit 35de8b1ff644201434140df61bb6e86502835244 Author: Julian Kosciessa Date: Tue Mar 3 17:04:55 2026 +0100 [distance] attempt to calculate transducer distance during load commit 6efa79705ddba0d8c226bd06caaf4bfc0e1d7d00 Author: Julian Kosciessa Date: Tue Mar 3 16:45:01 2026 +0100 [distance] fix geometric focal distance estimation does not rely on T1, needs to be informed by grid size, not dimensions of planning image commit 67352f188dde5b3a4a4c208497208d2264dbf427 Author: Julian Kosciessa Date: Tue Mar 3 16:17:44 2026 +0100 [param, calibration] separate focal distance from exit and bowl additional adjustments: commit 8de0a9a2acf9b79eee978792c5169727bede7a2f Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Thu Mar 26 16:29:40 2026 +0100 Snellius compatibility + validation bug fixes commit dc0bffed84f606ff52073fdf2a0b7420bcdb1bc4 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Tue Mar 24 17:18:08 2026 +0100 Update default_config.yaml commit 5ed986a3eae172b7701572cedd3f6d417e0b0f9c Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Tue Mar 24 17:17:25 2026 +0100 support HPC Snellius commit 8d70f750df30710883941d61930411cfd1fba439 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Tue Mar 24 16:26:09 2026 +0100 support matrix visualization commit 3f3b24dbb1ef10328070d83d166fb8a9f0614813 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Thu Mar 19 17:30:26 2026 +0100 additional plots & add matrix visualization commit 29d89a05c7b7514837a5d14b703161b99f6da182 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Thu Mar 19 14:18:46 2026 +0100 only plot when debug mode commit 0704072fa2ad561bb48430bf3e74c7a1a0519720 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Thu Mar 19 14:07:49 2026 +0100 Allow 3D steering for matrix transducers only commit c6a3a5d5387fdd948a92d5133a7fdede980a779a Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Thu Mar 19 13:47:30 2026 +0100 Clover source implementation commit ed6a46947d1f179eb788abee31f46b1d761b1259 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Fri Mar 13 15:59:02 2026 +0100 source implementation commit ef12b6a88da958ef49d7eff2eca1933070abeac5 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Fri Mar 13 11:51:40 2026 +0100 updated summary load_param + load_param write back commit be11b3026b56107135e0068ab72733dc24f76bd7 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Mon Mar 9 17:05:55 2026 +0100 prepare source_create for matrix impl. commit 9d7bdd8213f0a33ab056872af92984785cd03b78 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Mon Mar 9 12:09:10 2026 +0100 updated load transducer param commit 57d1a168671a8e295e33734883ad9a881c3722ba Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Fri Mar 6 17:36:49 2026 +0100 Initial updated load_parameters commit 2c2b9c96e178b165bf65f78d872182d6967c7643 Author: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Tue Mar 3 17:19:45 2026 +0100 Update tutorial_config.yaml --- configs/default_config.yaml | 101 +++++- configs/equipment/example_coord_file.xlsx | Bin 0 -> 12103 bytes configs/tutorial_config.yaml | 109 ++++++- documentation/doc_parameters.md | 85 ++++- documentation/doc_transducer.md | 129 +++++++- functions/acoustic/acoustic_analysis.m | 72 +++-- functions/core/load_parameters.m | 45 +-- functions/core/load_transducer_parameters.m | 165 ++++++++++ functions/core/prestus_pipeline_start.m | 2 +- functions/core/print_parameter_summary.m | 110 ++++++- functions/core/validate_annular_transducer.m | 64 ++++ functions/core/validate_matrix_transducer.m | 306 ++++++++++++++++++ functions/head/preproc_align_to_focal_axis.m | 10 +- functions/helper/log_timer.m | 6 +- functions/hpc/hpc_submit_job.m | 55 +++- functions/plot/plot_overlay.m | 24 +- functions/plot/plot_transducer_overlay.m | 27 +- functions/plot/show_3d_head.m | 24 +- functions/plot/show_binary_mask_transducer.m | 68 ++++ functions/source/create_clover_array.m | 182 +++++++++++ functions/source/create_matrix_karray.m | 191 +++++++++++ functions/source/grid_transducer_location.m | 12 +- functions/source/source_create.m | 147 ++++++--- functions/thermal/thermal_analysis.m | 39 ++- functions/transducer/convert_to_element_pos.m | 209 ++++++++++++ functions/transducer/extract_element_pos.m | 143 ++++++++ functions/transducer/get_arc.m | 2 +- functions/transducer/get_transducer_box.m | 56 +++- functions/transducer/transducer_setup.m | 115 +++++-- simple_main.m | 1 + 30 files changed, 2281 insertions(+), 218 deletions(-) mode change 100755 => 100644 configs/default_config.yaml create mode 100644 configs/equipment/example_coord_file.xlsx mode change 100755 => 100644 configs/tutorial_config.yaml create mode 100644 functions/core/load_transducer_parameters.m create mode 100644 functions/core/validate_annular_transducer.m create mode 100644 functions/core/validate_matrix_transducer.m create mode 100644 functions/plot/show_binary_mask_transducer.m create mode 100644 functions/source/create_clover_array.m create mode 100644 functions/source/create_matrix_karray.m create mode 100644 functions/transducer/convert_to_element_pos.m create mode 100644 functions/transducer/extract_element_pos.m diff --git a/configs/default_config.yaml b/configs/default_config.yaml old mode 100755 new mode 100644 index 0c1e7654..26ab32a9 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -69,8 +69,8 @@ modules: segmentation_only: 0 # Stop after segmentation (skip grid setup and all simulations)? 1=yes, 0=no #------------ High-performance computing - hpc: + name: 'default' # 'default' or 'snellius' gpu: '' partition: '' reservation: '' @@ -81,21 +81,104 @@ hpc: job_prefix: 'PRESTUS' # Prefix for HPC job names max_wait_checks: 540 # Maximum number of status checks when waiting for a job (1 check/20s ≈ 3 hours) -#------------ Target analysis +# Smallest possible allocation (https://servicedesk.surf.nl/wiki/spaces/WIKI/pages/30660209/Snellius+partitions+and+accounting) +snellius: + gpu_a100: + cores: 18 + memorylimit: 120 + timelimit: "120:00:00" # max wall time of 120 h + n_gpu: 1 + gpu_h100: + cores: 16 + memorylimit: 180 + timelimit: "120:00:00" # max wall time of 120 h + n_gpu: 1 +#------------ Target analysis analysis: focus_area_radius: 5 # Radius of the target area around the focus in which the ISPPA is averaged for the outputs [mm] #------------ Transducer specification (no defaults) transducer: - n_elements: [] # Number of elements in the transducer [#] - Elements_ID_mm: [] # Inner diameter of each element [mm] - Elements_OD_mm: [] # Outer diameter of each element [mm] - curv_radius_mm: [] # Radius of curvature of the bowl [mm] - dist_to_plane_mm: [] # Distance to the transducer plane from the geometric focus [mm] - source_amp: [] # Pressure amplitude [Pa] - source_phase_deg: [] # Source phase [deg] + # ------------------------------ + # General annular/matrix properties + # ------------------------------ + array_shape: + type: [] # 'annular' for Annular array or 'matrix' for Matrix array + + # -------------------------- + # Annular transducer parameters + # -------------------------- + annular: + n_elements: [] # Number of elements in the transducer [#] + Elements_ID_mm: [] # Inner diameter of each element [mm] + Elements_OD_mm: [] # Outer diameter of each element [mm] + curv_radius_mm: [] # Radius of curvature of the bowl [mm] + dist_to_plane_mm: [] # Distance from transducer plane to geometric focus [mm] + source_phase_deg: [] # Source phase of each element [deg] (0-360) + + # -------------------------- + # Matrix transducer parameters + # -------------------------- + matrix: + steering: [] # '1D' (Axial steering) or '3D' (Volumetric steering) + element_shape: [] # 'rect', 'disc', or 'bowl' - physical shape of each element + elem_height_mm: [] # Element height [mm] + elem_width_mm: [] # Element width [mm] + outer_diameter_mm: [] # Outer diameter of the transducer [mm] + is_curved: [] # true if transducer has a curved (spherical) surface, false if flat + curved: + curv_radius_mm: [] # Radius of curvature of the bowl [mm] + dist_to_plane_mm: [] # Distance from transducer plane to geometric focus [mm] + + # Optional Clover multi-aperture configuration + is_clover_setup: [] # true to enable Clover replication + clover: + n_leaves: [] # Number of leaves (1–3) + ROC_parent: [] # Radius of curvature of parent setup [mm] + + # -------------------------- + # Grid definition + # -------------------------- + matrix_shape: + type: [] # 'define_here' (define grid manually) or 'extract_from_file' (load positions from file) + + # If define_here is used: + define_here: + grid_shape: + type: [] # 'rect', 'fibonacci', 'fermat' - type of grid layout + + rect: + n_elem_row: [] # Number of rows in rectangular grid + n_elem_col: [] # Number of columns in rectangular grid + elem_spacing_height_mm: [] # Edge-to-edge spacing between rows [mm] + elem_spacing_width_mm: [] # Edge-to-edge spacing between columns [mm] + sparsity_factor: [] # Fraction of elements used (0 < value <= 1) + + fibonacci: + n_elements: [] # Total number of elements for Fibonacci grid + kerf_mm: [] # Minimum spacing between elements [mm] + + fermat: + n_elements: [] # Total number of elements for Fermat spiral grid + + # If extract_from_file is used: + extract_from_file: + file_path: [] # Path to file with element positions (x, y, z) [string] + start_row: [] # Row in file where element data starts + start_col: [] # Column in file where element data starts + n_elements: [] # Total number of elements to extract + select_random_subset: [] # true to randomly pick a subset + subset: + random_seed: [] # true to use a new seed each run, false to fix seed + subset_n_elements: [] # Number of elements in the subset + project_on_new_ROC: [] # true to project elements onto new ROC + ROC_projection: + new_ROC_mm: [] # New desired radius of curvature [mm] + + depth_mm: [] # Depth of transducer used for visualization purposes [mm] + source_amp: [] # Pressure amplitude [Pa] source_freq_hz: [] # Fundamental frequency [Hz] trans_pos: [] # Transducer position [T1 grid space] focus_pos: [] # Stimulation target position[T1 grid space] diff --git a/configs/equipment/example_coord_file.xlsx b/configs/equipment/example_coord_file.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ce6d5455dccfb925a25d2b6437c1fae0585637bc GIT binary patch literal 12103 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 t1j4=v0{zRn{ipc#y7*7=ah89G|EiMrl~91_f 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/functions/acoustic/acoustic_analysis.m b/functions/acoustic/acoustic_analysis.m index c96a1ae1..342465a8 100644 --- a/functions/acoustic/acoustic_analysis.m +++ b/functions/acoustic/acoustic_analysis.m @@ -171,17 +171,45 @@ tpos_sim = parameters.transducer(ti).trans_pos; fpos_sim = parameters.transducer(ti).focus_pos; end - - if numel(parameters.grid.dims)==3 - [~,~,~,~,~,~,~,h]=plot_overlay(... - acoustic_Ipa, ... - segmentation, ... - source_labels, ... - parameters, ... - {'y', fpos_sim(2)}, ... - tpos_sim, ... - fpos_sim, ... - highlighted_pos); + + trans_suffix = ''; + if n_plots > 1; trans_suffix = sprintf('_T%02d', ti); end + + if numel(parameters.grid.dims)==3 + slices = struct( ... + 'dim', {'x', 'y', 'z'}, ... + 'pos', {fpos_sim(1), fpos_sim(2), fpos_sim(3)} ... + ); + + for i = 1:numel(slices) + slice = slices(i); + + [~,~,~,~,~,~,~,h]=plot_overlay(... + acoustic_Ipa, ... + segmentation, ... + source_labels, ... + parameters, ... + {slice.dim, slice.pos}, ... + tpos_sim, ... + fpos_sim, ... + highlighted_pos); + + % Construct output filename + output_plot = fullfile( ... + parameters.io.output_dir, ... + sprintf('sub-%03d_%s_intensity%s_%s%s.png', ... + parameters.subject_id, ... + parameters.simulation.medium, ... + trans_suffix, ... + slice.dim, ... + parameters.io.output_affix) ... + ); + + % Keep original colors and save + set(h, 'InvertHardcopy', 'off'); + saveas(h, output_plot, 'png'); + close(h); + end else h = plot_overlay_2d(... acoustic_Ipa, ... @@ -191,14 +219,16 @@ tpos_sim, ... fpos_sim, ... highlighted_pos); + + output_plot = fullfile(parameters.io.output_dir, ... + sprintf('sub-%03d_%s_intensity%s_%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); end - - 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); - end \ No newline at end of file + end +end diff --git a/functions/core/load_parameters.m b/functions/core/load_parameters.m index a0548916..0cb738d7 100644 --- a/functions/core/load_parameters.m +++ b/functions/core/load_parameters.m @@ -55,50 +55,7 @@ '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'), ... - 'Source phase should be set in transducer parameters as source_phase_rad or source_phase_deg'); - parameters.transducer(t_i).source_phase_rad = parameters.transducer(t_i).source_phase_deg / 180 * pi; - end - - % Calculate distance to transducer plane if not provided - if ~isfield(parameters.transducer(t_i), 'dist_to_plane_mm') - parameters.transducer(t_i).dist_to_plane_mm = sqrt(parameters.transducer(t_i).curv_radius_mm^2 - ... - (max(parameters.transducer(t_i).Elements_OD_mm) / 2)^2); - fprintf('Distance to transducer plane is not provided, calculated as %.2f mm\n', ... - 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]); - end - - % Evaluate source phase expressions if stored as cell arrays - if iscell(parameters.transducer(t_i).source_phase_rad) - for i = 1:length(parameters.transducer(t_i).source_phase_rad) - if ~isnumeric(parameters.transducer(t_i).source_phase_rad{i}) - parameters.transducer(t_i).source_phase_rad{i} = eval(parameters.transducer(t_i).source_phase_rad{i}); - end - end - parameters.transducer(t_i).source_phase_rad = cell2mat(parameters.transducer(t_i).source_phase_rad); - end - - % Ensure source phase degrees are calculated from radians if not provided - if ~isfield(parameters.transducer(t_i), 'source_phase_deg') - parameters.transducer(t_i).source_phase_deg = parameters.transducer(t_i).source_phase_rad / pi * 180; - end - - end + parameters = load_transducer_parameters(parameters); %% Validate thermal simulation settings % Request no timing overview here diff --git a/functions/core/load_transducer_parameters.m b/functions/core/load_transducer_parameters.m new file mode 100644 index 00000000..0a7f88f0 --- /dev/null +++ b/functions/core/load_transducer_parameters.m @@ -0,0 +1,165 @@ +function parameters = load_transducer_parameters(parameters) + +% LOAD_TRANSDUCER_PARAMETERS Validates and normalizes transducer parameters. +% +% This function processes the 'transducer' field of the input parameters struct. +% It validates required fields, ensures backward compatibility with legacy +% annular-only configurations, and normalizes parameters into a consistent format +% for simulation use. For matrix transducers, it validates element geometry, +% curvature, steering, grid definitions, and optional Clover or sparse grid setups. +% For annular transducers, it validates element diameters, curvature, and phases. +% The function also calculates derived values such as source phases in radians, +% element counts, and distances to the transducer plane. +% +% Input: +% parameters - Struct containing simulation parameters, expected to include +% a 'transducer' field with one or more transducer definitions. +% +% Output: +% parameters - Struct with validated and normalized transducer parameters, +% including additional derived fields such as: +% * n_elements, Elements_ID_mm, Elements_OD_mm +% * source_phase_rad, source_phase_deg +% * curv_radius_mm, dist_to_plane_mm +% * n_elem_row, n_elem_col (for matrix grids) +% * sparsity_factor, clover setup fields, etc. + + if isfield(parameters, 'transducer') + + new_transducers = struct([]); + + for t_i = 1:numel(parameters.transducer) + + tr = parameters.transducer(t_i); + + % --------------------------------------------------------------------- + % Validate and initialize transducer parameters + % --------------------------------------------------------------------- + + % Detect legacy configurations where only annular transducers are defined + if ~isfield(tr, 'array_shape') || isempty(tr.array_shape.type) + + % Create a clean structure for the new format + new_tr = struct(); + new_tr.array_shape.type = 'annular'; + new_tr.array_shape.annular = struct(); % initialize annular sub-struct; + + if isfield(tr, 'n_elements') + new_tr.array_shape.annular.n_elements = tr.n_elements; + end + + if isfield(tr, 'Elements_ID_mm') + new_tr.array_shape.annular.Elements_ID_mm = tr.Elements_ID_mm; + end + + if isfield(tr, 'Elements_OD_mm') + new_tr.array_shape.annular.Elements_OD_mm = tr.Elements_OD_mm; + end + + if isfield(tr, 'curv_radius_mm') + new_tr.array_shape.annular.curv_radius_mm = tr.curv_radius_mm; + end + + if isfield(tr, 'dist_to_plane_mm') + new_tr.array_shape.annular.dist_to_plane_mm = tr.dist_to_plane_mm; + end + + if isfield(tr, 'source_amp') + new_tr.source_amp = tr.source_amp; + end + + if isfield(tr, 'source_phase_deg') + new_tr.source_phase_deg = tr.source_phase_deg; + end + + if isfield(tr, 'source_freq_hz') + new_tr.source_freq_hz = tr.source_freq_hz; + end + + if isfield(tr, 'trans_pos') + new_tr.trans_pos = tr.trans_pos; + end + + if isfield(tr, 'focus_pos') + new_tr.focus_pos = tr.focus_pos; + end + + % Replace old transducer completely + tr = new_tr; + end + + % Supported transducer geometries: matrix and annular arrays + + % Ensure the array_shape.type field is defined + assert(isfield(tr.array_shape, 'type'),... + 'Transducer %i; Missing type field. Please specify either "matrix" or "annular".', t_i); + + switch tr.array_shape.type + + case 'matrix' + [parameters, tr] = validate_matrix_transducer(parameters, tr, t_i); + + case 'annular' + tr = validate_annular_transducer(tr, t_i); + + otherwise + error('Transducer %i; Element shape option "%s" is not implemented.', ... + tr.array_shape.type); + end + + % Validate general parameters + % Ensure source phase is set in radians or degrees + if ~isfield(tr, 'source_phase_rad') && isfield(tr, 'source_phase_deg') + tr.source_phase_rad = deg2rad(tr.source_phase_deg); + elseif ~isfield(tr, 'source_phase_rad') + error('Transducer %i; Phase must be specified as source_phase_rad or source_phase_deg.', t_i); + end + + assert(isfield(tr,'source_amp'), ... + 'Transducer %i; Missing source_amp field.', t_i); + + % Ensure source amplitude matches number of transducer elements + if numel(tr.source_amp) == 1 && tr.n_elements > 1 + tr.source_amp = repmat(tr.source_amp, [1, tr.n_elements]); + end + + % Evaluate source phase expressions if stored as cell arrays + if iscell(tr.source_phase_rad) + for p_i = 1:numel(tr.source_phase_rad) + if ~isnumeric(tr.source_phase_rad{p_i}) + tr.source_phase_rad{p_i} = eval(tr.source_phase_rad{p_i}); + end + end + tr.source_phase_rad = cell2mat(tr.source_phase_rad); + end + + % Ensure source phase degrees are calculated from radians if not provided + if ~isfield(tr, 'source_phase_deg') + tr.source_phase_deg = rad2deg(tr.source_phase_rad); + end + + if ~isfield(tr, 'depth_mm') + tr.depth_mm = 16; + end + + if t_i == 1 + new_transducers = tr; + else + new_transducers(t_i) = tr; + end + end + + parameters.transducer = new_transducers; + + % 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 + + else + % Warn user about missing transducer information + assert(confirmation_dlg('The transducer info is missing in the configuration file. Do you want to continue?', 'Yes', 'No'), ... + 'Exiting'); + end + +end \ No newline at end of file diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index 0db5bcf0..75e24ac5 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -71,7 +71,7 @@ function prestus_pipeline_start(parameters, options) job_name = hpc_job_name(parameters); % Submit job - job_id = hpc_submit_job(platform, temp_m_file, parameters, log_dir); + [job_id, parameters] = hpc_submit_job(platform, temp_m_file, parameters, log_dir); % Display job info job_info = hpc_job_info(platform, job_id, job_name, ... diff --git a/functions/core/print_parameter_summary.m b/functions/core/print_parameter_summary.m index 6cd005d3..d4500687 100644 --- a/functions/core/print_parameter_summary.m +++ b/functions/core/print_parameter_summary.m @@ -47,22 +47,104 @@ function print_parameter_summary(parameters) fprintf('\n'); %% 4. Transducer Specification - fprintf('🎯 TRANSDUCER SPECIFICATION\n'); -if isfield(parameters, 'transducer') && ~isempty(parameters.transducer) - td = parameters.transducer(1); -else - td = struct(); + +% Loop over all transducers (support multiple transducers) +for t_i = 1:numel(parameters.transducer) + tr = parameters.transducer(t_i); + + % Determine type + tr_type = tr.array_shape.type; + + fprintf('Transducer %d (%s):\n', t_i, tr_type); + + switch tr_type + case 'annular' + ann = tr.array_shape.annular; + + print_if_field(ann, 'n_elements', '%d'); + print_if_field(ann, 'curv_radius_mm', '%.0f mm'); + print_if_field(ann, 'source_phase_deg', '%.1f deg.'); + + case 'matrix' + mat = tr.array_shape.matrix; + + fprintf(' Steering: %s\n', mat.steering); + fprintf(' Element shape: %s\n', mat.element_shape); + print_if_field(mat, 'elem_height_mm', '%.3f mm'); + print_if_field(mat, 'elem_width_mm', '%.3f mm'); + print_if_field(mat, 'outer_diameter_mm', '%.2f mm'); + print_if_field(mat, 'is_curved', '%d'); + + if mat.is_curved && isfield(mat, 'curved') + print_if_field(mat.curved, 'curv_radius_mm', '%.2f mm'); + end + + % Clover setup + if isfield(mat, 'is_clover_setup') && mat.is_clover_setup + fprintf(' Clover setup enabled:\n'); + print_if_field(mat.clover, 'n_leaves', '%d'); + print_if_field(mat.clover, 'ROC_parent', '%.2f mm'); + end + + % Matrix shape type + shape_type = mat.matrix_shape.type; + fprintf(' Matrix shape type: %s\n', shape_type); + + switch shape_type + case 'define_here' + define_here = mat.matrix_shape.define_here; + grid_type = define_here.grid_shape.type; + fprintf(' Grid type: %s\n', grid_type); + + switch grid_type + case 'rect' + rect_grid = define_here.grid_shape.rect; + fprintf(' Rectangular grid:\n'); + print_if_field(rect_grid, 'n_elem_row', '%d'); + print_if_field(rect_grid, 'n_elem_col', '%d'); + print_if_field(rect_grid, 'elem_spacing_height_mm', '%.2f mm'); + print_if_field(rect_grid, 'elem_spacing_width_mm', '%.2f mm'); + print_if_field(rect_grid, 'sparsity_factor', '%.2f'); + + case 'fibonacci' + fib_grid = define_here.grid_shape.fibonacci; + fprintf(' Fibonacci grid:\n'); + print_if_field(fib_grid, 'n_elements', '%d'); + + case 'fermat' + fermat_grid = define_here.grid_shape.fermat; + fprintf(' Fermat spiral grid:\n'); + print_if_field(fermat_grid, 'n_elements', '%d'); + end + + case 'extract_from_file' + ext = mat.matrix_shape.extract_from_file; + print_if_field(ext, 'file_path', '%s'); + print_if_field(ext, 'start_row', '%d'); + print_if_field(ext, 'start_col', '%d'); + print_if_field(ext, 'n_elements', '%d'); + print_if_field(ext, 'select_random_subset', '%d'); + if ext.select_random_subset + print_if_field(ext.subset, 'random_seed', '%d'); + print_if_field(ext.subset, 'subset_n_elements', '%d'); + end + print_if_field(ext, 'project_on_new_ROC', '%d'); + if ext.project_on_new_ROC + print_if_field(ext.ROC_projection, 'new_ROC_mm', '%.2f mm'); + end + end + end + + print_if_field(tr, 'source_freq_hz', '%.1f Hz'); + print_if_field(tr, 'source_amp', '%.1f Pa'); + print_if_field(tr, 'trans_pos', '[%.1f %.1f %.1f]'); + print_if_field(tr, 'focus_pos', '[%.1f %.1f %.1f]'); + print_if_field(parameters, 'expected_focal_distance_ep', '%.1f mm'); + print_if_field(parameters, 'expected_focal_distance_bowl', '%.1f mm'); + + fprintf('\n'); 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(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) diff --git a/functions/core/validate_annular_transducer.m b/functions/core/validate_annular_transducer.m new file mode 100644 index 00000000..20132653 --- /dev/null +++ b/functions/core/validate_annular_transducer.m @@ -0,0 +1,64 @@ +function tr = validate_annular_transducer(tr, t_i) +% VALIDATE_ANNULAR_TRANSDUCER Validates configuration of an annular transducer. +% +% This function checks that all required fields for an annular transducer +% definition are present in the input transducer structure. +% +% Input: +% tr - Struct containing a single transducer definition. +% t_i - Index of the transducer in the configuration (used for error messages). +% +% Output: +% (None) + + assert(isfield(tr.array_shape, 'annular'), ... + 'Transducer %i; Appropriate configuration for annular transducer is missing.', t_i); + + annular_tr = tr.array_shape.annular; + + % Validate required geometric parameters + assert(isfield(annular_tr, 'Elements_ID_mm'), ... + 'Transducer %i; Missing Elements_ID_mm for annular transducer. This defines inner diameters of elements.', t_i); + + assert(isfield(annular_tr, 'Elements_OD_mm'), ... + 'Transducer %i; Missing Elements_OD_mm for annular transducer. This defines outer diameters of elements.', t_i); + + assert(isfield(annular_tr, 'n_elements'), ... + 'Transducer %i; Missing n_elements parameter for annular transducer. This defines element count.', t_i); + + tr.n_elements = annular_tr.n_elements; + + assert(numel(annular_tr.Elements_ID_mm) == annular_tr.n_elements, ... + 'Transducer %i; Elements_ID_mm length must match n_elements.', t_i); + + assert(numel(annular_tr.Elements_OD_mm) == annular_tr.n_elements, ... + 'Transducer %i; Elements_OD_mm length must match n_elements.', t_i); + + % Validate inner/outer diameter ordering + assert(all(annular_tr.Elements_OD_mm > annular_tr.Elements_ID_mm), ... + 'Transducer %i; Outer diameter must be larger than inner diameter for all elements.', t_i); + + % Validate curvature definition + assert(isfield(annular_tr, 'curv_radius_mm'), ... + 'Transducer %i; Missing curv_radius_mm field for annular transducer. Please specify radius of curvature.', t_i); + + tr.curv_radius_mm = annular_tr.curv_radius_mm; + + % 3D steering unavailable for annular arrays → align with focus + tr.align_transducer_with_focus = true; + + % Calculate distance to transducer plane if not provided + if ~isfield(tr, 'dist_to_plane_mm') + assert(annular_tr.curv_radius_mm > max(annular_tr.Elements_OD_mm)/2, ... + 'Transducer %i; curv_radius_mm must exceed aperture radius.', t_i); + + tr.dist_to_plane_mm = sqrt(annular_tr.curv_radius_mm^2 - ... + (max(annular_tr.Elements_OD_mm) / 2)^2); + + fprintf('Transducer %i; Distance to transducer plane is not provided, calculated as %.2f mm\n', ... + t_i, tr.dist_to_plane_mm); + else + tr.dist_to_plane_mm = annular_tr.dist_to_plane_mm; + end + +end \ No newline at end of file diff --git a/functions/core/validate_matrix_transducer.m b/functions/core/validate_matrix_transducer.m new file mode 100644 index 00000000..c9388dda --- /dev/null +++ b/functions/core/validate_matrix_transducer.m @@ -0,0 +1,306 @@ +function [parameters, tr] = validate_matrix_transducer(parameters, tr, t_i) +% VALIDATE_MATRIX_TRANSDUCER Validates configuration of a matrix transducer. +% +% This function checks that all required fields for a matrix transducer +% definition are present in the input transducer structure. + +% The function also supports optional configurations such as Clover array +% layouts, sparse Fibonacci grids, random element subsets, and projection +% of element positions onto a new radius of curvature. +% +% After validation, the relevant parameters are copied to standardized +% fields used throughout the simulation pipeline. Additional derived values +% such as the number of elements and equivalent annular element diameters +% (for visualization compatibility) are calculated. +% +% Input: +% parameters - Struct containing global simulation parameters. +% tr - Struct containing a single transducer definition. +% t_i - Index of the transducer in the configuration (used for +% informative error messages). +% +% Output: +% parameters - Updated parameters struct (e.g., enabling kWaveArray). +% tr - Transducer struct with validated and normalized fields, +% including geometry, grid configuration, and derived values. + + matrix_tr = tr.array_shape.matrix; + + fprintf('Matrix transducer detected. Using kWaveArray (set to 1).\n'); + parameters.use_kWaveArray = 1; + + % --------------------------------------------------------------------- + % Steering configuration + % Determines whether the array orientation follows the acoustic focus. + % --------------------------------------------------------------------- + assert(isfield(matrix_tr, 'steering'), ... + 'Transducer %i; Missing steering definition. Choose "1D" or "3D".', t_i); + + switch matrix_tr.steering + case '1D' + tr.align_transducer_with_focus = true; + case '3D' + tr.align_transducer_with_focus = false; + otherwise + error('Transducer %i; Steering option "%s" is not implemented.', ... + t_i, matrix_tr.steering); + end + + % --------------------------------------------------------------------- + % Element geometry + % Defines the physical dimensions of individual matrix elements. + % --------------------------------------------------------------------- + assert(isfield(matrix_tr, 'element_shape'), ... + 'Transducer %i; Missing element_shape field for matrix array. Please specify "rect", "disc" or "bowl".', t_i); + + assert(isfield(matrix_tr, 'elem_height_mm'), ... + 'Transducer %i; Missing elem_height parameter. Please specify to define height of each element.', t_i); + + assert(isfield(matrix_tr, 'elem_width_mm'), ... + 'Transducer %i; Missing elem_width parameter. Please specify to define width of each element.', t_i); + + % Validate outer diameter + assert(isfield(matrix_tr, 'outer_diameter_mm'), ... + 'Transducer %i; Missing outer_diameter_mm parameter for matrix array. Please specify to define overall size.', t_i); + + assert(matrix_tr.outer_diameter_mm > 0) + + % --------------------------------------------------------------------- + % Curvature definition + % Determines whether the array lies on a spherical surface. + % --------------------------------------------------------------------- + assert(isfield(matrix_tr, 'is_curved'), ... + 'Transducer %i; Missing is_curved field for matrix transducer. Please specify.', t_i); + + if matrix_tr.is_curved + assert(isfield(matrix_tr.curved, 'curv_radius_mm'), ... + 'Transducer %i; Missing curv_radius_mm field for matrix transducer. Please specify radius of curvature.', t_i); + + % Calculate distance to transducer plane if not provided + if ~isfield(matrix_tr.curved, 'dist_to_plane_mm') + assert(matrix_tr.curved.curv_radius_mm > matrix_tr.outer_diameter_mm/2, ... + 'Transducer %i; curv_radius_mm must exceed aperture radius.', t_i); + + tr.dist_to_plane_mm = sqrt(matrix_tr.curved.curv_radius_mm^2 - ... + (matrix_tr.outer_diameter_mm / 2)^2); + + fprintf('Transducer %i; Distance to transducer plane is not provided, calculated as %.2f mm\n', ... + t_i, matrix_tr.curved.dist_to_plane_mm); + else + tr.dist_to_plane_mm = matrix_tr.curved.dist_to_plane_mm; + end + + + else + matrix_tr.curved.curv_radius_mm = inf; + + % For a flat transducer the distance to the focal plane approaches + % infinity. A finite value is assigned here for visualization purposes. + tr.dist_to_plane_mm = 70; + end + + tr.curv_radius_mm = matrix_tr.curved.curv_radius_mm; + + % --------------------------------------------------------------------- + % Optional Clover multi-aperture configuration + % --------------------------------------------------------------------- + if isfield(matrix_tr, 'is_clover_setup') + + if matrix_tr.is_clover_setup + assert(isfield(matrix_tr, 'clover'), ... + 'Transducer %i; Missing additional information of Clover setup.', t_i); + + assert(isfield(matrix_tr.clover, 'n_leaves'), ... + 'Transducer %i; Missing additional information of Clover setup. Define number of leaves.', t_i); + + assert(isfield(matrix_tr.clover, 'ROC_parent'), ... + 'Transducer %i; Missing additional information of Clover setup. Define ROC of parent.', t_i); + end + else + % Clover array configuration not provided; skip Clover setup + matrix_tr.is_clover_setup = false; + end + + % --------------------------------------------------------------------- + % Matrix grid definition + % Elements can be defined directly or extracted from an external file. + % --------------------------------------------------------------------- + assert(isfield(matrix_tr.matrix_shape, 'type'), ... + 'Transducer %i; Missing matrix_shape type field for matrix transducer. Please specify "define_here" or "extract_from_file"', t_i); + + switch matrix_tr.matrix_shape.type + case 'define_here' + assert(isfield(matrix_tr.matrix_shape, 'define_here'), ... + 'Transducer %i; Missing define_here field and properties for matrix transducer. Please specify.', t_i); + + define_shape_here = matrix_tr.matrix_shape.define_here; + + assert(isfield(define_shape_here, 'grid_shape'), ... + 'Transducer %i; Missing grid_shape field and properties for matrix transducer. Please specify.', t_i); + + grid_shape = define_shape_here.grid_shape; + + assert(isfield(grid_shape, 'type'), ... + 'Transducer %i; Missing grid_shape type field and properties for matrix transducer. Please specify.', t_i); + + switch grid_shape.type + case 'rect' + assert(isfield(grid_shape, 'rect'), ... + 'Transducer %i; Missing rect field in grid_shape for rectangular grid configuration.', t_i); + + rect_grid = grid_shape.rect; + + % Validate required parameters for grid + assert(isfield(rect_grid, 'n_elem_row'), ... + 'Transducer %i;: Missing n_elem_row parameter for grid. Please specify to define number of rows.', t_i); + + assert(isfield(rect_grid, 'n_elem_col'), ... + 'Transducer %i; Missing n_elem_col parameter for grid. Please specify to define number of columns.', t_i); + + assert(isfield(rect_grid, 'elem_spacing_height_mm'), ... + 'Transducer %i; Missing elem_spacing_height parameter for grid. Please specify to define height spacing between elements.', t_i); + + assert(isfield(rect_grid, 'elem_spacing_width_mm'), ... + 'Transducer %i; Missing elem_spacing_width parameter for grid. Please specify to define width spacing between elements.', t_i); + + % Validate spacing positivity + assert(rect_grid.elem_spacing_height_mm > 0) + assert(rect_grid.elem_spacing_width_mm > 0) + + % Total physical span of the array + tran_width = (rect_grid.n_elem_width * matrix_tr.elem_width_mm) + ... + ((rect_grid.n_elem_width - 1) * rect_grid.elem_spacing_width_mm); + + tran_height = (rect_grid.n_elem_height * matrix_tr.elem_height_mm) + ... + ((rect_grid.n_elem_height - 1) * rect_grid.elem_spacing_height_mm); + + % Distance of the array side from the center + grid_radius = max(tran_width/2, tran_height/2); + + aperture_radius = matrix_tr.outer_diameter_mm / 2; + + assert(grid_radius <= aperture_radius, ... + ['Transducer %i; Rectangular grid exceeds outer_diameter_mm. ' ... + 'Grid span: %.2f x %.2f mm, aperture diameter: %.2f mm.'], ... + t_i, tran_width, tran_height, matrix_tr.outer_diameter_mm); + + assert(isfield(rect_grid, 'sparsity_factor'), ... + 'Transducer %i; Missing sparsity_factor parameter for grid. Please specify to define % of used elements.', t_i); + + assert(rect_grid.sparsity_factor > 0 && rect_grid.sparsity_factor <= 1) + + % Extract grid dimensions + n_elem_row = rect_grid.n_elem_row; + n_elem_col = rect_grid.n_elem_col; + + % Calculate initial element count (will be adjusted later for circular cutout) + tr.n_elements = n_elem_col * n_elem_row; + + case 'fibonacci' + % Sparse spiral grid configuration + + % Validate required parameters for sparser grid + assert(isfield(grid_shape, 'fibonacci'), ... + 'Transducer %i; Missing fibonacci field in grid_shape for sparser grid configuration.', t_i); + + assert(isfield(grid_shape.fibonacci, 'n_elements'), ... + 'Transducer %i; Missing n_elements parameter for grid. Please specify to define number of elements.', t_i); + + assert(isfield(grid_shape.fibonacci, 'kerf_mm'), ... + 'Transducer %i; Missing kerf_mm parameter for grid. Please specify.', t_i); + + tr.n_elements = grid_shape.fibonacci.n_elements; + + case 'fermat' + % Sparse spiral grid configuration + + % Validate required parameters for sparser grid + assert(isfield(grid_shape, 'fermat'), ... + 'Transducer %i; Missing fermat field in grid_shape for sparser grid configuration.', t_i); + + assert(isfield(grid_shape.fermat, 'n_elements'), ... + 'Transducer %i; Missing n_elements parameter for grid. Please specify to define number of elements.', t_i); + + tr.n_elements = grid_shape.fermat.n_elements; + + otherwise + error('Transducer %i; Grid shape type "%s" is not implemented.', ... + t_i, grid_shape.type); + end + + case 'extract_from_file' + assert(isfield(matrix_tr.matrix_shape, 'extract_from_file'), ... + 'Transducer %i; Missing extract_from_file field and properties for matrix transducer. Please specify.', t_i); + + extract_shape_from_file = matrix_tr.matrix_shape.extract_from_file; + + assert(isfield(extract_shape_from_file, 'file_path'), ... + 'Transducer %i; Missing file_path to extract element positions from. Please specify.', t_i); + + file_path = extract_shape_from_file.file_path; + + resolved_path = which(file_path); + + assert(~isempty(resolved_path), ... + 'Transducer %i; File "%s" does not exist or is not on the MATLAB path.', t_i, file_path); + + extract_shape_from_file.file_path = resolved_path; + + assert(isfield(extract_shape_from_file, 'start_row'), ... + 'Transducer %i; Missing start_row parameter. Please specify start row of element positions.', t_i); + + assert(isfield(extract_shape_from_file, 'start_col'), ... + 'Transducer %i; Missing start_col parameter. Please specify start column of element positions.', t_i); + + assert(isfield(extract_shape_from_file, 'n_elements'), ... + 'Transducer %i; Missing n_elements. Please specify number of elements.', t_i); + + tr.n_elements = extract_shape_from_file.n_elements; + + if isfield(extract_shape_from_file, 'select_random_subset') + + if extract_shape_from_file.select_random_subset + assert(isfield(extract_shape_from_file, 'subset'), ... + 'Transducer %i; Missing subset parameters. Please specify subset properties.', t_i); + + assert(isfield(extract_shape_from_file.subset, 'random_seed'), ... + 'Transducer %i; Missing random_seed parameter. Please specify if a random subseed for subset selection is desired.', t_i); + + assert(isfield(extract_shape_from_file.subset, 'subset_n_elements'), ... + 'Transducer %i; Missing subset_n_element parameter. Please specify subset size.', t_i); + + end + else + % Random element subset not specified; use full element set + extract_shape_from_file.select_random_subset = false; + end + + if isfield(extract_shape_from_file, 'project_on_new_ROC') + + if extract_shape_from_file.project_on_new_ROC + assert(isfield(extract_shape_from_file, 'ROC_projection'), ... + 'Transducer %i; Missing ROC_projection parameters. Please specify to define ROC to project on.', t_i); + + assert(isfield(extract_shape_from_file.ROC_projection, 'new_ROC_mm'), ... + 'Transducer %i; Missing new_ROC_mm parameter. Please specify new desired ROC.', t_i); + + end + else + % No projection configuration provided; element positions remain unchanged + extract_shape_from_file.project_on_new_ROC = false; + end + + matrix_tr.matrix_shape.extract_from_file = extract_shape_from_file; + + otherwise + error('Transducer %i; Matrix shape option "%s" is not implemented.', ... + t_i, matrix_tr.matrix_shape.type); + end + + + % Initialize element phases to zero degrees by default. + % Phase delays are later adjusted based on the defined focus. + tr.source_phase_deg = repmat(0, [1, tr.n_elements]); + + tr.array_shape.matrix = matrix_tr; +end \ No newline at end of file diff --git a/functions/head/preproc_align_to_focal_axis.m b/functions/head/preproc_align_to_focal_axis.m index 236fefab..5b30fc1f 100644 --- a/functions/head/preproc_align_to_focal_axis.m +++ b/functions/head/preproc_align_to_focal_axis.m @@ -55,7 +55,15 @@ %% 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]'; + % [Multi-transducer] the preprocessing will be based on the first transducer + if parameters.transducer(1).align_transducer_with_focus + focal_axis = [focus_pos_grid - trans_pos_grid, 1]'; + else + % Use the natural focus based on transducer curvature + curv_radius = parameters.transducer(1).array_shape.matrix.curv_radius_mm; + natural_focus = trans_pos_grid + [0, 0, curv_radius / parameters.grid_step_mm]; + focal_axis = [natural_focus - trans_pos_grid, 1]'; + end %% Step 2: Compute rotation angles % Rotate around the y-axis first to align focal axis with z-axis projection in x-z plane diff --git a/functions/helper/log_timer.m b/functions/helper/log_timer.m index c54d4dfe..a8024181 100644 --- a/functions/helper/log_timer.m +++ b/functions/helper/log_timer.m @@ -75,8 +75,10 @@ free_bytes = str2double(strtrim(out_free)); elseif ispc drive = monitor_path(1); - [~, out_used] = system(sprintf('powershell -c "(Get-PSDrive ''%c'').Used"', drive)); - [~, out_free] = system(sprintf('powershell -c "(Get-PSDrive ''%c'').Free"', drive)); + driveLetter = char(drive); + driveLetter = driveLetter(1); + [~, out_used] = system(sprintf('powershell -c "(Get-PSDrive ''%c'').Used"', driveLetter)); + [~, out_free] = system(sprintf('powershell -c "(Get-PSDrive ''%c'').Free"', driveLetter)); used_bytes = str2double(strtrim(out_used)); free_bytes = str2double(strtrim(out_free)); else diff --git a/functions/hpc/hpc_submit_job.m b/functions/hpc/hpc_submit_job.m index 462a1896..d823818f 100644 --- a/functions/hpc/hpc_submit_job.m +++ b/functions/hpc/hpc_submit_job.m @@ -1,4 +1,4 @@ -function job_id = hpc_submit_job(hpc_type, temp_m_file, parameters, log_dir) +function [job_id, parameters] = 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 @@ -21,6 +21,17 @@ switch hpc_type case 'slurm' temp_slurm_path = fullfile(log_dir, sprintf('temp_slurm_%s.sh', datestr(now, 'yyyymmdd_HHMMSS'))); + + % Define HPC type + if ~isfield(parameters.hpc, 'name') + parameters.hpc.name = 'default'; + end + + % Overwrite and extract additional parameters if Snellius HPC + if strcmp(parameters.hpc.name, 'snellius') + [parameters] = extract_snellius_parameters(parameters); + end + write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir); job_id = submit_slurm_job(temp_slurm_path, log_dir); @@ -39,7 +50,7 @@ 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); @@ -53,7 +64,9 @@ function write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir) fprintf(fid, '#SBATCH --partition=gpu\n'); end - if isfield(parameters.hpc, 'gpu') && ~isempty(strtrim(char(parameters.hpc.gpu))) + if strcmp(parameters.hpc.name, 'snellius') + fprintf(fid, '#SBATCH --gpus=%i\n', parameters.hpc.n_gpu); + elseif 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'); @@ -63,6 +76,10 @@ function write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir) fprintf(fid, '#SBATCH --reservation=%s\n', strtrim(char(parameters.hpc.reservation))); end + if strcmp(parameters.hpc.name, 'snellius') + fprintf(fid, '#SBATCH --cpus-per-task=%i\n', parameters.hpc.cores); + 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); @@ -70,11 +87,39 @@ function write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir) fprintf(fid, '#SBATCH --chdir=%s\n', log_dir); if needs_gpu, fprintf(fid, 'nvidia-smi\n'); end - fprintf(fid, 'module load matlab/R2023b\n'); + if strcmp(parameters.hpc.name, 'snellius') + fprintf(fid, 'module load 2024\n'); + fprintf(fid, 'module load MATLAB/2024b\n'); + else + fprintf(fid, 'module load matlab/R2023b\n'); + end fprintf(fid, 'matlab -batch "%s"\n', temp_m_file); fclose(fid); end +function [parameters] = extract_snellius_parameters(parameters) + + switch parameters.hpc.partition + case 'gpu_a100' + parameters.hpc.memorylimit = parameters.snellius.gpu_a100.memorylimit; + parameters.hpc.cores = parameters.snellius.gpu_a100.cores; + max_timelimit = parameters.snellius.gpu_a100.timelimit; + parameters.hpc.n_gpu = parameters.snellius.gpu_a100.n_gpu; + case 'gpu_h100' + parameters.hpc.memorylimit = parameters.snellius.gpu_h100.memorylimit; + parameters.hpc.cores = parameters.snellius.gpu_h100.cores; + max_timelimit = parameters.snellius.gpu_h100.timelimit; + parameters.hpc.n_gpu = parameters.snellius.gpu_h100.n_gpu; + otherwise + error('GPU %s is unknown or not implemented for Snellius.', parameters.hpc.partition) + end + + assert(parameters.hpc.timelimit <= max_timelimit, ... + 'Maximum wall time of %s is exceeded (%s).', ... + max_timelimit, ... + parameters.hpc.timelimit) +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); @@ -100,7 +145,7 @@ function write_qsub_script(temp_qsub_path, parameters, temp_m_file, log_dir) 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, 'module load matlab/R2023b\n'); fprintf(fid, 'matlab -batch "%s"\n', temp_m_file); fclose(fid); end diff --git a/functions/plot/plot_overlay.m b/functions/plot/plot_overlay.m index 57793bbe..87507b37 100644 --- a/functions/plot/plot_overlay.m +++ b/functions/plot/plot_overlay.m @@ -99,6 +99,7 @@ end if ~isempty(trans_pos) trans_pos = trans_pos(2:3); + natural_focus = trans_pos + [0, parameters.transducer(1).curv_radius_mm / parameters.grid.resolution_mm]; end max_data_pos = max_data_pos(2:3); elseif slice{1} == 'y' @@ -108,15 +109,17 @@ end if ~isempty(trans_pos) trans_pos = trans_pos([1,3]); + natural_focus = trans_pos + [0, parameters.transducer(1).curv_radius_mm / parameters.grid.resolution_mm]; end max_data_pos = max_data_pos([1,3]); elseif slice{1} == 'z' slice_z = slice{2}; if ~isempty(focus_pos) - focus_pos = focus_pos(1:2); + focus_pos = focus_pos(1:2); end if ~isempty(trans_pos) - trans_pos = trans_pos([1,2]); + trans_pos = trans_pos([1,2]); + natural_focus = trans_pos + [0, 0]; end max_data_pos = max_data_pos([1,2]); else @@ -158,10 +161,11 @@ if options.rotation R = [cosd(options.rotation) -sind(options.rotation); sind(options.rotation) cosd(options.rotation)]; if ~isempty(focus_pos) - focus_pos = round(R*double(focus_pos')); + focus_pos = round(R*double(focus_pos')); end if ~isempty(trans_pos) trans_pos = round(R*double(trans_pos')); + natural_focus = round(R*double(natural_focus')); end max_data_pos = round(R*double(max_data_pos')); overlay_image = imrotate(overlay_image, options.rotation); @@ -189,7 +193,7 @@ % 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]) + plot_transducer_overlay(parameters, trans_pos, focus_pos, natural_focus, max_data_pos, options, 0.3, [0.2 0.6 1]) end if options.overlay_segmented @@ -253,7 +257,17 @@ end ax2.Position = ax1.Position; - ax2_colour = ax1.Children(length(ax1.Children)).CData(1,1,:); + + child = ax1.Children(end); + + if isa(child, 'matlab.graphics.primitive.Image') + ax2_colour = child.CData(1,1,:); + elseif isa(child, 'matlab.graphics.chart.primitive.Line') + ax2_colour = child.Color; + else + error('Unknown child type'); + end + if any(ax2_colour > 1) ax2_colour = zeros(size(ax2_colour)); end diff --git a/functions/plot/plot_transducer_overlay.m b/functions/plot/plot_transducer_overlay.m index 31d38286..cb1d76d3 100644 --- a/functions/plot/plot_transducer_overlay.m +++ b/functions/plot/plot_transducer_overlay.m @@ -1,10 +1,11 @@ -function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, options, overlay_weight, overlay_color) +function plot_transducer_overlay(parameters, trans_pos, focus_pos, natural_foc, 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) +% natural_foc - [2x1] Natural 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 @@ -15,11 +16,29 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, end %% 1. GEOMETRY CALCULATIONS (curved transducer details) -focal_slope = (trans_pos(:) - focus_pos(:)) / norm(trans_pos(:) - focus_pos(:)); +%% Compute focal slope +% Calculate unit vector pointing from focus to transducer +if parameters.transducer(1).align_transducer_with_focus + focal_slope = (trans_pos(:) - focus_pos(:)) / norm(trans_pos(:) - focus_pos(:)); + +else + focal_slope = (trans_pos(:) - natural_foc(:)) / norm(trans_pos(:) - natural_foc(:)); +end + focal_angle = atan2(focal_slope(2), focal_slope(1)); grid_step = options.grid_step; -max_od = max(parameters.transducer(1).Elements_OD_mm); +%% Compute distance to exit plane +% Maximum outer diameter of transducer elements +switch parameters.transducer(1).array_shape.type + case 'annular' + max_od = max(parameters.transducer(1).array_shape.annular.Elements_OD_mm); + case 'matrix' + max_od = parameters.transducer(1).array_shape.matrix.outer_diameter_mm; + otherwise + error('Array type %s is unknown or not implemented.', parameters.transducer(1).array_shape.type) +end + r = max_od / 2 / grid_step; % Exit plane positions (two methods for consistency) @@ -85,7 +104,7 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, end %% 5. DETAILED TRANSDUCER GEOMETRY (always shown) -trans_full_depth = 16 / grid_step; +trans_full_depth = parameters.transducer(1).depth_mm / grid_step; trans_back = ex_plane_pos_trig + trans_full_depth * focal_slope(:)'; % BACK PLANE, SIDE WALLS, EXIT CHORD (as before) diff --git a/functions/plot/show_3d_head.m b/functions/plot/show_3d_head.m index 10b2725e..c3bab107 100644 --- a/functions/plot/show_3d_head.m +++ b/functions/plot/show_3d_head.m @@ -110,10 +110,30 @@ function show_3d_head(segmented_img, target_xyz, trans_xyz, parameters, pixel_si c = color_list(k,:); % Plot transducer exit plane if not cropped if ~any(crop_at_target) + % Maximum outer diameter of transducer elements + switch parameters.transducer(1).array_shape.type + case 'annular' + max_od_mm = max(parameters.transducer(1).Elements_OD_mm); + case 'matrix' + max_od_mm = parameters.transducer(1).array_shape.matrix.outer_diameter_mm; + otherwise + error('Array type %s is unknown or not implemented.', parameters.transducer(1).array_shape.type) + end + % All shapes in downsampled space - max_od_mm = max(parameters.transducer(1).Elements_OD_mm); max_od_grid = max_od_mm / pixel_size; - norm_vec = (thisTrans - thisTarg) / norm(thisTrans - thisTarg); + + if parameters.transducer(1).align_transducer_with_focus + norm_vec = (thisTrans - thisTarg) / norm(thisTrans - thisTarg); + + else + % Use the natural focus based on transducer curvature + curv_radius = parameters.transducer(1).array_shape.matrix.curv_radius_mm; + natural_focus = trans_pos_grid + [0, 0, curv_radius / parameters.grid_step_mm]; + natural_focus_grid = natural_focus / pixel_size; + + norm_vec = (thisTrans - natural_focus_grid) / norm(thisTrans - natural_focus_grid); + end % Geometric focus in grid space geom_focus = thisTrans - norm_vec * (parameters.transducer(1).curv_radius_mm) / pixel_size; diff --git a/functions/plot/show_binary_mask_transducer.m b/functions/plot/show_binary_mask_transducer.m new file mode 100644 index 00000000..d1f5b492 --- /dev/null +++ b/functions/plot/show_binary_mask_transducer.m @@ -0,0 +1,68 @@ +function show_binary_mask_transducer(karray, kgrid, parameters) +% SHOW_BINARY_MASK_TRANSDUCER Visualizes the binary mask of a transducer in the simulation grid. +% +% The resulting figure is saved to the debug directory defined in the +% simulation parameters for inspection and debugging purposes. +% +% Input: +% karray - kWaveArray object defining the transducer geometry. +% kgrid - kWaveGrid object describing the simulation grid. +% parameters - Struct containing simulation parameters. +% +% Output: +% (none) The generated visualization is saved to disk. + + % Generate binary mask of transducer elements on the simulation grid + source_mask = karray.getArrayBinaryMask(kgrid); + + % Construct base filename for saved figures + base_filename = sprintf('sub-%03d_%s_binary_transducer_mask%s', ... + parameters.subject_id, parameters.simulation.medium, ... + parameters.io.output_affix); + + if numel(parameters.grid.dims) == 3 + + % ----------------------------------------------------------------- + % 3D visualization using isosurface + % ----------------------------------------------------------------- + + h = figure('Name', '3D Transducer Binary Mask'); + hold on; + + % Extract isosurface from binary mask + p = patch(isosurface(source_mask, 0.5)); % 0.5 is the threshold for binary mask + isonormals(source_mask, p); % Add surface normals for better visualization + + set(p, 'FaceColor', 'r', 'EdgeColor', 'none'); + + camlight; + lighting gouraud; + + axis equal; + xlabel('x-grid'); ylabel('y-grid'); zlabel('z-grid'); + title('3D Visualization of Transducer Binary Mask'); + + % Save figure + saveas(h, fullfile(parameters.io.debug_dir, [base_filename '.fig'])); + saveas(h, fullfile(parameters.io.debug_dir, [base_filename '.png'])); + + close(h); + else + % ----------------------------------------------------------------- + % 2D visualization + % ----------------------------------------------------------------- + + h = figure('Name', 'Transducer in Simulation Grid'); + + imagesc(source_mask); + axis equal tight; + colormap(hot); + colorbar; + title('Transducer Binary Mask in Simulation Grid'); + + % Save figure + saveas(h, fullfile(parameters.io.debug_dir, [base_filename '.png'])); + + close(h); + end +end \ No newline at end of file diff --git a/functions/source/create_clover_array.m b/functions/source/create_clover_array.m new file mode 100644 index 00000000..d50696e7 --- /dev/null +++ b/functions/source/create_clover_array.m @@ -0,0 +1,182 @@ +function elem_pos_m = create_clover_array(parameters, matrix_tp, elem_pos_m, trans_pos_m, focus_pos_m) +% CREATE_CLOVER_ARRAY Generate a multi-leaf clover transducer configuration. +% +% This function replicates a single matrix transducer layout into a clover +% configuration (multi-aperture arrangement), where multiple identical +% sub-arrays are distributed on a spherical surface (parent bowl). +% +% Each sub-array is: +% 1) Positioned on a sphere with radius ROC_parent +% 2) Rotated in azimuth (Z-axis) +% 3) Tilted toward the natural focus of the multi-leaf configuration +% (Y-axis rotation) +% +% Additionally, a sphere fit is performed per leaf to determine its apex +% and validate its distance to the intended focus. +% +% INPUTS: +% parameters - global parameter struct (used for debug output) +% matrix_tp - struct containing matrix + clover configuration +% elem_pos_m - [3 x N] element positions (single sub-array) [m] +% trans_pos_m - [3 x 1] transducer origin in simulation grid [m] +% focus_pos_m - [3 x 1] acoustic focus position in simulation grid [m] +% +% OUTPUTS: +% elem_pos_m - [3 x N_total] full clover element positions [m] + + % -------------------------------------------------------------------- + % Clover geometry parameters + % -------------------------------------------------------------------- + n_leaves = matrix_tp.clover.n_leaves; + ROC_parent_mm = matrix_tp.clover.ROC_parent; + + theta_az = 2*pi / 3; % 120° spacing (fixed geometry) + + % Estimate elevation angle to ensure sub-apertures do not overlap on + % the parent sphere + aperture_diam = matrix_tp.outer_diameter_mm; + radius_circle = aperture_diam / (2 * ROC_parent_mm * sin(theta_az / 2)); + elevation_angle = asin(radius_circle); + + % -------------------------------------------------------------------- + % Extract base geometry (convert to mm for geometric operations) + % -------------------------------------------------------------------- + base_pos_mm = elem_pos_m' * 1e3; % [N x 3] + + % Parent bowl center (global reference) + parent_center_m = trans_pos_m; + parent_center_m(3) = trans_pos_m(3) + ROC_parent_mm * 1e-3; + parent_center_mm = parent_center_m * 1e3; + + % -------------------------------------------------------------------- + % Define reference (first leaf center) + % Place first transducer at desired elevation on ROC sphere + % (along X-axis azimuthally) + % -------------------------------------------------------------------- + center0 = parent_center_mm + [ ... + ROC_parent_mm * cos(elevation_angle); + 0; + ROC_parent_mm * sin(elevation_angle) + ]; + + % Express base positions relative to first center + positions_local = base_pos_mm - center0'; + + % -------------------------------------------------------------------- + % Allocate storage + % -------------------------------------------------------------------- + elem_all = []; + h_leaves = gobjects(1, matrix_tp.clover.n_leaves); % handles for each leaf + + + % [DEBUG] visualize leaf orientation + if parameters.simulation.debug == 1 + h = figure; + hold on; + axis equal; + colors = lines(n_leaves); + legend_entries = strings(1, n_leaves); + end + + % -------------------------------------------------------------------- + % Generate each clover leaf + % -------------------------------------------------------------------- + for i = 1:n_leaves + + % --- Rotation matrices --- + angle_z = (i-1) * theta_az; + + % Rotate around Z to spread evenly + Rz = [cos(angle_z), -sin(angle_z), 0; + sin(angle_z), cos(angle_z), 0; + 0, 0, 1]; + + % Rotate around Y to tilt downward (toward focus) + Ry = [cos(elevation_angle), 0, sin(elevation_angle); + 0, 1, 0; + -sin(elevation_angle), 0, cos(elevation_angle)]; + + R = Rz * Ry; + + % --- Rotate center --- + center_rot = (R * (center0 - parent_center_mm)) + parent_center_mm; + + % --- Rotate elements --- + elems_rot = (R * positions_local')' + center_rot'; + + % --- Fit sphere to find apex (validation) --- + ROC_leaf = matrix_tp.curved.curv_radius_mm; + + residuals = @(c) vecnorm(elems_rot - c, 2, 2) - ROC_leaf; + + % Least squares optimization + center_fit = lsqnonlin(residuals, mean(elems_rot)); + + % Compute apex (bowl center point) + dir_vec = mean(elems_rot) - center_fit; + dir_vec = dir_vec / norm(dir_vec); + apex = center_fit + ROC_leaf * dir_vec; + + % Distance to parent center (sanity check) + dist_focus = norm(parent_center_mm' - apex); + + % [DEBUG] visualize leaf orientation + if parameters.simulation.debug == 1 + legend_entries(i) = sprintf('Leaf %d (dist: %.2f mm)', i, dist_focus); + + scatter3(elems_rot(:,1), elems_rot(:,2), elems_rot(:,3), ... + 15, colors(i,:), 'filled'); + + h_leaves(i) = plot3([apex(1), parent_center_mm(1)], ... + [apex(2), parent_center_mm(2)], ... + [apex(3), parent_center_mm(3)], 'k--'); + end + + % --- Store --- + elem_all = [elem_all; elems_rot]; + end + + % -------------------------------------------------------------------- + % Finalize outputs + % -------------------------------------------------------------------- + elem_pos_m = elem_all' / 1e3; % back to meters + + % -------------------------------------------------------------------- + % Debug plot + % -------------------------------------------------------------------- + % [DEBUG] visualize leaf orientation + if parameters.simulation.debug == 1 + h_parent = scatter3(parent_center_mm(1), parent_center_mm(2), ... + parent_center_mm(3), 100, 'r', 'filled'); + h_focus = scatter3(focus_pos_m(1)*1e3, focus_pos_m(2)*1e3, focus_pos_m(3)*1e3, 100, 'b', 'filled'); + h_center = scatter3(trans_pos_m(1)*1e3, trans_pos_m(2)*1e3, trans_pos_m(3)*1e3, 100, 'g', 'filled'); + + % Combine handles for legend + legend_handles = [h_leaves, h_parent, h_focus, h_center]; + + % Labels + par_bowl_label = "Middle of parent bowl, ROC " + sprintf('%.2f', ... + ROC_parent_mm) + " mm"; + legend_labels = [legend_entries, ... + par_bowl_label, "Focus", "Transducer center"]; + + legend(legend_handles, legend_labels); + + xlabel('X [mm]'); + ylabel('Y [mm]'); + zlabel('Z [mm]'); + title(sprintf('Clover Array (%d leaves) ROC sub-arrray %.1f mm', n_leaves, ROC_leaf)); + + grid on; + view([20 25 30]); + + output_file = fullfile(parameters.io.debug_dir, ... + sprintf('sub-%03d_%s_clover%s.png', ... + parameters.subject_id, parameters.simulation.medium, ... + parameters.io.output_affix)); + + saveas(h, output_file); + close(h); + end + +end \ No newline at end of file diff --git a/functions/source/create_matrix_karray.m b/functions/source/create_matrix_karray.m new file mode 100644 index 00000000..433a6bca --- /dev/null +++ b/functions/source/create_matrix_karray.m @@ -0,0 +1,191 @@ +function [karray, transducer_pars] = create_matrix_karray(kgrid, karray, parameters, transducer_pars, elem_pos_m, trans_pos, focus_pos) +%CREATE_MATRIX_KARRAY Adds elements of a matrix transducer to a kWave array. +% +% Inputs: +% kgrid - kWave grid object +% karray - kWaveArray object to which elements are added +% parameters - Simulation parameters struct +% transducer_pars - Struct containing transducer geometry, type, curvature, and element properties +% elem_pos_m - Nx3 matrix of element positions in meters +% trans_pos - 1x3 transducer reference position (indices in kgrid) +% focus_pos - 1x3 focus position (indices in kgrid) +% +% Output: +% karray - Updated kWaveArray object with elements added + + % Extract matrix transducer configuration from the transducer parameters + matrix_tp = transducer_pars.array_shape.matrix; + + % Convert positions from kgrid indices to meters + trans_pos_m = [kgrid.x_vec(trans_pos(1)), ... + kgrid.y_vec(trans_pos(2)), ... + kgrid.z_vec(trans_pos(3))]'; + + focus_pos_m = [kgrid.x_vec(focus_pos(1)), ... + kgrid.y_vec(focus_pos(2)), ... + kgrid.z_vec(focus_pos(3))]'; + + natural_focus_pos_m = trans_pos_m + [0, 0, matrix_tp.curved.curv_radius_mm / 1000]'; + + % Apply Clover setup if requested + if matrix_tp.is_clover_setup + elem_pos_m = create_clover_array(parameters, matrix_tp, elem_pos_m, trans_pos_m, focus_pos_m); + end + + transducer_pars.n_elements = size(elem_pos_m, 2); + + % Initialize source amplitudes (uniform) + transducer_pars.source_amp = transducer_pars.source_amp(1) * ones(1, transducer_pars.n_elements); + + % Wavelength and wavenumber for phase calculation + lambda = parameters.medium_properties.water.sound_speed / transducer_pars.source_freq_hz; + k = 2 * pi / lambda; + + % Initialize source phases, scaled vectors, tx, ty, tz + source_phase_rad = zeros(1, transducer_pars.n_elements); + scaled_vectors = zeros(3, transducer_pars.n_elements); + tx = zeros(1, transducer_pars.n_elements); + ty = zeros(1, transducer_pars.n_elements); + tz = zeros(1, transducer_pars.n_elements); + + % Loop over each element and add it to karray + for ind = 1:transducer_pars.n_elements + el_pos_m_i = elem_pos_m(:, ind); + + % Vector from element to natural focus to position elements to + % natural focus + vec_to_nat_focus = natural_focus_pos_m - el_pos_m_i; + norm_vec = vec_to_nat_focus / norm(vec_to_nat_focus); + scaled_vectors(:, ind) = vec_to_nat_focus; + + % Compute rotation matrix to align default normal [0;0;1] to vec_to_focus + z0 = [0;0;1]; + v = cross(z0, norm_vec); + s = norm(v); + c = dot(z0, norm_vec); + vx = [ 0 -v(3) v(2); v(3) 0 -v(1); -v(2) v(1) 0 ]; + R = eye(3) + vx + vx^2*((1-c)/(s^2+eps)); + + + % Convert rotation matrix to ZYX Euler angles (extrinsic) + yaw = atan2d(R(2,1), R(1,1)); % around z + pitch = atan2d(-R(3,1), sqrt(R(3,2)^2 + R(3,3)^2)); % around y + roll = atan2d(R(3,2), R(3,3)); % around x + + tx(ind) = roll; + ty(ind) = pitch; + tz(ind) = yaw; + + % Determine element type + switch lower(matrix_tp.element_shape) + case 'rect' + karray.addRectElement(el_pos_m_i, matrix_tp.elem_height_mm, matrix_tp.elem_width_mm, [roll, pitch, yaw]); + + case 'disc' + % Disc with same area as rectangular element + diameter = sqrt(matrix_tp.elem_height_mm * matrix_tp.elem_width_mm * 4 / pi); + karray.addDiscElement(el_pos_m_i, diameter, natural_focus_pos_m); + + case 'bowl' + r_c = matrix_tp.curved.curv_radius_mm / 1e3; + A_target = matrix_tp.elem_height_mm * matrix_tp.elem_width_mm; + a_min = 0.001; % Lower bound of aperture radius (in m) + a_max = r_c * 0.999; % Slightly less than full bowl radius + + + % Define the function to solve: A_cap(a) - A_target = 0 + area_diff = @(a) 2*pi*r_c*(r_c - sqrt(r_c^2 - a^2)) - A_target; + + % Check if the function changes sign in the interval + if sign(area_diff(a_min)) == sign(area_diff(a_max)) + error('No sign change in interval: cannot find bowl diameter. Possibly A_target is out of bounds.'); + end + + a_solution = fzero(area_diff, [a_min, a_max]); + diameter = 2 * a_solution; + karray.addBowlElement(el_pos_m_i, r_c, diameter, natural_focus_pos_m); + end + + % Calculate phase delay based on set focus + distance = sqrt((el_pos_m_i(1) - focus_pos_m(1))^2 + (el_pos_m_i(2) - focus_pos_m(2))^2 + (el_pos_m_i(3) - focus_pos_m(3))^2); + + source_phase_rad(ind) = mod(k * distance, 2 * pi); + end + + transducer_pars.array_shape.matrix = matrix_tp; + + transducer_pars.source_phase_rad = source_phase_rad; + transducer_pars.source_phase_deg = rad2deg(source_phase_rad); + + % [DEBUG] visualize matrix element orientation + if parameters.simulation.debug == 1 + h = figure; + hold on; + axis equal; + + scatter3(elem_pos_m(1,:), elem_pos_m(2,:), elem_pos_m(3,:), 'b.'); + scatter3(natural_focus_pos_m(1,:), natural_focus_pos_m(2,:), natural_focus_pos_m(3,:), 'r.'); + plot3(focus_pos_m(1), focus_pos_m(2), focus_pos_m(3), 'go', 'MarkerFaceColor', 'g'); + + for ind = 1:transducer_pars.n_elements + el_pos = elem_pos_m(:, ind); + + % --- Ground truth direction (element -> focus) + vec_truth = focus_pos_m - el_pos; + vec_truth = vec_truth / norm(vec_truth); + + % --- Euler angles (degrees -> radians) + roll = deg2rad(tx(ind)); + pitch = deg2rad(ty(ind)); + yaw = deg2rad(tz(ind)); + + % --- Rotation matrix (extrinsic ZYX) + Rz = [cos(yaw) -sin(yaw) 0; + sin(yaw) cos(yaw) 0; + 0 0 1]; + + Ry = [cos(pitch) 0 sin(pitch); + 0 1 0; + -sin(pitch) 0 cos(pitch)]; + + Rx = [1 0 0; + 0 cos(roll) -sin(roll); + 0 sin(roll) cos(roll)]; + + R = Rz * Ry * Rx; + + % --- Predicted element normal + vec_pred = R * [0; 0; 1]; + + % --- Scale vectors for visualization + scale = norm(focus_pos_m - el_pos); + vec_pred = vec_pred * scale; + vec_truth = vec_truth * scale; + + % --- Plot vectors + quiver3(el_pos(1), el_pos(2), el_pos(3), ... + vec_pred(1), vec_pred(2), vec_pred(3), ... + 0, 'r', 'LineWidth', 1.5); + + quiver3(el_pos(1), el_pos(2), el_pos(3), ... + vec_truth(1), vec_truth(2), vec_truth(3), ... + 0, 'g--', 'LineWidth', 1.2); + + end + + view(0, 0); + xlabel('x [m]'); ylabel('y [m]'); zlabel('z [m]'); + title('Element Orientation Validation (Red = Euler, Green = Ground Truth)') + grid on; + + legend({'Element Position','Natural Focus','Focus','From Euler Angles','Ground Truth'}) + + base_name = sprintf('sub-%03d_%s_transducer_element_orientation%s', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix); + + saveas(h, fullfile(parameters.io.debug_dir, [base_name '.fig'])) + saveas(h, fullfile(parameters.io.debug_dir, [base_name '.png'])) + + close(h) + end +end \ No newline at end of file diff --git a/functions/source/grid_transducer_location.m b/functions/source/grid_transducer_location.m index f8bfb8e2..af10914a 100644 --- a/functions/source/grid_transducer_location.m +++ b/functions/source/grid_transducer_location.m @@ -27,11 +27,13 @@ parameters.transducer = parameters.transducer(1); end - % 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') - parameters.transducer.trans_pos = []; - parameters.transducer.focus_pos = []; + if strcmp(parameters.transducer.array_shape.type, 'annular') + % 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') + parameters.transducer.trans_pos = []; + parameters.transducer.focus_pos = []; + end end if (~isfield(parameters.transducer, 'trans_pos') || isempty(parameters.transducer.trans_pos)) ... diff --git a/functions/source/source_create.m b/functions/source/source_create.m index 4ada9dcd..a13ee9f2 100644 --- a/functions/source/source_create.m +++ b/functions/source/source_create.m @@ -41,17 +41,17 @@ %% Convert element diameters from mm to grid points (for all transducers) - transducer_pars = parameters.transducer; - 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.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.resolution_mm); + if strcmp(tp.array_shape.type, 'annular') + tp.array_shape.annular.Elements_OD = 2 * floor(tp.array_shape.annular.Elements_OD_mm / parameters.grid.resolution_mm / 2) + 1; + tp.array_shape.annular.Elements_ID = 2 * floor(tp.array_shape.annular.Elements_ID_mm / parameters.grid.resolution_mm / 2) + 1; + tp.array_shape.annular.Elements_ID(tp.array_shape.annular.Elements_ID_mm == 0) = 0; + + end + + tp.radius_grid = round(tp.curv_radius_mm / parameters.grid.resolution_mm); if it == 1 % initialise struct array with full field set of tp @@ -111,20 +111,20 @@ % outer element aperture if numel(parameters.grid.dims) == 3 bowl = makeBowl(grid_dims, trans_pos_i, tp.radius_grid, ... - tp.Elements_OD(el_i), focus_pos_i); + tp.array_shape.annular.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); + tp.array_shape.annular.Elements_OD(el_i), focus_pos_i); end % subtract inner aperture if applicable - if tp.Elements_ID(el_i) > 0 + if tp.array_shape.annular.Elements_ID(el_i) > 0 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); + tp.radius_grid, tp.array_shape.annular.Elements_ID(el_i), focus_pos_i); else bowl = bowl - makeArc(grid_dims, trans_pos_i, ... - tp.radius_grid, tp.Elements_ID(el_i), focus_pos_i); + tp.radius_grid, tp.array_shape.annular.Elements_ID(el_i), focus_pos_i); end end @@ -165,19 +165,14 @@ else disp('Setting up kWaveArray (might take a bit of time)'); + % Note: Both `parameters` and `tp` are passed to this function to + % allow support for multiple transducers in future implementations. tp = transducer_pars(1); % use first (and only) transducer here % 3D/2D positions for kWaveArray are taken from the first row trans_pos_1 = trans_pos(1, :); focus_pos_1 = focus_pos(1, :); - % CW per-element for this transducer - cw_signal = createCWSignals( ... - kgrid.t_array, ... - tp.source_freq_hz, ... - tp.source_amp, ... - tp.source_phase_rad); % [n_elements x Nt] - % Determine if axisymmetric mode should be enabled if numel(parameters.grid.dims) == 2 && ... isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 @@ -193,30 +188,94 @@ 'UpsamplingRate', 10, ... 'BLIType', 'sinc'); - % Set focus position and transducer position vectors in physical coordinates - 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))]; - - karray.addAnnularArray(pos_vec, ... - tp.curv_radius_mm * 1e-3, ... - [tp.Elements_ID_mm; tp.Elements_OD_mm] * 1e-3, ... - focus_vec); - - elseif numel(parameters.grid.dims) == 2 && axisymmetric == false + switch tp.array_shape.type + case 'annular' + % Set focus position and transducer position vectors in physical coordinates + 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))]; + + karray.addAnnularArray(pos_vec, ... + tp.curv_radius_mm * 1e-3, ... + [tp.array_shape.annular.Elements_ID_mm; tp.array_shape.annular.Elements_OD_mm] * 1e-3, ... + focus_vec); + + 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))]; + focus_vec = [kgrid.x_vec(focus_pos_1(1)), kgrid.y_vec(focus_pos_1(2))]; + + karray.addArcElement(pos_vec, ... + tp.curv_radius_mm * 1e-3, ... + tp.array_shape.annular.Elements_OD_mm * 1e-3, ... + focus_vec); + end + case 'matrix' + matrix_tp = tp.array_shape.matrix; + + trans_pos_m = [kgrid.x_vec(tp.trans_pos(1)); + kgrid.y_vec(tp.trans_pos(2)); + kgrid.z_vec(tp.trans_pos(3))]; + + focus_pos_m = [kgrid.x_vec(tp.focus_pos(1)); + kgrid.y_vec(tp.focus_pos(2)); + kgrid.z_vec(tp.focus_pos(3))]; + + switch matrix_tp.matrix_shape.type + case 'define_here' + [elem_pos_m, tp] = convert_to_element_pos(parameters, tp, trans_pos_m, focus_pos_m); + case 'extract_from_file' + elem_pos_m = extract_element_pos(parameters, tp, trans_pos_m); + otherwise + error('Matrix shape %s is unknown or not implemented.', matrix_tp.matrix_shape.type) + end - % 2D arc-shaped element - pos_vec = [kgrid.x_vec(trans_pos_1(1)), kgrid.y_vec(trans_pos_1(2))]; - focus_vec = [kgrid.x_vec(focus_pos_1(1)), kgrid.y_vec(focus_pos_1(2))]; + % [DEBUG] visualize element distribution + if parameters.simulation.debug == 1 + % Convert positions to mm for plotting + elem_pos_mm = elem_pos_m' * 1e3; + + h = figure; + scatter3(elem_pos_mm(:,1), elem_pos_mm(:,2), elem_pos_mm(:,3), 60, 'filled'); + axis equal + xlabel('X [mm]') + ylabel('Y [mm]') + zlabel('Z [mm]') + view([0 90]) + title('Transducer Element Distribution') + grid on; + + % Build filenames + fig_filename = fullfile(parameters.io.debug_dir, ... + sprintf('sub-%03d_%s_transducer_element_distribution%s.fig', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + + png_filename = fullfile(parameters.io.debug_dir, ... + sprintf('sub-%03d_%s_transducer_element_distribution%s.png', ... + parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); + + % Save outputs + saveas(h, fig_filename, 'fig') + saveas(h, png_filename, 'png') + close(h) + end + + [karray, tp] = create_matrix_karray(kgrid, karray, parameters, tp, elem_pos_m, trans_pos, focus_pos); - karray.addArcElement(pos_vec, ... - tp.curv_radius_mm * 1e-3, ... - tp.Elements_OD_mm * 1e-3, ... - focus_vec); + otherwise + error('Array shape %s is unknown or not implemented.', tp.array_shape.type) end - if axisymmetric == true + % CW per-element for this transducer + cw_signal = createCWSignals( ... + kgrid.t_array, ... + tp.source_freq_hz, ... + tp.source_amp, ... + tp.source_phase_rad); % [n_elements x Nt] + + if axisymmetric == true && strcmp(tp.array_shape.type, 'annular') kgrid_mirrored = kWaveGrid(kgrid.Nx, kgrid.dx, 2*kgrid.Ny - 1, kgrid.dy); karray_full = kWaveArray('Axisymmetric', false, 'BLITolerance', 0.01, 'UpsamplingRate', 100); @@ -224,9 +283,9 @@ 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]; - + for el_i = 1:tp.n_elements - el_OD_m = tp.Elements_OD_mm(el_i) * 1e-3; + el_OD_m = tp.array_shape.annular.Elements_OD_mm(el_i) * 1e-3; y_shift = (el_i - (tp.n_elements+1)/2) * el_OD_m; element_pos = position_base + [0, y_shift]; @@ -305,7 +364,9 @@ source.p_mask = binary_mask; source.p = distributed_source_signal; - end + show_binary_mask_transducer(karray, kgrid, parameters); + end + transducer_pars(1) = tp; end end diff --git a/functions/thermal/thermal_analysis.m b/functions/thermal/thermal_analysis.m index fc02e0ee..f2d2e8c0 100644 --- a/functions/thermal/thermal_analysis.m +++ b/functions/thermal/thermal_analysis.m @@ -100,16 +100,37 @@ % plot heating superimposed on segmentation if ndims(results_heating.maxT) == 3 - [~,~,~,~,~,~,~,h]=plot_overlay(... + + slices = struct( ... + 'dim', {'x', 'y', 'z'}, ... + 'pos', {parameters.transducer(1).focus_pos(1), ... + parameters.transducer(1).focus_pos(2), ... + parameters.transducer(1).focus_pos(3)}); + + for i = 1:numel(slices) + slice = slices(i); + [~,~,~,~,~,~,~,h]=plot_overlay(... results_heating.maxT, ... segmentation, ... source_labels, ... parameters, ... - {'y', parameters.transducer(1).focus_pos(2)}, ... + {slice.dim, slice.pos}, ... parameters.transducer(1).trans_pos, ... parameters.transducer(1).focus_pos, ... highlighted_pos, ... 'overlay_color_range', temp_color_range); + + % Construct output filename + output_plot_filename = fullfile(parameters.output_dir,... + sprintf('sub-%03d_%s_maxT_%s_%s.png',... + parameters.subject_id, ... + parameters.simulation_medium, ... + slice.dim, ... + parameters.results_filename_affix)); + + saveas(h, output_plot_filename, 'png') + close(h); + end elseif ndims(results_heating.maxT) == 2 [h]=plot_overlay_2d(... results_heating.maxT, ... @@ -121,9 +142,13 @@ highlighted_pos, ... 'overlay_color_range', temp_color_range, ... 'bg_bw_range', [0, numel(fieldnames(parameters.layers))]); + + output_plot_filename = fullfile(parameters.io.output_dir,... + sprintf('sub-%03d_%s_maxT%s.png',... + parameters.subject_id, ... + parameters.simulation_medium, ... + parameters.io.output_affix)); + saveas(h, output_plot_filename, 'png') + close(h); end - output_plot_filename = fullfile(parameters.io.output_dir,... - sprintf('sub-%03d_%s_maxT%s.png',... - parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); - saveas(h, output_plot_filename, 'png') - close(h); \ No newline at end of file +end \ No newline at end of file diff --git a/functions/transducer/convert_to_element_pos.m b/functions/transducer/convert_to_element_pos.m new file mode 100644 index 00000000..06b3c394 --- /dev/null +++ b/functions/transducer/convert_to_element_pos.m @@ -0,0 +1,209 @@ +function [elem_pos_m, tp] = convert_to_element_pos(parameters, tp, trans_pos_m, focus_pos_m) +%CONVERT_TO_ELEMENT_POS Generate element positions for matrix transducers +% +% This function generates the 3-D element coordinates of a matrix +% array transducer based on the configuration defined in the parameter +% structure. Supported grid layouts are: +% +% • rectangular grid +% • Fibonacci spiral grid +% • Fermat spiral grid (via makeCartBowl) +% +% The resulting element coordinates are optionally projected onto a +% spherical cap when the transducer is defined as curved. +% +% INPUTS +% parameters Global simulation parameters +% tp Transducer parameter structure +% trans_pos_m [3x1] transducer position in meters +% focus_pos_m [3x1] focus position in meters +% +% OUTPUTS +% elem_pos_m Element center coordinates [m] (3 × N) +% tp Updated transducer parameter structure +% +% NOTES +% • Element coordinates are returned in k-Wave format (3 × N) +% • Internal calculations are performed in millimeters unless noted +% • Curvature is applied only when tp.array_shape.matrix.is_curved = true + + % ---------------------------------------------------------------------- + % Extract configuration + % ----------------------------------------------------------------------- + + % Extract matrix transducer configuration from the transducer parameters + matrix_tp = tp.array_shape.matrix; + + % Extract defined matrix shape parameters for reading element positions + defined = matrix_tp.matrix_shape.define_here; + grid_shape = defined.grid_shape; + + % Ensure column vectors + trans_pos_m = trans_pos_m(:); + focus_pos_m = focus_pos_m(:); + + switch grid_shape.type + case 'fibonacci' + + fib = grid_shape.fibonacci; + + N = fib.n_elements; % number of elements + D = matrix_tp.outer_diameter_mm; % aperture diameter [mm] + R = matrix_tp.curv_radius_mm; % radius of curvature [mm] + + a = D/2; % aperture radius [mm] + ga = pi*(3 - sqrt(5)); % golden angle + + elem_pos_mm = zeros(N,3); + + for i = 1:N + + % Uniform point distribution inside aperture + r = a * sqrt((i - 0.5)/N); + theta = ga * (i - 1); + + x = r*cos(theta); + y = r*sin(theta); + + % Project onto spherical cap if curved + if matrix_tp.is_curved + z = R - sqrt(R^2 - x^2 - y^2); + else + z = 0; + end + + elem_pos_mm(i,:) = [x y z]; + + end + + % --- Estimate average element pitch ------------------------------- + avg_dist = 0; + for i = 1:N + dists = vecnorm(elem_pos_mm - elem_pos_mm(i,:),2,2); % Euclidean distances + dists(i) = inf; % Ignore self + avg_dist = avg_dist + min(dists); % Nearest neighbor distance + end + + avg_pitch = avg_dist / N; % Average center-to-center spacing + + kerf = fib.kerf_mm; % assumed kerf width [mm] + element_size = avg_pitch - kerf; + + fprintf('Estimated avg pitch: %.3f mm\n', avg_pitch); + fprintf('Recommended element size (kerf %.3f mm): %.3f mm\n', ... + kerf, element_size); + + % Convert to meters + translate + elem_pos_m = (elem_pos_mm / 1e3)' + trans_pos_m; + + case 'rect' + + rect = grid_shape.rect; + + % --- Compute array dimensions -------------------------------------- + tran_width = (rect.n_elem_row * tp.elem_width_mm) + ... + ((rect.n_elem_row - 1) * rect.elem_spacing_width_mm); + + tran_height = (rect.n_elem_col * tp.elem_height_mm) + ... + ((rect.n_elem_col - 1) * rect.elem_spacing_height_mm); + + % --- Element center coordinates ------------------------------------ + x_vec = linspace(-tran_width/2 + tp.elem_width_mm/2,... + tran_width/2 - tp.elem_width_mm/2,... + rect.n_elem_row); + + y_vec = linspace(-tran_height/2 + tp.elem_height_mm/2,... + tran_height/2 - tp.elem_height_mm/2,... + rect.n_elem_col); + + [X,Y] = meshgrid(x_vec,y_vec); + + % Flat array coordinates + X = X + trans_pos_m(1); + Y = Y + trans_pos_m(2); + Z = trans_pos_m(3) * ones(size(X)); + + % [DEBUG] visualize grid + if parameters.debug == 1 + h = figure; + scatter(X(:), Y(:), 60, 'filled') + axis equal + xlabel('X [m]') + ylabel('Y [m]') + title('Grid - Element Center Positions') + grid on + + output_plot_filename = fullfile(parameters.debug_dir,... + sprintf('sub-%03d_%s_transducer_grid%s.png',... + parameters.subject_id,... + parameters.simulation_medium,... + parameters.results_filename_affix)); + + saveas(h,output_plot_filename,'png') + close(h) + end + + % Restrict to circular aperture + dx = X(:) - trans_pos_m(1); + dy = Y(:) - trans_pos_m(2); + distances = sqrt(dx.^2 + dy.^2); + + radius = matrix_tp.Elements_OD_mm(end) * 1e-3 / 2; + mask = distances <= radius; + + elem_pos_m = [X(:) Y(:) Z(:)]; + elem_pos_m = elem_pos_m(mask,:); + + % Update number of elements + tp.array_shape.matrix.n_elements = size(elem_pos_m,1); + + % Duplicate source amplitude per element + tp.source_amp = tp.source_amp(1) * ... + ones(1,tp.array_shape.matrix.n_elements); + + % [DEBUG] visualize circular aperture + if parameters.debug == 1 + h = figure; + scatter(elem_pos_m(:,1)*1e3,elem_pos_m(:,2)*1e3,60,'filled') + axis equal + xlabel('X [mm]') + ylabel('Y [mm]') + title('Circular Aperture - Element Center Positions') + grid on + + saveas(h,output_plot_filename,'png') + close(h) + end + + % Apply curvature (spherical cap) + if matrix_tp.is_curved + + ROC = matrix_tp.curved.curv_radius_mm / 1000; + R2 = ROC^2; + + sagitta_term = R2 - elem_pos_m(:,1).^2 - elem_pos_m(:,2).^2; + + if any(sagitta_term < 0) + error('Some elements fall outside the spherical cap.'); + end + + elem_pos_m(:,3) = elem_pos_m(:,3) + ROC - sqrt(sagitta_term); + + end + + elem_pos_m = elem_pos_m'; + + case 'fermat' + elem_pos_m = makeCartBowl( ... + trans_pos_m', ... + matrix_tp.curved.curv_radius_mm * 1e-3, ... + matrix_tp.outer_diameter_mm * 1e-3, ... + focus_pos_m', ... + tp.n_elements, ... + true); + + otherwise + error('Grid shape %s is unknown or not implemented.', grid_shape.type) + end + +end \ No newline at end of file diff --git a/functions/transducer/extract_element_pos.m b/functions/transducer/extract_element_pos.m new file mode 100644 index 00000000..5f5f81f8 --- /dev/null +++ b/functions/transducer/extract_element_pos.m @@ -0,0 +1,143 @@ +function elem_pos_m = extract_element_pos(parameters, tp, trans_pos_m) +% EXTRACT_ELEMENT_POS Extracts matrix transducer element positions from file. +% +% This function reads element position coordinates from an external file +% and converts them into expected element position coordinates. The +% positions are defined in physical space (typically in millimetres) and +% are transformed into metres relative to the simulation grid. +% +% The resulting element coordinates are shifted to the desired transducer +% location within the k-Wave simulation grid. +% +% Input: +% parameters Global simulation parameters +% tp - Struct containing transducer parameters. +% trans_pos_m [3x1] transducer position in meters +% +% Output: +% elem_pos_m - 3 x N matrix containing element positions in metres, +% expressed in simulation coordinates. + + % Extract matrix transducer configuration from the transducer parameters + matrix_tp = tp.array_shape.matrix; + + % Extract file-based matrix shape parameters for reading element positions + file_ext = matrix_tp.matrix_shape.extract_from_file; + + % --------------------------------------------------------------------- + % Read element positions from file + % --------------------------------------------------------------------- + + tran_info = readtable(file_ext.file_path); + + row_start = file_ext.start_row; + row_end = row_start + file_ext.n_elements - 1; + + col_start = file_ext.start_col; + + phys_positions_mm = table2array( ... + tran_info(row_start:row_end, col_start:col_start+2) ... + ); + + % --------------------------------------------------------------------- + % Optional: randomly select subset of elements + % --------------------------------------------------------------------- + if file_ext.select_random_subset + + if file_ext.subset.random_seed + rng('shuffle'); % seed RNG based on current time + end + + phys_positions_mm = datasample(phys_positions_mm, ... + file_ext.subset.subset_n_elements, ... + 1, ... + 'Replace', ... + false); + end + + % --------------------------------------------------------------------- + % Convert positions from mm to metres and center coordinates + % --------------------------------------------------------------------- + ROC = matrix_tp.curved.curv_radius_mm; + + % Translate origin from [0,0,ROC] to [0,0,0], convert mm→m, and flip Z-axis + phys_positions_m = (phys_positions_mm - [0, 0, ROC]) .* [1, 1, -1] / 1000; + + % --------------------------------------------------------------------- + % Optional: project elements onto new radius of curvature + % --------------------------------------------------------------------- + + if file_ext.project_on_new_ROC + + new_ROC_mm = file_ext.ROC_projection.new_ROC_mm; + + r = hypot(phys_positions_m(:,1), phys_positions_m(:,2)); + r_max = max(r); + + sag_old = ROC - sqrt(ROC^2 - r_max^2); + sag_new = new_ROC_mm - sqrt(new_ROC_mm^2 - r_max^2); + + scale_factor = sag_new / sag_old; + + z_ref = max(phys_positions_m(:,3)); + z_offset = phys_positions_m(:,3) - z_ref; + + new_z = z_ref + z_offset * scale_factor; + + phys_positions_m(:,3) = new_z; + + % --------------------------------------------------------------------- + % Optional: projection sanity check + % --------------------------------------------------------------------- + + % Extract X, Y, Z for convenience + x = phys_positions_m(:,1); + y = phys_positions_m(:,2); + z = phys_positions_m(:,3); + + % Solve least-squares sphere fit (optional, purely for information) + A = [2*x, 2*y, 2*z, ones(size(x))]; + b = x.^2 + y.^2 + z.^2; + params = A \ b; + + xc = params(1); yc = params(2); zc = params(3); c = params(4); + R_fit = sqrt(xc^2 + yc^2 + zc^2 + c); + fprintf('Best-fit radius = %.6f m\n', R_fit); + + % [DEBUG] visualize grid + if parameters.debug == 1 + h = figure('Name', 'Transducer Element Distribution'); + hold on; grid on; + + % Plot original element positions + scatter3(x, y, z, 50, 'b', 'filled', 'DisplayName', 'Original positions'); + + % Plot projected element positions + scatter3(x, y, new_z, 50, 'r', 'filled', 'DisplayName', 'Projected positions'); + + axis equal; + xlabel('X [m]'); ylabel('Y [m]'); zlabel('Z [m]'); + title('Transducer Element Distribution (3D)'); + legend('Location', 'best'); + view([90 0]); % Top-down view + + % Save figure + fig_name = sprintf('sub-%03d_%s_transducer_redone_element_distribution%s', ... + parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix); + + saveas(h, fullfile(parameters.debug_dir, [fig_name '.fig'])); + saveas(h, fullfile(parameters.debug_dir, [fig_name '.png'])); + + close(h); + end + + end + + % --------------------------------------------------------------------- + % Translate positions into simulation grid coordinates + % --------------------------------------------------------------------- + elem_pos_m = phys_positions_m + trans_pos_m'; + + elem_pos_m = elem_pos_m'; + +end \ No newline at end of file diff --git a/functions/transducer/get_arc.m b/functions/transducer/get_arc.m index 6682bf38..1ccc8b6d 100644 --- a/functions/transducer/get_arc.m +++ b/functions/transducer/get_arc.m @@ -22,7 +22,7 @@ radius (1,1) double angleStart (1,1) double angleEnd (1,1) double - npoints (1,1) int8 = 200 + npoints (1,1) double = 200 end % Generate angles uniformly between `angleStart` and `angleEnd` diff --git a/functions/transducer/get_transducer_box.m b/functions/transducer/get_transducer_box.m index 8e544963..e7a62ef8 100644 --- a/functions/transducer/get_transducer_box.m +++ b/functions/transducer/get_transducer_box.m @@ -1,4 +1,4 @@ -function [transducer_box, ex_plane_pos_trig, geom_focus_pos, dist_to_ep_mm] = get_transducer_box(trans_pos, focus_pos, grid_step, parameters, plot) +function [transducer_box, ex_plane_pos_trig, geom_focus_pos, dist_to_ep_mm] = get_transducer_box(trans_pos, focus_pos, natural_foc, grid_step, parameters, is_plot) % GET_TRANSDUCER_BOX Computes the transducer box dimensions and positions. % @@ -10,6 +10,7 @@ % Input: % trans_pos - [1x2] array specifying the transducer position in grid coordinates. % focus_pos - [1x2] array specifying the focus position in grid coordinates. +% natural_foc - [1x2] array specifying the natural focus position in grid coordinates. % grid_step - Scalar specifying the grid step size (in mm). % parameters - Struct containing transducer properties (e.g., curvature radius, element diameters). % plot - Boolean flag to enable/disable visualization of the transducer box (default: 1). @@ -23,23 +24,37 @@ arguments trans_pos (1, 2) % Transducer position in grid coordinates focus_pos (1, 2) % Focus position in grid coordinates + natural_foc (1, 2) % Natural focus position in grid coordinates grid_step (1, 1) % Grid step size in mm parameters struct % Struct containing transducer properties - plot = 1 % Enable/disable visualization (default: enabled) + is_plot = 1 % Enable/disable visualization (default: enabled) end - %% Compute focal slope and angle - % Calculate unit vector pointing from focus to transducer and its angle - focal_slope = (trans_pos - focus_pos) / norm(trans_pos - focus_pos); - focal_angle = atan2(focal_slope(2), focal_slope(1)); + %% Compute focal slope0 + % Calculate unit vector pointing from focus to transducer + if parameters.transducer(1).align_transducer_with_focus + focal_slope = (trans_pos - focus_pos) / norm(trans_pos - focus_pos); + + else + focal_slope = (trans_pos - natural_foc) / norm(trans_pos - natural_foc); + end + + focal_angle = atan2(focal_slope(2),focal_slope(1)); %% Compute geometric focus position % Calculate geometric focus position based on curvature radius and focal angle - geom_focus_pos = trans_pos - (parameters.transducer(1).curv_radius_mm) / grid_step * [cos(focal_angle), sin(focal_angle)]; + geom_focus_pos = trans_pos - (parameters.transducer(1).curv_radius_mm) / grid_step * focal_slope; %% Compute distance to exit plane % Maximum outer diameter of transducer elements - max_od = max(parameters.transducer(1).Elements_OD_mm); + switch parameters.transducer(1).array_shape.type + case 'annular' + max_od = max(parameters.transducer(1).array_shape.annular.Elements_OD_mm); + case 'matrix' + max_od = parameters.transducer(1).array_shape.matrix.outer_diameter_mm; + otherwise + error('Array type %s is unknown or not implemented.', parameters.transducer(1).array_shape.type) + end % Distance from geometric focus to exit plane in mm dist_to_ep_mm = 0.5 * sqrt(4 * parameters.transducer(1).curv_radius_mm^2 - max_od^2); @@ -49,18 +64,20 @@ %% Compute exit plane position % Calculate exit plane position based on focal angle and distance to exit plane - ex_plane_pos_trig = geom_focus_pos + dist_to_ep_grid * [cos(focal_angle), sin(focal_angle)]; + ex_plane_pos_trig = geom_focus_pos + dist_to_ep_grid * focal_slope; %% Compute orthogonal angle for bounding box calculation % Orthogonal angle perpendicular to focal slope ort_angle = atan(-focal_slope(1) / focal_slope(2)); + arc_halfangle = atan(max_od/2/dist_to_ep_grid/grid_step); + %% Compute bounding box dimensions % Radius of bounding box based on maximum outer diameter of elements - r = max(parameters.transducer(1).Elements_OD_mm) / 2 / grid_step; + r = max_od / 2 / grid_step; % Depth of transducer in grid units - trans_full_depth = 16 / grid_step; + trans_full_depth = parameters.transducer(1).depth_mm / grid_step; % Back end position of transducer based on depth and focal slope trans_back = ex_plane_pos_trig + trans_full_depth * focal_slope; @@ -72,7 +89,7 @@ [ex_plane_pos_trig(2) + r * sin(ort_angle), ex_plane_pos_trig(1) + r * cos(ort_angle)]]; %% Visualization (optional) - if plot + if is_plot overlay_weight = 0; % Weight for overlay color blending overlay_color = [0, 0.2, 0.7]; % Overlay color (blue) lineWidth = 1; % Line width for visualization @@ -92,6 +109,21 @@ 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/ parameters.grid_step_mm, ... + focal_angle-arc_halfangle, ... + focal_angle+arc_halfangle ); + plot(arc_y, arc_x, 'Color', boxColor, 'LineWidth', lineWidth, ... + 'LineSmoothing', LineSmoothing) end end diff --git a/functions/transducer/transducer_setup.m b/functions/transducer/transducer_setup.m index da89f878..43b98680 100644 --- a/functions/transducer/transducer_setup.m +++ b/functions/transducer/transducer_setup.m @@ -54,35 +54,100 @@ error('Transducer and focus positions should have the size [1 2] or [1 3]') 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_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_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 source_label = zeros(grid_dims); % Label matrix for identifying individual elements + switch transducer_pars.array_shape.type + case 'annular' + % 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_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_res_mm); % Radius in grid points + + % Loop through each transducer element to create its geometry + for el_i = 1:transducer_pars.n_elements + % Create the outer bowl geometry for the current element + bowl = makeBowl(grid_dims, trans_pos, transducer_pars.radius_grid, transducer_pars.Elements_OD(el_i), focus_pos); + + % If the inner diameter is greater than zero, subtract the inner bowl geometry + if transducer_pars.Elements_ID(el_i) > 0 + bowl = bowl - makeBowl(grid_dims, trans_pos, transducer_pars.radius_grid, transducer_pars.Elements_ID(el_i), focus_pos); + end + + % Add the current element's bowl geometry to the binary mask + transducer_mask = transducer_mask + bowl; + + % Assign a unique label to this element in the source label matrix + source_label = source_label + el_i * bowl; + end + case 'matrix' + matrix_tp = transducer_pars.array_shape.matrix; + + trans_pos_m = trans_pos * grid_step_mm / 1e3; + focus_pos_m = focus_pos * grid_step_mm / 1e3; + + switch matrix_tp.matrix_shape.type + case 'define_here' + [elem_pos_m, transducer_pars] = convert_to_element_pos(parameters, transducer_pars, trans_pos_m, focus_pos_m); + case 'extract_from_file' + elem_pos_m = extract_element_pos(parameters, transducer_pars, trans_pos_m); + otherwise + error('Matrix shape %s is unknown or not implemented.', matrix_tp.matrix_shape.type) + end + + natural_focus_pos_m = trans_pos_m + [0, 0, matrix_tp.curved.curv_radius_mm / 1000]'; + + % Apply Clover setup if requested + if transducer_pars.is_clover_setup + elem_pos_m = create_clover_array(elem_pos_m, transducer_pars, trans_pos_m); + end + + transducer_pars.n_elements = size(elem_pos_m, 2); + + % Convert to grid units + elem_pos_grid = round(elem_pos_m * 1e3 / grid_step_mm); + + natural_focus_pos_grid = round(natural_focus_pos_m * 1e3 / grid_step_mm); + + % Loop through each transducer element to create its geometry + for el_i = 1:transducer_pars.n_elements + el_pos_grid_i = elem_pos_grid(:, el_i); + + r_c = matrix_tp.curved.curv_radius_mm / 1e3; + A_target = matrix_tp.elem_height_mm * matrix_tp.elem_width_mm; + a_min = 0.001; % Lower bound of aperture radius (in m) + a_max = r_c * 0.999; % Slightly less than full bowl radius + + % Define the function to solve: A_cap(a) - A_target = 0 + area_diff = @(a) 2*pi*r_c*(r_c - sqrt(r_c^2 - a^2)) - A_target; + + % Check if the function changes sign in the interval + if sign(area_diff(a_min)) == sign(area_diff(a_max)) + error('No sign change in interval: cannot find bowl diameter. Possibly A_target is out of bounds.'); + end + + a_solution = fzero(area_diff, [a_min, a_max]); + diameter = 2 * a_solution; + + % Convert to grid dimensions + r_c_grid = r_c * 1e3 / grid_step_mm; + diameter_grid = diameter * 1e3 / grid_step_mm; + + bowl = makeBowl(grid_dims, el_pos_grid_i, r_c_grid, diameter_grid, natural_focus_pos_grid); + + % Add the current element's bowl geometry to the binary mask + transducer_mask = transducer_mask + bowl; - % Loop through each transducer element to create its geometry - for el_i = 1:transducer_pars.n_elements - % Create the outer bowl geometry for the current element - bowl = makeBowl(grid_dims, trans_pos, transducer_pars.radius_grid, transducer_pars.Elements_OD(el_i), focus_pos); - - % If the inner diameter is greater than zero, subtract the inner bowl geometry - if transducer_pars.Elements_ID(el_i) > 0 - bowl = bowl - makeBowl(grid_dims, trans_pos, transducer_pars.radius_grid, transducer_pars.Elements_ID(el_i), focus_pos); - end - - % Add the current element's bowl geometry to the binary mask - transducer_mask = transducer_mask + bowl; - - % Assign a unique label to this element in the source label matrix - source_label = source_label + el_i * bowl; + % Assign a unique label to this element in the source label matrix + source_label = source_label + el_i * bowl; + end + otherwise + error('Array type %s is unknown or not implemented.', transducer_pars.array_shape.type) end end \ No newline at end of file diff --git a/simple_main.m b/simple_main.m index c57e5514..095e5023 100644 --- a/simple_main.m +++ b/simple_main.m @@ -8,6 +8,7 @@ % add paths addpath(genpath('functions')) +addpath(genpath('configs')) addpath(genpath('toolboxes')) parameters = load_parameters('tutorial_config.yaml'); % load the configuration file From 5d512b8516a94761f4206ec83f23327428916ae7 Mon Sep 17 00:00:00 2001 From: Margely Cornelissen <134381864+MaCuinea@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:14:43 +0200 Subject: [PATCH 144/144] Revert "Squashed commit of the following:" This reverts commit 551116263a7fe06718796d3b8e9767a2bf112637. --- configs/default_config.yaml | 101 +----- configs/equipment/example_coord_file.xlsx | Bin 12103 -> 0 bytes configs/tutorial_config.yaml | 109 +------ documentation/doc_parameters.md | 85 +---- documentation/doc_transducer.md | 129 +------- functions/acoustic/acoustic_analysis.m | 72 ++--- functions/core/load_parameters.m | 45 ++- functions/core/load_transducer_parameters.m | 165 ---------- functions/core/prestus_pipeline_start.m | 2 +- functions/core/print_parameter_summary.m | 110 +------ functions/core/validate_annular_transducer.m | 64 ---- functions/core/validate_matrix_transducer.m | 306 ------------------ functions/head/preproc_align_to_focal_axis.m | 10 +- functions/helper/log_timer.m | 6 +- functions/hpc/hpc_submit_job.m | 55 +--- functions/plot/plot_overlay.m | 24 +- functions/plot/plot_transducer_overlay.m | 27 +- functions/plot/show_3d_head.m | 24 +- functions/plot/show_binary_mask_transducer.m | 68 ---- functions/source/create_clover_array.m | 182 ----------- functions/source/create_matrix_karray.m | 191 ----------- functions/source/grid_transducer_location.m | 12 +- functions/source/source_create.m | 147 +++------ functions/thermal/thermal_analysis.m | 39 +-- functions/transducer/convert_to_element_pos.m | 209 ------------ functions/transducer/extract_element_pos.m | 143 -------- functions/transducer/get_arc.m | 2 +- functions/transducer/get_transducer_box.m | 56 +--- functions/transducer/transducer_setup.m | 115 ++----- simple_main.m | 1 - 30 files changed, 218 insertions(+), 2281 deletions(-) mode change 100644 => 100755 configs/default_config.yaml delete mode 100644 configs/equipment/example_coord_file.xlsx mode change 100644 => 100755 configs/tutorial_config.yaml delete mode 100644 functions/core/load_transducer_parameters.m delete mode 100644 functions/core/validate_annular_transducer.m delete mode 100644 functions/core/validate_matrix_transducer.m delete mode 100644 functions/plot/show_binary_mask_transducer.m delete mode 100644 functions/source/create_clover_array.m delete mode 100644 functions/source/create_matrix_karray.m delete mode 100644 functions/transducer/convert_to_element_pos.m delete mode 100644 functions/transducer/extract_element_pos.m diff --git a/configs/default_config.yaml b/configs/default_config.yaml old mode 100644 new mode 100755 index 26ab32a9..0c1e7654 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -69,8 +69,8 @@ modules: segmentation_only: 0 # Stop after segmentation (skip grid setup and all simulations)? 1=yes, 0=no #------------ High-performance computing + hpc: - name: 'default' # 'default' or 'snellius' gpu: '' partition: '' reservation: '' @@ -81,104 +81,21 @@ hpc: job_prefix: 'PRESTUS' # Prefix for HPC job names max_wait_checks: 540 # Maximum number of status checks when waiting for a job (1 check/20s ≈ 3 hours) -# Smallest possible allocation (https://servicedesk.surf.nl/wiki/spaces/WIKI/pages/30660209/Snellius+partitions+and+accounting) -snellius: - gpu_a100: - cores: 18 - memorylimit: 120 - timelimit: "120:00:00" # max wall time of 120 h - n_gpu: 1 - gpu_h100: - cores: 16 - memorylimit: 180 - timelimit: "120:00:00" # max wall time of 120 h - n_gpu: 1 - #------------ Target analysis + analysis: focus_area_radius: 5 # Radius of the target area around the focus in which the ISPPA is averaged for the outputs [mm] #------------ Transducer specification (no defaults) transducer: - # ------------------------------ - # General annular/matrix properties - # ------------------------------ - array_shape: - type: [] # 'annular' for Annular array or 'matrix' for Matrix array - - # -------------------------- - # Annular transducer parameters - # -------------------------- - annular: - n_elements: [] # Number of elements in the transducer [#] - Elements_ID_mm: [] # Inner diameter of each element [mm] - Elements_OD_mm: [] # Outer diameter of each element [mm] - curv_radius_mm: [] # Radius of curvature of the bowl [mm] - dist_to_plane_mm: [] # Distance from transducer plane to geometric focus [mm] - source_phase_deg: [] # Source phase of each element [deg] (0-360) - - # -------------------------- - # Matrix transducer parameters - # -------------------------- - matrix: - steering: [] # '1D' (Axial steering) or '3D' (Volumetric steering) - element_shape: [] # 'rect', 'disc', or 'bowl' - physical shape of each element - elem_height_mm: [] # Element height [mm] - elem_width_mm: [] # Element width [mm] - outer_diameter_mm: [] # Outer diameter of the transducer [mm] - is_curved: [] # true if transducer has a curved (spherical) surface, false if flat - curved: - curv_radius_mm: [] # Radius of curvature of the bowl [mm] - dist_to_plane_mm: [] # Distance from transducer plane to geometric focus [mm] - - # Optional Clover multi-aperture configuration - is_clover_setup: [] # true to enable Clover replication - clover: - n_leaves: [] # Number of leaves (1–3) - ROC_parent: [] # Radius of curvature of parent setup [mm] - - # -------------------------- - # Grid definition - # -------------------------- - matrix_shape: - type: [] # 'define_here' (define grid manually) or 'extract_from_file' (load positions from file) - - # If define_here is used: - define_here: - grid_shape: - type: [] # 'rect', 'fibonacci', 'fermat' - type of grid layout - - rect: - n_elem_row: [] # Number of rows in rectangular grid - n_elem_col: [] # Number of columns in rectangular grid - elem_spacing_height_mm: [] # Edge-to-edge spacing between rows [mm] - elem_spacing_width_mm: [] # Edge-to-edge spacing between columns [mm] - sparsity_factor: [] # Fraction of elements used (0 < value <= 1) - - fibonacci: - n_elements: [] # Total number of elements for Fibonacci grid - kerf_mm: [] # Minimum spacing between elements [mm] - - fermat: - n_elements: [] # Total number of elements for Fermat spiral grid - - # If extract_from_file is used: - extract_from_file: - file_path: [] # Path to file with element positions (x, y, z) [string] - start_row: [] # Row in file where element data starts - start_col: [] # Column in file where element data starts - n_elements: [] # Total number of elements to extract - select_random_subset: [] # true to randomly pick a subset - subset: - random_seed: [] # true to use a new seed each run, false to fix seed - subset_n_elements: [] # Number of elements in the subset - project_on_new_ROC: [] # true to project elements onto new ROC - ROC_projection: - new_ROC_mm: [] # New desired radius of curvature [mm] - - depth_mm: [] # Depth of transducer used for visualization purposes [mm] - source_amp: [] # Pressure amplitude [Pa] + n_elements: [] # Number of elements in the transducer [#] + Elements_ID_mm: [] # Inner diameter of each element [mm] + Elements_OD_mm: [] # Outer diameter of each element [mm] + curv_radius_mm: [] # Radius of curvature of the bowl [mm] + dist_to_plane_mm: [] # Distance to the transducer plane from the geometric focus [mm] + source_amp: [] # Pressure amplitude [Pa] + source_phase_deg: [] # Source phase [deg] source_freq_hz: [] # Fundamental frequency [Hz] trans_pos: [] # Transducer position [T1 grid space] focus_pos: [] # Stimulation target position[T1 grid space] diff --git a/configs/equipment/example_coord_file.xlsx b/configs/equipment/example_coord_file.xlsx deleted file mode 100644 index ce6d5455dccfb925a25d2b6437c1fae0585637bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12103 zcmeHtbySpH*Z$BAL#H$--Jx_yNGUDSF*HL-NFycPhzN=TN;gV(2@0qP(%s!X-#y^- z_~84#>$ko?et&)M%vv*R&N=(q`|N$~ea_tH+*d+ECIX>?&_N&&9msSe-O3OF1d2rl zfe1n9h>v8T4z3msu11=kjutKkoF4Xev~Q3RZ@mT~0^|St`akS}g2W!xR&Ma!LxnS$ zIgWc{Ws>Lu>w%pFtPjMS+7deQ%pPS}Tf;J*or0w^iEV^R3Hrvoc(*@Wl{wf~1%`Fh zs$)k4bTw%iQVMakcdjutl9Ipk3jO++clLvCag1xW)vclyw(&M!MHb+3TJikL*) ze>Q!jQyYJ>qZ&I;@BQtr(dH>t?FoG0Pr2W~vjc?RZLC?m40mTa6p1wc;s#iOoq$oAG-1^8jXl#Z!!5tD?k30Tl61BELh+2i8qAWLjFHf%{)|Rc&Ti^Rh zw0|qi~bjx*3(XL(Tx&9P-^G7|{X#a?Ng&G`PEo zj?pJ{eX46&#A~g8{?XHo?c`-Y-V0O^2nIs}-M^NWm0H{kdjQvzfvm#<($dJ;!p?<@ z6aM+%bo@V@ga4R%LA;7uD>rt?rovfh*WuuJELd8}T}q*rPSe+4aSX3A>MbMXc;ihf zuqMS*BzeDj-?LAnlBBLGIhOY$>>pQ-=bBFq*qu)1wsVw&N6hu+i3+^%U? zHm#BH+?zx7WtTcVKfw`ipL{3N>wQ1H3jXb$bk8;+jMn_Ft?s}Gn)egH3iYo^^1yC4 z{Sguf#Eu36;Q^lUu;X%vI@_8;p|88X>$_-JC>{KH17I$&y_qQzC z+jwM*#EZwtZjXQ;;B%sU?hm%vGr3r#IlP&haiA`K8$|0GVq-S)eb@Ejw~!@~pXvw< zXt@-ouXCd_9Eh2cidY124iP6~>O!~!P=hq<(PXG`3$oZAy5SH*Ko1fLk@g;c))RXp zFz+=*@g}3@2ZzNs#I_742&dRB=NG&_!kD>T8Ld@)!meL1eeusq@$PaLA4oDk->-pckQt!LizWJd?13MI@+u!ceMwI*`6S^UO?ZP#A+8O5d|Jb zSWPEA@?SVa>sTI=s=>7$ZB#VlYmaMvCV00UyO5XvX^!Z?t2>%QoFW}2{0c44V(N~y zzEGmzbe#uM<{|U&r?mKgH_U|<6+Bt1OQtnljJx>8bGKQ9mTnj~F-y7I5E_=7uDVU9 zK%t+lP?=&di1Fjm;C;y0g7Z7uH&$$^WUfD?*|TV=__L!Ejoh*|1D$gOh(!k1kq_k- zmsTl>CrE;xATpvmunF*~^W=#+<_o4D2*$J@?4}gz+;cLPUw`<+ezSpwO44EdKtfdV zUK%1^i+B*k0|v#Um1e#1xPj+Wz+vd1eAj-msLaAP^6kOZ?k!MtlslKAc=!3ot#M;w z`3-(n0{7G)KA{fJQvLa6X9J>16H3;5N{V+h8b5UhLaZ=Hs>x4ezs#Z^e2dnCA{&w< zn3JnoR~y)S#u|mP6+)i z@_|dWn{u=GJKsIZd}W-kIF>xZu1lv8Wo9^uPQp?Cm2{`_Ud)hNdxh(o#;K5{c0V8e z_c~Qu;{u{SJkk5u^)$B*y{Q^ zz;g7jc^Dv`n6eAxUk%Vp0YXOr^6+ZC`e!!&wRRx@TS*|B|8Ktv)K!$)xWV;U7optl zX>P>$KV7-#*R)pg(7P*GMw#jOeD@|PS!)b*e<*PwK|?$@KXfq zenM!sDS`t1y!#mm-E_T>I#3>kkYH_PHGdro_k{~#wRn6Ndj^s4!qF`z^3bEiJr#5QCqWu(g@F|xKG42kfy~3IWkwHBUj=&74D6C)D2n2E zf(Dkp)3F^j-#tLzsXK$Hcz&^MetUvFPnEAkohKmX9pXwG+9glp#}e-{|0cCoYjfIF z`t%b^>O)zIIv+`223_^Z~Cu$>kbCLsB-QKnP z`L=)N4|3M1pE**mt-Fy28pg^f+%R*IdA8pF2$*1%b4DZ30tZj?gM)_NnAs7w9^G|}5h4HJo5?7wrQXwW)I`Of!S(GG+OB_p#xgG~&1#GBzFnU^0$SdZeX`@2 z%Uz_OM^WbAGJ2^~?zxNh^>3Ec%YjR8w3sX%T)dvAU%YRUmh?_HE)!4qob0X{5j$zo z(*_r}eoL3W*g>p(G68H&?j2u67Q${jzW z$5V?eRzj+clX!|#^GzgPHAhg08U3b0t&VuJ<<#~K>@tLBzwGUL*4 zZE&nD-`*7xzE#yKm99rcmMXi=?j&AJdy|8}G_=i^pfkammvJYLIcu4p?x2fvR|nxf z0-FEPEA?^`j8d6ss1ja-uPWh;e_Mj1O^^G$?g9tyjQ{GYFG|=n(pVyxnZtEMUNLgk z&w8fegxfl^JiiiM|MUz6PQgnaCZ?}PQx4(F8nyQ$>8?HWU85-TFWBgGK@SJP%8-;C z<3FgmSb5Z*^tOFFo~IIgHp678mnwG zX2S1yw{+;}`Lfxo8M6rSA2ViZC%Q2)>HDSlG_^}+eBy5BtEGn*(Xc0@O4Ig}q*F05 zicPCeGkm=jp$E;y)^%cA9E$6B-ijV}v&Zk^zn%MyZm#BTHFzHs$LuDHe>oas;q&RWML!=Ic`s;A$%zHMwlBN{TMt$2XFknG%+P58de2{CvcoP?!#>(U7X7?SBCtbnvki)1eM=47JI^tjf~x`Cn>@$V7PWG6`)(N<#$+AFxpG#9FVv{h~oGzBzr zT0Vkv()~2Qv-*a4adtl=Ns6n+!NUa_w7q5tlA~Xv{ud2)?0=am1QULZcTsBWhwxPj zwJTC~zy7!r!DlN5Wfc1_f6UNeogw{gD=|!WiiQ>^>&g@PN1cc;koA?_vXdI^5ukr|1Z@AyF{~Hc$bUlt9+k+ru7~iN5gvhl{(nbo zhDL4cKPU}raDm$-R{YYP@prT&zFZ%VeNFKnoxQ6RbtTPglyw!&wv=9^LSFzEgzg_T zT_9aW-}RjJ!YSmMIhNPv^&dOwPdG2Jc4*FiVZrCkdEK_({{4S<)`@;uovoAn!ZueY z+u$B`roJ8XSU1M#Wn+u-C6Npo!~OKHyA|bHeE!k7RI7bvcH>7a{}j#hU)%-e8GLP? zI@jmv=bhno)?BX$9s$=_!F7ECzmehjv+9(;L~T>PY>E-fbF@X8&t?S^3(A@!A-LoYyF&4x$)OuOC3u=pbUZR{cO~VELAa95k z?|yp%t)(7JxXqglsSX*4>6tSO%k?m0&*<_}T9U5q_ISj%3`Zf0_N#;F8yl%?1V4{S z$*p*iT@QY<{wk8(lRl-p9&EP5n`EARAg%DRw%1PiVjMs*@l^GEq~c*3|8NA0ZfS$J zoTpkD5Kdi8?7>!3=2h~dn;SQP2r|RP-|oJ2PObII6sYn&1HZxIk$__N6CTHXsqc3P znfS4H0?e4MV~M6XHQE(`$V$1HgI8ZcYWjTQdCtuphIB7F)D??+7RmJc9m-;HDdGdc zWciDUhvMrQagP|*LnU10R-=ACq33!xLw>ts)q=GUvK&c8c+e3h7T1%7#bd#xp$O>? zCaVx)UVjFJAw7FS-So1#35ZHJA;A-GxV21CISbySot- z7T^1o{I(zhSuEU?WCX`gF?-{D&bDCKQJgBNDT9A0fj?ld<(%6i1iR0(@Z2=zX#xV- zCz6jKJfC0f<=#Tk1JBMjUyzv;D6W`!Q+0S9y+mEH$xLrEPG)M-wq{wH9djyC=<#Mv zKgCyxx)iw5iVwVJ>{ZR5Mzdv^HX0f z0nx3sN32>ny7YW7yqVWChDrow?%6V!b)2D755cS$6i2fx%Ft zD$o@2t4WvF*p3`%8pA5Jk_2d$Ytu9=AUP+|AxUaF4tjTXNaki>6?H-*9;AEQc#Vp7 zhbHPZR;in0&Q}{Cx9zC8wei64Bpw#01v18(s|^v+3<)6)UNe}GuL{5#9XAR7)u6k- z2Hin4qX7nGVU@;90(|^x7*>efp@(?MP`&_Vrc@?U)7t^1>HhFo>)wY;bq5bWFlJ`~ zOm(F@AnLkI5p80!TFfwbdOk+s&E{1KM(OB4d?Pp4Sl#M1H>?L2TUNCvAtg)0x*Ro+ zaz&$alnz9sOJ$+Iltl%Uc3vs7_@xYDT)=m!OpxMASuzFLBb-Z^cGN4FanugyE11S# zFzo@QvsW-KzhGRA3+`OPRNc6OA)zLt#JhyaMuXd2iPkZJ1h=_Bj@0z<7tVP=DeMX- z;1^Dyae>s8Ids3wp(7gwU*ZI#UzwAR-l2|sIV-NfD;Ej`mQn^>x-jM!OssK%!j(CU zzszAGOCz|1vB0>3@xkb5xq`9$1!EsrDs%;tPOCxh!t(g0XwG~MI1c8`{G|*37|&{f z1%~pup3k{QPsedDa?I>bx2#Q)#s%LiM>_fxa)2t@`c@|Hh;hY`DykF=QhMi96V~p6 zkNqthfTC26PK%b{F#Uyz$C(!P+`<=`vo-y*D2zRFbG*~ivYdR%*@M5v8mWw>OU{LY zi?A^6@v?DNl!yRk&A>sPdCegMi#w^_Fff3(eEujDmy~`7&u5dm+M9OQuanr@HmESRF@wUxfdNhEPW0r zQLC7^#mu#YB(y2d0DUk4%Peh_>_MR3oZyOctf|uO084$Th}30q&D?CVfcR}+wghNg zWNy~n&lNF|nhfaay_n&3dM5~MMLvp1-G|q>%dvd$MZxW+<0lR42em#)JI>Gf8C~rKn`5HW&-o#?`*?^Sw@lEz6 z5y#Y@M)1@a$AB-SoSG}Dx`1=Ohus;VeG#}P!*x062$=J-v?P4@F`8bISVKtt?Fo9R zNPtGe3%EsHmvy*Q!~`BacutRF@b1SRU;N{dgT&>-i_4G5=<$2NqeD3(rruzkGJBnD zwyIzYi|vlG_gt}+<@P1ZLEYa`UQO^{6dE^IE6`2~-F)X*)9&@Tki2eXo0cgn!aK>x z@KrA+QCd}6Pmn&wPx^Yjg-Jr~14ikC;w+hQ+P*Va+W2bj-0<#0d<)m`;VD`@Y#b(G zQxkjS?@La$!#$its{Y0&u z=&eDK%X$8JB8rad;J^nwVEzCc_5cqK;ZF=*tt{*u+}b2905Mz+l6gXZZwJe!ix z%^QB_LZoZ=E#(<)^w5n7DX-?iZAjZt@g8}%()KR1I8{f)c8uJaw8=r=yhd7=l7&e7 z_dEuSP<1Hyy%a)nep3c(GEzIDffeqXd+W( znrCEK%AT_mB3lt+GMbyB|uy-7o@2GFuJ0MrR8}A{Ry6CqEDEbiNB5rDE=m%+_dg;o(?r^!>bO zPpU;)Fbl3NSA}-Z}fW`M@q}gQg(nSUYXH$VFbp;t@BWu+q`sX>={tx*V zTdKq?$0MLh;$w(+Idl~dTf}kFFvN=`N&`17gkPTY;*C^+;lJ;kKTTD7CU9VeGmhJ6 z1qGg~jTMTs|8Re}^!a>nEX}aA>yd1JC8-KjXZ3N_mJIA*(Q4zY^SgB?qrwg4luG}f z<|pQ-L^OR3$T5-8zHI3I)GkIrd@yEAZ?4)MBQ8>@H%$PmVLXLnus#1HnAtO zt~-igSC=nwG0DkF%pdPqKUTL|Ly2oTlOdSPLcR?{%WnL)g*Cjr$$A**c>$ct5&a^) zm5H;3xrVE=wS(m)_g(v&?;-Jk{ikcEM1rbE?Zk-U?83vu6!EG8pJ3I_I?$%t@s+Fl zJo%;+d_U&cH-y~uM<>~LQpmwGJzY+U`w9jtVmKUi8$v?vuXU;RIT>3>8sbdsi;J<# z-}5SswcO$zxBWaGHn2^vqLA&KLzSLrNzJcDZqUc@rtLK}4U+wK7dcOfPw{6hvOt1F z?~w2EIfEZ-j3a@pB3qNZxMAX0MC&(otHr9eh1y$TBF&{fw` zUf-O7l;;XQc3{|7+b(WD3>CG>U7t!qZlh#+_JrX*M~m?FX;}T@ zI5}ed~yxkdN69#+qpD$ zlzqld&?O6n;UhUzWiJ{Ef`3Qk4Gg)tFCHPcE?At=V|JmZ@y{F7ysMTCU~H8 zm;n%)54a=){wJYL93B75XTW8@UM~}x9Y*0-sgMqELpGw;?3O|#wRy5ApA46PoNCQ7 zyIt8elaZeZ#EYH3|EToR{@p~^E9CR-ckB)aPdVhmx;=EtZ61^%zC0*&s(mpoWK<+x zMxW_iST-T`88>z~%KKE#?hPJzK$6ueNJ(H7zcqX}%Zp4?@BYsR*}G2n6|BdcCLhbS z77q0x3D5PE)h(3BJ91R$O!LR85b7{AxLK_}UQojOOb0F@!ohB4MP@m=Z70e)`W(Uh z?hXt7E9nwBHb$j6p|@|<8d_&|4WANw8xmD~BH44{_lV7&+SRnHEGuEg{K=9gxm8ZP zA!!jR^r?-pJuDxe|3sH@H$v2~Z06m)*q^q7c~*%sBXl2Bx_PSTOcEu$z3+*En45D= z2x6w|+^N$JJq=c}r^QVl)doltxir_YO0+KO8%7Q5%Mvj^?9t{Pf*9Vhj^0;~Eh)A7}gU^gykzG1z-M zbeSf0aQ}^W3XA27sl_2qKY+DYHwO?9Ie?|<-(Ok&D~5l4|C#T)$lTmmvH* z1yK2qtCoKX{(0f-w`d)(WBlX#*`LDKZ^`@?1%YHSFNOcZotZyz{=7Qz8_6B}ZzKNY zBE_F5fBv2Njgm|IZ~t8V3GnA$`8NO#!zIA)o%5fff9^Pbiy|^#iT=6&_!Hs!O8r|N t1j4=v0{zRn{ipc#y7*7=ah89G|EiMrl~91_f 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/functions/acoustic/acoustic_analysis.m b/functions/acoustic/acoustic_analysis.m index 342465a8..c96a1ae1 100644 --- a/functions/acoustic/acoustic_analysis.m +++ b/functions/acoustic/acoustic_analysis.m @@ -171,45 +171,17 @@ tpos_sim = parameters.transducer(ti).trans_pos; fpos_sim = parameters.transducer(ti).focus_pos; end - - trans_suffix = ''; - if n_plots > 1; trans_suffix = sprintf('_T%02d', ti); end - - if numel(parameters.grid.dims)==3 - slices = struct( ... - 'dim', {'x', 'y', 'z'}, ... - 'pos', {fpos_sim(1), fpos_sim(2), fpos_sim(3)} ... - ); - - for i = 1:numel(slices) - slice = slices(i); - - [~,~,~,~,~,~,~,h]=plot_overlay(... - acoustic_Ipa, ... - segmentation, ... - source_labels, ... - parameters, ... - {slice.dim, slice.pos}, ... - tpos_sim, ... - fpos_sim, ... - highlighted_pos); - - % Construct output filename - output_plot = fullfile( ... - parameters.io.output_dir, ... - sprintf('sub-%03d_%s_intensity%s_%s%s.png', ... - parameters.subject_id, ... - parameters.simulation.medium, ... - trans_suffix, ... - slice.dim, ... - parameters.io.output_affix) ... - ); - - % Keep original colors and save - set(h, 'InvertHardcopy', 'off'); - saveas(h, output_plot, 'png'); - close(h); - end + + if numel(parameters.grid.dims)==3 + [~,~,~,~,~,~,~,h]=plot_overlay(... + acoustic_Ipa, ... + segmentation, ... + source_labels, ... + parameters, ... + {'y', fpos_sim(2)}, ... + tpos_sim, ... + fpos_sim, ... + highlighted_pos); else h = plot_overlay_2d(... acoustic_Ipa, ... @@ -219,16 +191,14 @@ tpos_sim, ... fpos_sim, ... highlighted_pos); - - output_plot = fullfile(parameters.io.output_dir, ... - sprintf('sub-%03d_%s_intensity%s_%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); end - end -end + + 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); + end \ No newline at end of file diff --git a/functions/core/load_parameters.m b/functions/core/load_parameters.m index 0cb738d7..a0548916 100644 --- a/functions/core/load_parameters.m +++ b/functions/core/load_parameters.m @@ -55,7 +55,50 @@ 'MATLAB should run in desktop mode if parameters.simulation.interactive is enabled in PRESTUS config'); %% Transducer settings validation and derived calculations - parameters = load_transducer_parameters(parameters); + + 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'), ... + 'Source phase should be set in transducer parameters as source_phase_rad or source_phase_deg'); + parameters.transducer(t_i).source_phase_rad = parameters.transducer(t_i).source_phase_deg / 180 * pi; + end + + % Calculate distance to transducer plane if not provided + if ~isfield(parameters.transducer(t_i), 'dist_to_plane_mm') + parameters.transducer(t_i).dist_to_plane_mm = sqrt(parameters.transducer(t_i).curv_radius_mm^2 - ... + (max(parameters.transducer(t_i).Elements_OD_mm) / 2)^2); + fprintf('Distance to transducer plane is not provided, calculated as %.2f mm\n', ... + 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]); + end + + % Evaluate source phase expressions if stored as cell arrays + if iscell(parameters.transducer(t_i).source_phase_rad) + for i = 1:length(parameters.transducer(t_i).source_phase_rad) + if ~isnumeric(parameters.transducer(t_i).source_phase_rad{i}) + parameters.transducer(t_i).source_phase_rad{i} = eval(parameters.transducer(t_i).source_phase_rad{i}); + end + end + parameters.transducer(t_i).source_phase_rad = cell2mat(parameters.transducer(t_i).source_phase_rad); + end + + % Ensure source phase degrees are calculated from radians if not provided + if ~isfield(parameters.transducer(t_i), 'source_phase_deg') + parameters.transducer(t_i).source_phase_deg = parameters.transducer(t_i).source_phase_rad / pi * 180; + end + + end %% Validate thermal simulation settings % Request no timing overview here diff --git a/functions/core/load_transducer_parameters.m b/functions/core/load_transducer_parameters.m deleted file mode 100644 index 0a7f88f0..00000000 --- a/functions/core/load_transducer_parameters.m +++ /dev/null @@ -1,165 +0,0 @@ -function parameters = load_transducer_parameters(parameters) - -% LOAD_TRANSDUCER_PARAMETERS Validates and normalizes transducer parameters. -% -% This function processes the 'transducer' field of the input parameters struct. -% It validates required fields, ensures backward compatibility with legacy -% annular-only configurations, and normalizes parameters into a consistent format -% for simulation use. For matrix transducers, it validates element geometry, -% curvature, steering, grid definitions, and optional Clover or sparse grid setups. -% For annular transducers, it validates element diameters, curvature, and phases. -% The function also calculates derived values such as source phases in radians, -% element counts, and distances to the transducer plane. -% -% Input: -% parameters - Struct containing simulation parameters, expected to include -% a 'transducer' field with one or more transducer definitions. -% -% Output: -% parameters - Struct with validated and normalized transducer parameters, -% including additional derived fields such as: -% * n_elements, Elements_ID_mm, Elements_OD_mm -% * source_phase_rad, source_phase_deg -% * curv_radius_mm, dist_to_plane_mm -% * n_elem_row, n_elem_col (for matrix grids) -% * sparsity_factor, clover setup fields, etc. - - if isfield(parameters, 'transducer') - - new_transducers = struct([]); - - for t_i = 1:numel(parameters.transducer) - - tr = parameters.transducer(t_i); - - % --------------------------------------------------------------------- - % Validate and initialize transducer parameters - % --------------------------------------------------------------------- - - % Detect legacy configurations where only annular transducers are defined - if ~isfield(tr, 'array_shape') || isempty(tr.array_shape.type) - - % Create a clean structure for the new format - new_tr = struct(); - new_tr.array_shape.type = 'annular'; - new_tr.array_shape.annular = struct(); % initialize annular sub-struct; - - if isfield(tr, 'n_elements') - new_tr.array_shape.annular.n_elements = tr.n_elements; - end - - if isfield(tr, 'Elements_ID_mm') - new_tr.array_shape.annular.Elements_ID_mm = tr.Elements_ID_mm; - end - - if isfield(tr, 'Elements_OD_mm') - new_tr.array_shape.annular.Elements_OD_mm = tr.Elements_OD_mm; - end - - if isfield(tr, 'curv_radius_mm') - new_tr.array_shape.annular.curv_radius_mm = tr.curv_radius_mm; - end - - if isfield(tr, 'dist_to_plane_mm') - new_tr.array_shape.annular.dist_to_plane_mm = tr.dist_to_plane_mm; - end - - if isfield(tr, 'source_amp') - new_tr.source_amp = tr.source_amp; - end - - if isfield(tr, 'source_phase_deg') - new_tr.source_phase_deg = tr.source_phase_deg; - end - - if isfield(tr, 'source_freq_hz') - new_tr.source_freq_hz = tr.source_freq_hz; - end - - if isfield(tr, 'trans_pos') - new_tr.trans_pos = tr.trans_pos; - end - - if isfield(tr, 'focus_pos') - new_tr.focus_pos = tr.focus_pos; - end - - % Replace old transducer completely - tr = new_tr; - end - - % Supported transducer geometries: matrix and annular arrays - - % Ensure the array_shape.type field is defined - assert(isfield(tr.array_shape, 'type'),... - 'Transducer %i; Missing type field. Please specify either "matrix" or "annular".', t_i); - - switch tr.array_shape.type - - case 'matrix' - [parameters, tr] = validate_matrix_transducer(parameters, tr, t_i); - - case 'annular' - tr = validate_annular_transducer(tr, t_i); - - otherwise - error('Transducer %i; Element shape option "%s" is not implemented.', ... - tr.array_shape.type); - end - - % Validate general parameters - % Ensure source phase is set in radians or degrees - if ~isfield(tr, 'source_phase_rad') && isfield(tr, 'source_phase_deg') - tr.source_phase_rad = deg2rad(tr.source_phase_deg); - elseif ~isfield(tr, 'source_phase_rad') - error('Transducer %i; Phase must be specified as source_phase_rad or source_phase_deg.', t_i); - end - - assert(isfield(tr,'source_amp'), ... - 'Transducer %i; Missing source_amp field.', t_i); - - % Ensure source amplitude matches number of transducer elements - if numel(tr.source_amp) == 1 && tr.n_elements > 1 - tr.source_amp = repmat(tr.source_amp, [1, tr.n_elements]); - end - - % Evaluate source phase expressions if stored as cell arrays - if iscell(tr.source_phase_rad) - for p_i = 1:numel(tr.source_phase_rad) - if ~isnumeric(tr.source_phase_rad{p_i}) - tr.source_phase_rad{p_i} = eval(tr.source_phase_rad{p_i}); - end - end - tr.source_phase_rad = cell2mat(tr.source_phase_rad); - end - - % Ensure source phase degrees are calculated from radians if not provided - if ~isfield(tr, 'source_phase_deg') - tr.source_phase_deg = rad2deg(tr.source_phase_rad); - end - - if ~isfield(tr, 'depth_mm') - tr.depth_mm = 16; - end - - if t_i == 1 - new_transducers = tr; - else - new_transducers(t_i) = tr; - end - end - - parameters.transducer = new_transducers; - - % 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 - - else - % Warn user about missing transducer information - assert(confirmation_dlg('The transducer info is missing in the configuration file. Do you want to continue?', 'Yes', 'No'), ... - 'Exiting'); - end - -end \ No newline at end of file diff --git a/functions/core/prestus_pipeline_start.m b/functions/core/prestus_pipeline_start.m index 75e24ac5..0db5bcf0 100644 --- a/functions/core/prestus_pipeline_start.m +++ b/functions/core/prestus_pipeline_start.m @@ -71,7 +71,7 @@ function prestus_pipeline_start(parameters, options) job_name = hpc_job_name(parameters); % Submit job - [job_id, parameters] = hpc_submit_job(platform, temp_m_file, parameters, log_dir); + 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, ... diff --git a/functions/core/print_parameter_summary.m b/functions/core/print_parameter_summary.m index d4500687..6cd005d3 100644 --- a/functions/core/print_parameter_summary.m +++ b/functions/core/print_parameter_summary.m @@ -47,104 +47,22 @@ function print_parameter_summary(parameters) fprintf('\n'); %% 4. Transducer Specification -fprintf('🎯 TRANSDUCER SPECIFICATION\n'); - -% Loop over all transducers (support multiple transducers) -for t_i = 1:numel(parameters.transducer) - tr = parameters.transducer(t_i); - - % Determine type - tr_type = tr.array_shape.type; - - fprintf('Transducer %d (%s):\n', t_i, tr_type); - - switch tr_type - case 'annular' - ann = tr.array_shape.annular; - - print_if_field(ann, 'n_elements', '%d'); - print_if_field(ann, 'curv_radius_mm', '%.0f mm'); - print_if_field(ann, 'source_phase_deg', '%.1f deg.'); - - case 'matrix' - mat = tr.array_shape.matrix; - - fprintf(' Steering: %s\n', mat.steering); - fprintf(' Element shape: %s\n', mat.element_shape); - print_if_field(mat, 'elem_height_mm', '%.3f mm'); - print_if_field(mat, 'elem_width_mm', '%.3f mm'); - print_if_field(mat, 'outer_diameter_mm', '%.2f mm'); - print_if_field(mat, 'is_curved', '%d'); - - if mat.is_curved && isfield(mat, 'curved') - print_if_field(mat.curved, 'curv_radius_mm', '%.2f mm'); - end - % Clover setup - if isfield(mat, 'is_clover_setup') && mat.is_clover_setup - fprintf(' Clover setup enabled:\n'); - print_if_field(mat.clover, 'n_leaves', '%d'); - print_if_field(mat.clover, 'ROC_parent', '%.2f mm'); - end - - % Matrix shape type - shape_type = mat.matrix_shape.type; - fprintf(' Matrix shape type: %s\n', shape_type); - - switch shape_type - case 'define_here' - define_here = mat.matrix_shape.define_here; - grid_type = define_here.grid_shape.type; - fprintf(' Grid type: %s\n', grid_type); - - switch grid_type - case 'rect' - rect_grid = define_here.grid_shape.rect; - fprintf(' Rectangular grid:\n'); - print_if_field(rect_grid, 'n_elem_row', '%d'); - print_if_field(rect_grid, 'n_elem_col', '%d'); - print_if_field(rect_grid, 'elem_spacing_height_mm', '%.2f mm'); - print_if_field(rect_grid, 'elem_spacing_width_mm', '%.2f mm'); - print_if_field(rect_grid, 'sparsity_factor', '%.2f'); - - case 'fibonacci' - fib_grid = define_here.grid_shape.fibonacci; - fprintf(' Fibonacci grid:\n'); - print_if_field(fib_grid, 'n_elements', '%d'); - - case 'fermat' - fermat_grid = define_here.grid_shape.fermat; - fprintf(' Fermat spiral grid:\n'); - print_if_field(fermat_grid, 'n_elements', '%d'); - end - - case 'extract_from_file' - ext = mat.matrix_shape.extract_from_file; - print_if_field(ext, 'file_path', '%s'); - print_if_field(ext, 'start_row', '%d'); - print_if_field(ext, 'start_col', '%d'); - print_if_field(ext, 'n_elements', '%d'); - print_if_field(ext, 'select_random_subset', '%d'); - if ext.select_random_subset - print_if_field(ext.subset, 'random_seed', '%d'); - print_if_field(ext.subset, 'subset_n_elements', '%d'); - end - print_if_field(ext, 'project_on_new_ROC', '%d'); - if ext.project_on_new_ROC - print_if_field(ext.ROC_projection, 'new_ROC_mm', '%.2f mm'); - end - end - end - - print_if_field(tr, 'source_freq_hz', '%.1f Hz'); - print_if_field(tr, 'source_amp', '%.1f Pa'); - print_if_field(tr, 'trans_pos', '[%.1f %.1f %.1f]'); - print_if_field(tr, 'focus_pos', '[%.1f %.1f %.1f]'); - print_if_field(parameters, 'expected_focal_distance_ep', '%.1f mm'); - print_if_field(parameters, 'expected_focal_distance_bowl', '%.1f mm'); - - fprintf('\n'); +fprintf('🎯 TRANSDUCER SPECIFICATION\n'); +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(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) diff --git a/functions/core/validate_annular_transducer.m b/functions/core/validate_annular_transducer.m deleted file mode 100644 index 20132653..00000000 --- a/functions/core/validate_annular_transducer.m +++ /dev/null @@ -1,64 +0,0 @@ -function tr = validate_annular_transducer(tr, t_i) -% VALIDATE_ANNULAR_TRANSDUCER Validates configuration of an annular transducer. -% -% This function checks that all required fields for an annular transducer -% definition are present in the input transducer structure. -% -% Input: -% tr - Struct containing a single transducer definition. -% t_i - Index of the transducer in the configuration (used for error messages). -% -% Output: -% (None) - - assert(isfield(tr.array_shape, 'annular'), ... - 'Transducer %i; Appropriate configuration for annular transducer is missing.', t_i); - - annular_tr = tr.array_shape.annular; - - % Validate required geometric parameters - assert(isfield(annular_tr, 'Elements_ID_mm'), ... - 'Transducer %i; Missing Elements_ID_mm for annular transducer. This defines inner diameters of elements.', t_i); - - assert(isfield(annular_tr, 'Elements_OD_mm'), ... - 'Transducer %i; Missing Elements_OD_mm for annular transducer. This defines outer diameters of elements.', t_i); - - assert(isfield(annular_tr, 'n_elements'), ... - 'Transducer %i; Missing n_elements parameter for annular transducer. This defines element count.', t_i); - - tr.n_elements = annular_tr.n_elements; - - assert(numel(annular_tr.Elements_ID_mm) == annular_tr.n_elements, ... - 'Transducer %i; Elements_ID_mm length must match n_elements.', t_i); - - assert(numel(annular_tr.Elements_OD_mm) == annular_tr.n_elements, ... - 'Transducer %i; Elements_OD_mm length must match n_elements.', t_i); - - % Validate inner/outer diameter ordering - assert(all(annular_tr.Elements_OD_mm > annular_tr.Elements_ID_mm), ... - 'Transducer %i; Outer diameter must be larger than inner diameter for all elements.', t_i); - - % Validate curvature definition - assert(isfield(annular_tr, 'curv_radius_mm'), ... - 'Transducer %i; Missing curv_radius_mm field for annular transducer. Please specify radius of curvature.', t_i); - - tr.curv_radius_mm = annular_tr.curv_radius_mm; - - % 3D steering unavailable for annular arrays → align with focus - tr.align_transducer_with_focus = true; - - % Calculate distance to transducer plane if not provided - if ~isfield(tr, 'dist_to_plane_mm') - assert(annular_tr.curv_radius_mm > max(annular_tr.Elements_OD_mm)/2, ... - 'Transducer %i; curv_radius_mm must exceed aperture radius.', t_i); - - tr.dist_to_plane_mm = sqrt(annular_tr.curv_radius_mm^2 - ... - (max(annular_tr.Elements_OD_mm) / 2)^2); - - fprintf('Transducer %i; Distance to transducer plane is not provided, calculated as %.2f mm\n', ... - t_i, tr.dist_to_plane_mm); - else - tr.dist_to_plane_mm = annular_tr.dist_to_plane_mm; - end - -end \ No newline at end of file diff --git a/functions/core/validate_matrix_transducer.m b/functions/core/validate_matrix_transducer.m deleted file mode 100644 index c9388dda..00000000 --- a/functions/core/validate_matrix_transducer.m +++ /dev/null @@ -1,306 +0,0 @@ -function [parameters, tr] = validate_matrix_transducer(parameters, tr, t_i) -% VALIDATE_MATRIX_TRANSDUCER Validates configuration of a matrix transducer. -% -% This function checks that all required fields for a matrix transducer -% definition are present in the input transducer structure. - -% The function also supports optional configurations such as Clover array -% layouts, sparse Fibonacci grids, random element subsets, and projection -% of element positions onto a new radius of curvature. -% -% After validation, the relevant parameters are copied to standardized -% fields used throughout the simulation pipeline. Additional derived values -% such as the number of elements and equivalent annular element diameters -% (for visualization compatibility) are calculated. -% -% Input: -% parameters - Struct containing global simulation parameters. -% tr - Struct containing a single transducer definition. -% t_i - Index of the transducer in the configuration (used for -% informative error messages). -% -% Output: -% parameters - Updated parameters struct (e.g., enabling kWaveArray). -% tr - Transducer struct with validated and normalized fields, -% including geometry, grid configuration, and derived values. - - matrix_tr = tr.array_shape.matrix; - - fprintf('Matrix transducer detected. Using kWaveArray (set to 1).\n'); - parameters.use_kWaveArray = 1; - - % --------------------------------------------------------------------- - % Steering configuration - % Determines whether the array orientation follows the acoustic focus. - % --------------------------------------------------------------------- - assert(isfield(matrix_tr, 'steering'), ... - 'Transducer %i; Missing steering definition. Choose "1D" or "3D".', t_i); - - switch matrix_tr.steering - case '1D' - tr.align_transducer_with_focus = true; - case '3D' - tr.align_transducer_with_focus = false; - otherwise - error('Transducer %i; Steering option "%s" is not implemented.', ... - t_i, matrix_tr.steering); - end - - % --------------------------------------------------------------------- - % Element geometry - % Defines the physical dimensions of individual matrix elements. - % --------------------------------------------------------------------- - assert(isfield(matrix_tr, 'element_shape'), ... - 'Transducer %i; Missing element_shape field for matrix array. Please specify "rect", "disc" or "bowl".', t_i); - - assert(isfield(matrix_tr, 'elem_height_mm'), ... - 'Transducer %i; Missing elem_height parameter. Please specify to define height of each element.', t_i); - - assert(isfield(matrix_tr, 'elem_width_mm'), ... - 'Transducer %i; Missing elem_width parameter. Please specify to define width of each element.', t_i); - - % Validate outer diameter - assert(isfield(matrix_tr, 'outer_diameter_mm'), ... - 'Transducer %i; Missing outer_diameter_mm parameter for matrix array. Please specify to define overall size.', t_i); - - assert(matrix_tr.outer_diameter_mm > 0) - - % --------------------------------------------------------------------- - % Curvature definition - % Determines whether the array lies on a spherical surface. - % --------------------------------------------------------------------- - assert(isfield(matrix_tr, 'is_curved'), ... - 'Transducer %i; Missing is_curved field for matrix transducer. Please specify.', t_i); - - if matrix_tr.is_curved - assert(isfield(matrix_tr.curved, 'curv_radius_mm'), ... - 'Transducer %i; Missing curv_radius_mm field for matrix transducer. Please specify radius of curvature.', t_i); - - % Calculate distance to transducer plane if not provided - if ~isfield(matrix_tr.curved, 'dist_to_plane_mm') - assert(matrix_tr.curved.curv_radius_mm > matrix_tr.outer_diameter_mm/2, ... - 'Transducer %i; curv_radius_mm must exceed aperture radius.', t_i); - - tr.dist_to_plane_mm = sqrt(matrix_tr.curved.curv_radius_mm^2 - ... - (matrix_tr.outer_diameter_mm / 2)^2); - - fprintf('Transducer %i; Distance to transducer plane is not provided, calculated as %.2f mm\n', ... - t_i, matrix_tr.curved.dist_to_plane_mm); - else - tr.dist_to_plane_mm = matrix_tr.curved.dist_to_plane_mm; - end - - - else - matrix_tr.curved.curv_radius_mm = inf; - - % For a flat transducer the distance to the focal plane approaches - % infinity. A finite value is assigned here for visualization purposes. - tr.dist_to_plane_mm = 70; - end - - tr.curv_radius_mm = matrix_tr.curved.curv_radius_mm; - - % --------------------------------------------------------------------- - % Optional Clover multi-aperture configuration - % --------------------------------------------------------------------- - if isfield(matrix_tr, 'is_clover_setup') - - if matrix_tr.is_clover_setup - assert(isfield(matrix_tr, 'clover'), ... - 'Transducer %i; Missing additional information of Clover setup.', t_i); - - assert(isfield(matrix_tr.clover, 'n_leaves'), ... - 'Transducer %i; Missing additional information of Clover setup. Define number of leaves.', t_i); - - assert(isfield(matrix_tr.clover, 'ROC_parent'), ... - 'Transducer %i; Missing additional information of Clover setup. Define ROC of parent.', t_i); - end - else - % Clover array configuration not provided; skip Clover setup - matrix_tr.is_clover_setup = false; - end - - % --------------------------------------------------------------------- - % Matrix grid definition - % Elements can be defined directly or extracted from an external file. - % --------------------------------------------------------------------- - assert(isfield(matrix_tr.matrix_shape, 'type'), ... - 'Transducer %i; Missing matrix_shape type field for matrix transducer. Please specify "define_here" or "extract_from_file"', t_i); - - switch matrix_tr.matrix_shape.type - case 'define_here' - assert(isfield(matrix_tr.matrix_shape, 'define_here'), ... - 'Transducer %i; Missing define_here field and properties for matrix transducer. Please specify.', t_i); - - define_shape_here = matrix_tr.matrix_shape.define_here; - - assert(isfield(define_shape_here, 'grid_shape'), ... - 'Transducer %i; Missing grid_shape field and properties for matrix transducer. Please specify.', t_i); - - grid_shape = define_shape_here.grid_shape; - - assert(isfield(grid_shape, 'type'), ... - 'Transducer %i; Missing grid_shape type field and properties for matrix transducer. Please specify.', t_i); - - switch grid_shape.type - case 'rect' - assert(isfield(grid_shape, 'rect'), ... - 'Transducer %i; Missing rect field in grid_shape for rectangular grid configuration.', t_i); - - rect_grid = grid_shape.rect; - - % Validate required parameters for grid - assert(isfield(rect_grid, 'n_elem_row'), ... - 'Transducer %i;: Missing n_elem_row parameter for grid. Please specify to define number of rows.', t_i); - - assert(isfield(rect_grid, 'n_elem_col'), ... - 'Transducer %i; Missing n_elem_col parameter for grid. Please specify to define number of columns.', t_i); - - assert(isfield(rect_grid, 'elem_spacing_height_mm'), ... - 'Transducer %i; Missing elem_spacing_height parameter for grid. Please specify to define height spacing between elements.', t_i); - - assert(isfield(rect_grid, 'elem_spacing_width_mm'), ... - 'Transducer %i; Missing elem_spacing_width parameter for grid. Please specify to define width spacing between elements.', t_i); - - % Validate spacing positivity - assert(rect_grid.elem_spacing_height_mm > 0) - assert(rect_grid.elem_spacing_width_mm > 0) - - % Total physical span of the array - tran_width = (rect_grid.n_elem_width * matrix_tr.elem_width_mm) + ... - ((rect_grid.n_elem_width - 1) * rect_grid.elem_spacing_width_mm); - - tran_height = (rect_grid.n_elem_height * matrix_tr.elem_height_mm) + ... - ((rect_grid.n_elem_height - 1) * rect_grid.elem_spacing_height_mm); - - % Distance of the array side from the center - grid_radius = max(tran_width/2, tran_height/2); - - aperture_radius = matrix_tr.outer_diameter_mm / 2; - - assert(grid_radius <= aperture_radius, ... - ['Transducer %i; Rectangular grid exceeds outer_diameter_mm. ' ... - 'Grid span: %.2f x %.2f mm, aperture diameter: %.2f mm.'], ... - t_i, tran_width, tran_height, matrix_tr.outer_diameter_mm); - - assert(isfield(rect_grid, 'sparsity_factor'), ... - 'Transducer %i; Missing sparsity_factor parameter for grid. Please specify to define % of used elements.', t_i); - - assert(rect_grid.sparsity_factor > 0 && rect_grid.sparsity_factor <= 1) - - % Extract grid dimensions - n_elem_row = rect_grid.n_elem_row; - n_elem_col = rect_grid.n_elem_col; - - % Calculate initial element count (will be adjusted later for circular cutout) - tr.n_elements = n_elem_col * n_elem_row; - - case 'fibonacci' - % Sparse spiral grid configuration - - % Validate required parameters for sparser grid - assert(isfield(grid_shape, 'fibonacci'), ... - 'Transducer %i; Missing fibonacci field in grid_shape for sparser grid configuration.', t_i); - - assert(isfield(grid_shape.fibonacci, 'n_elements'), ... - 'Transducer %i; Missing n_elements parameter for grid. Please specify to define number of elements.', t_i); - - assert(isfield(grid_shape.fibonacci, 'kerf_mm'), ... - 'Transducer %i; Missing kerf_mm parameter for grid. Please specify.', t_i); - - tr.n_elements = grid_shape.fibonacci.n_elements; - - case 'fermat' - % Sparse spiral grid configuration - - % Validate required parameters for sparser grid - assert(isfield(grid_shape, 'fermat'), ... - 'Transducer %i; Missing fermat field in grid_shape for sparser grid configuration.', t_i); - - assert(isfield(grid_shape.fermat, 'n_elements'), ... - 'Transducer %i; Missing n_elements parameter for grid. Please specify to define number of elements.', t_i); - - tr.n_elements = grid_shape.fermat.n_elements; - - otherwise - error('Transducer %i; Grid shape type "%s" is not implemented.', ... - t_i, grid_shape.type); - end - - case 'extract_from_file' - assert(isfield(matrix_tr.matrix_shape, 'extract_from_file'), ... - 'Transducer %i; Missing extract_from_file field and properties for matrix transducer. Please specify.', t_i); - - extract_shape_from_file = matrix_tr.matrix_shape.extract_from_file; - - assert(isfield(extract_shape_from_file, 'file_path'), ... - 'Transducer %i; Missing file_path to extract element positions from. Please specify.', t_i); - - file_path = extract_shape_from_file.file_path; - - resolved_path = which(file_path); - - assert(~isempty(resolved_path), ... - 'Transducer %i; File "%s" does not exist or is not on the MATLAB path.', t_i, file_path); - - extract_shape_from_file.file_path = resolved_path; - - assert(isfield(extract_shape_from_file, 'start_row'), ... - 'Transducer %i; Missing start_row parameter. Please specify start row of element positions.', t_i); - - assert(isfield(extract_shape_from_file, 'start_col'), ... - 'Transducer %i; Missing start_col parameter. Please specify start column of element positions.', t_i); - - assert(isfield(extract_shape_from_file, 'n_elements'), ... - 'Transducer %i; Missing n_elements. Please specify number of elements.', t_i); - - tr.n_elements = extract_shape_from_file.n_elements; - - if isfield(extract_shape_from_file, 'select_random_subset') - - if extract_shape_from_file.select_random_subset - assert(isfield(extract_shape_from_file, 'subset'), ... - 'Transducer %i; Missing subset parameters. Please specify subset properties.', t_i); - - assert(isfield(extract_shape_from_file.subset, 'random_seed'), ... - 'Transducer %i; Missing random_seed parameter. Please specify if a random subseed for subset selection is desired.', t_i); - - assert(isfield(extract_shape_from_file.subset, 'subset_n_elements'), ... - 'Transducer %i; Missing subset_n_element parameter. Please specify subset size.', t_i); - - end - else - % Random element subset not specified; use full element set - extract_shape_from_file.select_random_subset = false; - end - - if isfield(extract_shape_from_file, 'project_on_new_ROC') - - if extract_shape_from_file.project_on_new_ROC - assert(isfield(extract_shape_from_file, 'ROC_projection'), ... - 'Transducer %i; Missing ROC_projection parameters. Please specify to define ROC to project on.', t_i); - - assert(isfield(extract_shape_from_file.ROC_projection, 'new_ROC_mm'), ... - 'Transducer %i; Missing new_ROC_mm parameter. Please specify new desired ROC.', t_i); - - end - else - % No projection configuration provided; element positions remain unchanged - extract_shape_from_file.project_on_new_ROC = false; - end - - matrix_tr.matrix_shape.extract_from_file = extract_shape_from_file; - - otherwise - error('Transducer %i; Matrix shape option "%s" is not implemented.', ... - t_i, matrix_tr.matrix_shape.type); - end - - - % Initialize element phases to zero degrees by default. - % Phase delays are later adjusted based on the defined focus. - tr.source_phase_deg = repmat(0, [1, tr.n_elements]); - - tr.array_shape.matrix = matrix_tr; -end \ No newline at end of file diff --git a/functions/head/preproc_align_to_focal_axis.m b/functions/head/preproc_align_to_focal_axis.m index 5b30fc1f..236fefab 100644 --- a/functions/head/preproc_align_to_focal_axis.m +++ b/functions/head/preproc_align_to_focal_axis.m @@ -55,15 +55,7 @@ %% Step 1: Compute focal axis % The focal axis is defined as a vector from `trans_pos_grid` to `focus_pos_grid`. - % [Multi-transducer] the preprocessing will be based on the first transducer - if parameters.transducer(1).align_transducer_with_focus - focal_axis = [focus_pos_grid - trans_pos_grid, 1]'; - else - % Use the natural focus based on transducer curvature - curv_radius = parameters.transducer(1).array_shape.matrix.curv_radius_mm; - natural_focus = trans_pos_grid + [0, 0, curv_radius / parameters.grid_step_mm]; - focal_axis = [natural_focus - trans_pos_grid, 1]'; - end + focal_axis = [focus_pos_grid - trans_pos_grid, 1]'; %% Step 2: Compute rotation angles % Rotate around the y-axis first to align focal axis with z-axis projection in x-z plane diff --git a/functions/helper/log_timer.m b/functions/helper/log_timer.m index a8024181..c54d4dfe 100644 --- a/functions/helper/log_timer.m +++ b/functions/helper/log_timer.m @@ -75,10 +75,8 @@ free_bytes = str2double(strtrim(out_free)); elseif ispc drive = monitor_path(1); - driveLetter = char(drive); - driveLetter = driveLetter(1); - [~, out_used] = system(sprintf('powershell -c "(Get-PSDrive ''%c'').Used"', driveLetter)); - [~, out_free] = system(sprintf('powershell -c "(Get-PSDrive ''%c'').Free"', driveLetter)); + [~, out_used] = system(sprintf('powershell -c "(Get-PSDrive ''%c'').Used"', drive)); + [~, out_free] = system(sprintf('powershell -c "(Get-PSDrive ''%c'').Free"', drive)); used_bytes = str2double(strtrim(out_used)); free_bytes = str2double(strtrim(out_free)); else diff --git a/functions/hpc/hpc_submit_job.m b/functions/hpc/hpc_submit_job.m index d823818f..462a1896 100644 --- a/functions/hpc/hpc_submit_job.m +++ b/functions/hpc/hpc_submit_job.m @@ -1,4 +1,4 @@ -function [job_id, parameters] = hpc_submit_job(hpc_type, temp_m_file, parameters, log_dir) +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 @@ -21,17 +21,6 @@ switch hpc_type case 'slurm' temp_slurm_path = fullfile(log_dir, sprintf('temp_slurm_%s.sh', datestr(now, 'yyyymmdd_HHMMSS'))); - - % Define HPC type - if ~isfield(parameters.hpc, 'name') - parameters.hpc.name = 'default'; - end - - % Overwrite and extract additional parameters if Snellius HPC - if strcmp(parameters.hpc.name, 'snellius') - [parameters] = extract_snellius_parameters(parameters); - end - write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir); job_id = submit_slurm_job(temp_slurm_path, log_dir); @@ -50,7 +39,7 @@ 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); @@ -64,9 +53,7 @@ function write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir) fprintf(fid, '#SBATCH --partition=gpu\n'); end - if strcmp(parameters.hpc.name, 'snellius') - fprintf(fid, '#SBATCH --gpus=%i\n', parameters.hpc.n_gpu); - elseif isfield(parameters.hpc, 'gpu') && ~isempty(strtrim(char(parameters.hpc.gpu))) + 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'); @@ -76,10 +63,6 @@ function write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir) fprintf(fid, '#SBATCH --reservation=%s\n', strtrim(char(parameters.hpc.reservation))); end - if strcmp(parameters.hpc.name, 'snellius') - fprintf(fid, '#SBATCH --cpus-per-task=%i\n', parameters.hpc.cores); - 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); @@ -87,39 +70,11 @@ function write_slurm_script(temp_slurm_path, parameters, temp_m_file, log_dir) fprintf(fid, '#SBATCH --chdir=%s\n', log_dir); if needs_gpu, fprintf(fid, 'nvidia-smi\n'); end - if strcmp(parameters.hpc.name, 'snellius') - fprintf(fid, 'module load 2024\n'); - fprintf(fid, 'module load MATLAB/2024b\n'); - else - fprintf(fid, 'module load matlab/R2023b\n'); - end + fprintf(fid, 'module load matlab/R2023b\n'); fprintf(fid, 'matlab -batch "%s"\n', temp_m_file); fclose(fid); end -function [parameters] = extract_snellius_parameters(parameters) - - switch parameters.hpc.partition - case 'gpu_a100' - parameters.hpc.memorylimit = parameters.snellius.gpu_a100.memorylimit; - parameters.hpc.cores = parameters.snellius.gpu_a100.cores; - max_timelimit = parameters.snellius.gpu_a100.timelimit; - parameters.hpc.n_gpu = parameters.snellius.gpu_a100.n_gpu; - case 'gpu_h100' - parameters.hpc.memorylimit = parameters.snellius.gpu_h100.memorylimit; - parameters.hpc.cores = parameters.snellius.gpu_h100.cores; - max_timelimit = parameters.snellius.gpu_h100.timelimit; - parameters.hpc.n_gpu = parameters.snellius.gpu_h100.n_gpu; - otherwise - error('GPU %s is unknown or not implemented for Snellius.', parameters.hpc.partition) - end - - assert(parameters.hpc.timelimit <= max_timelimit, ... - 'Maximum wall time of %s is exceeded (%s).', ... - max_timelimit, ... - parameters.hpc.timelimit) -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); @@ -145,7 +100,7 @@ function write_qsub_script(temp_qsub_path, parameters, temp_m_file, log_dir) 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, 'module load matlab/R2023b\n'); fprintf(fid, 'matlab -batch "%s"\n', temp_m_file); fclose(fid); end diff --git a/functions/plot/plot_overlay.m b/functions/plot/plot_overlay.m index 87507b37..57793bbe 100644 --- a/functions/plot/plot_overlay.m +++ b/functions/plot/plot_overlay.m @@ -99,7 +99,6 @@ end if ~isempty(trans_pos) trans_pos = trans_pos(2:3); - natural_focus = trans_pos + [0, parameters.transducer(1).curv_radius_mm / parameters.grid.resolution_mm]; end max_data_pos = max_data_pos(2:3); elseif slice{1} == 'y' @@ -109,17 +108,15 @@ end if ~isempty(trans_pos) trans_pos = trans_pos([1,3]); - natural_focus = trans_pos + [0, parameters.transducer(1).curv_radius_mm / parameters.grid.resolution_mm]; end max_data_pos = max_data_pos([1,3]); elseif slice{1} == 'z' slice_z = slice{2}; if ~isempty(focus_pos) - focus_pos = focus_pos(1:2); + focus_pos = focus_pos(1:2); end if ~isempty(trans_pos) - trans_pos = trans_pos([1,2]); - natural_focus = trans_pos + [0, 0]; + trans_pos = trans_pos([1,2]); end max_data_pos = max_data_pos([1,2]); else @@ -161,11 +158,10 @@ if options.rotation R = [cosd(options.rotation) -sind(options.rotation); sind(options.rotation) cosd(options.rotation)]; if ~isempty(focus_pos) - focus_pos = round(R*double(focus_pos')); + focus_pos = round(R*double(focus_pos')); end if ~isempty(trans_pos) trans_pos = round(R*double(trans_pos')); - natural_focus = round(R*double(natural_focus')); end max_data_pos = round(R*double(max_data_pos')); overlay_image = imrotate(overlay_image, options.rotation); @@ -193,7 +189,7 @@ % 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, natural_focus, max_data_pos, options, 0.3, [0.2 0.6 1]) + plot_transducer_overlay(parameters, trans_pos, focus_pos, max_data_pos, options, 0.3, [0.2 0.6 1]) end if options.overlay_segmented @@ -257,17 +253,7 @@ end ax2.Position = ax1.Position; - - child = ax1.Children(end); - - if isa(child, 'matlab.graphics.primitive.Image') - ax2_colour = child.CData(1,1,:); - elseif isa(child, 'matlab.graphics.chart.primitive.Line') - ax2_colour = child.Color; - else - error('Unknown child type'); - end - + ax2_colour = ax1.Children(length(ax1.Children)).CData(1,1,:); if any(ax2_colour > 1) ax2_colour = zeros(size(ax2_colour)); end diff --git a/functions/plot/plot_transducer_overlay.m b/functions/plot/plot_transducer_overlay.m index cb1d76d3..31d38286 100644 --- a/functions/plot/plot_transducer_overlay.m +++ b/functions/plot/plot_transducer_overlay.m @@ -1,11 +1,10 @@ -function plot_transducer_overlay(parameters, trans_pos, focus_pos, natural_foc, max_data_pos, options, overlay_weight, overlay_color) +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) -% natural_foc - [2x1] Natural 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 @@ -16,29 +15,11 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, natural_foc, end %% 1. GEOMETRY CALCULATIONS (curved transducer details) -%% Compute focal slope -% Calculate unit vector pointing from focus to transducer -if parameters.transducer(1).align_transducer_with_focus - focal_slope = (trans_pos(:) - focus_pos(:)) / norm(trans_pos(:) - focus_pos(:)); - -else - focal_slope = (trans_pos(:) - natural_foc(:)) / norm(trans_pos(:) - natural_foc(:)); -end - +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; -%% Compute distance to exit plane -% Maximum outer diameter of transducer elements -switch parameters.transducer(1).array_shape.type - case 'annular' - max_od = max(parameters.transducer(1).array_shape.annular.Elements_OD_mm); - case 'matrix' - max_od = parameters.transducer(1).array_shape.matrix.outer_diameter_mm; - otherwise - error('Array type %s is unknown or not implemented.', parameters.transducer(1).array_shape.type) -end - +max_od = max(parameters.transducer(1).Elements_OD_mm); r = max_od / 2 / grid_step; % Exit plane positions (two methods for consistency) @@ -104,7 +85,7 @@ function plot_transducer_overlay(parameters, trans_pos, focus_pos, natural_foc, end %% 5. DETAILED TRANSDUCER GEOMETRY (always shown) -trans_full_depth = parameters.transducer(1).depth_mm / grid_step; +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) diff --git a/functions/plot/show_3d_head.m b/functions/plot/show_3d_head.m index c3bab107..10b2725e 100644 --- a/functions/plot/show_3d_head.m +++ b/functions/plot/show_3d_head.m @@ -110,30 +110,10 @@ function show_3d_head(segmented_img, target_xyz, trans_xyz, parameters, pixel_si c = color_list(k,:); % Plot transducer exit plane if not cropped if ~any(crop_at_target) - % Maximum outer diameter of transducer elements - switch parameters.transducer(1).array_shape.type - case 'annular' - max_od_mm = max(parameters.transducer(1).Elements_OD_mm); - case 'matrix' - max_od_mm = parameters.transducer(1).array_shape.matrix.outer_diameter_mm; - otherwise - error('Array type %s is unknown or not implemented.', parameters.transducer(1).array_shape.type) - end - % All shapes in downsampled space + max_od_mm = max(parameters.transducer(1).Elements_OD_mm); max_od_grid = max_od_mm / pixel_size; - - if parameters.transducer(1).align_transducer_with_focus - norm_vec = (thisTrans - thisTarg) / norm(thisTrans - thisTarg); - - else - % Use the natural focus based on transducer curvature - curv_radius = parameters.transducer(1).array_shape.matrix.curv_radius_mm; - natural_focus = trans_pos_grid + [0, 0, curv_radius / parameters.grid_step_mm]; - natural_focus_grid = natural_focus / pixel_size; - - norm_vec = (thisTrans - natural_focus_grid) / norm(thisTrans - natural_focus_grid); - end + norm_vec = (thisTrans - thisTarg) / norm(thisTrans - thisTarg); % Geometric focus in grid space geom_focus = thisTrans - norm_vec * (parameters.transducer(1).curv_radius_mm) / pixel_size; diff --git a/functions/plot/show_binary_mask_transducer.m b/functions/plot/show_binary_mask_transducer.m deleted file mode 100644 index d1f5b492..00000000 --- a/functions/plot/show_binary_mask_transducer.m +++ /dev/null @@ -1,68 +0,0 @@ -function show_binary_mask_transducer(karray, kgrid, parameters) -% SHOW_BINARY_MASK_TRANSDUCER Visualizes the binary mask of a transducer in the simulation grid. -% -% The resulting figure is saved to the debug directory defined in the -% simulation parameters for inspection and debugging purposes. -% -% Input: -% karray - kWaveArray object defining the transducer geometry. -% kgrid - kWaveGrid object describing the simulation grid. -% parameters - Struct containing simulation parameters. -% -% Output: -% (none) The generated visualization is saved to disk. - - % Generate binary mask of transducer elements on the simulation grid - source_mask = karray.getArrayBinaryMask(kgrid); - - % Construct base filename for saved figures - base_filename = sprintf('sub-%03d_%s_binary_transducer_mask%s', ... - parameters.subject_id, parameters.simulation.medium, ... - parameters.io.output_affix); - - if numel(parameters.grid.dims) == 3 - - % ----------------------------------------------------------------- - % 3D visualization using isosurface - % ----------------------------------------------------------------- - - h = figure('Name', '3D Transducer Binary Mask'); - hold on; - - % Extract isosurface from binary mask - p = patch(isosurface(source_mask, 0.5)); % 0.5 is the threshold for binary mask - isonormals(source_mask, p); % Add surface normals for better visualization - - set(p, 'FaceColor', 'r', 'EdgeColor', 'none'); - - camlight; - lighting gouraud; - - axis equal; - xlabel('x-grid'); ylabel('y-grid'); zlabel('z-grid'); - title('3D Visualization of Transducer Binary Mask'); - - % Save figure - saveas(h, fullfile(parameters.io.debug_dir, [base_filename '.fig'])); - saveas(h, fullfile(parameters.io.debug_dir, [base_filename '.png'])); - - close(h); - else - % ----------------------------------------------------------------- - % 2D visualization - % ----------------------------------------------------------------- - - h = figure('Name', 'Transducer in Simulation Grid'); - - imagesc(source_mask); - axis equal tight; - colormap(hot); - colorbar; - title('Transducer Binary Mask in Simulation Grid'); - - % Save figure - saveas(h, fullfile(parameters.io.debug_dir, [base_filename '.png'])); - - close(h); - end -end \ No newline at end of file diff --git a/functions/source/create_clover_array.m b/functions/source/create_clover_array.m deleted file mode 100644 index d50696e7..00000000 --- a/functions/source/create_clover_array.m +++ /dev/null @@ -1,182 +0,0 @@ -function elem_pos_m = create_clover_array(parameters, matrix_tp, elem_pos_m, trans_pos_m, focus_pos_m) -% CREATE_CLOVER_ARRAY Generate a multi-leaf clover transducer configuration. -% -% This function replicates a single matrix transducer layout into a clover -% configuration (multi-aperture arrangement), where multiple identical -% sub-arrays are distributed on a spherical surface (parent bowl). -% -% Each sub-array is: -% 1) Positioned on a sphere with radius ROC_parent -% 2) Rotated in azimuth (Z-axis) -% 3) Tilted toward the natural focus of the multi-leaf configuration -% (Y-axis rotation) -% -% Additionally, a sphere fit is performed per leaf to determine its apex -% and validate its distance to the intended focus. -% -% INPUTS: -% parameters - global parameter struct (used for debug output) -% matrix_tp - struct containing matrix + clover configuration -% elem_pos_m - [3 x N] element positions (single sub-array) [m] -% trans_pos_m - [3 x 1] transducer origin in simulation grid [m] -% focus_pos_m - [3 x 1] acoustic focus position in simulation grid [m] -% -% OUTPUTS: -% elem_pos_m - [3 x N_total] full clover element positions [m] - - % -------------------------------------------------------------------- - % Clover geometry parameters - % -------------------------------------------------------------------- - n_leaves = matrix_tp.clover.n_leaves; - ROC_parent_mm = matrix_tp.clover.ROC_parent; - - theta_az = 2*pi / 3; % 120° spacing (fixed geometry) - - % Estimate elevation angle to ensure sub-apertures do not overlap on - % the parent sphere - aperture_diam = matrix_tp.outer_diameter_mm; - radius_circle = aperture_diam / (2 * ROC_parent_mm * sin(theta_az / 2)); - elevation_angle = asin(radius_circle); - - % -------------------------------------------------------------------- - % Extract base geometry (convert to mm for geometric operations) - % -------------------------------------------------------------------- - base_pos_mm = elem_pos_m' * 1e3; % [N x 3] - - % Parent bowl center (global reference) - parent_center_m = trans_pos_m; - parent_center_m(3) = trans_pos_m(3) + ROC_parent_mm * 1e-3; - parent_center_mm = parent_center_m * 1e3; - - % -------------------------------------------------------------------- - % Define reference (first leaf center) - % Place first transducer at desired elevation on ROC sphere - % (along X-axis azimuthally) - % -------------------------------------------------------------------- - center0 = parent_center_mm + [ ... - ROC_parent_mm * cos(elevation_angle); - 0; - ROC_parent_mm * sin(elevation_angle) - ]; - - % Express base positions relative to first center - positions_local = base_pos_mm - center0'; - - % -------------------------------------------------------------------- - % Allocate storage - % -------------------------------------------------------------------- - elem_all = []; - h_leaves = gobjects(1, matrix_tp.clover.n_leaves); % handles for each leaf - - - % [DEBUG] visualize leaf orientation - if parameters.simulation.debug == 1 - h = figure; - hold on; - axis equal; - colors = lines(n_leaves); - legend_entries = strings(1, n_leaves); - end - - % -------------------------------------------------------------------- - % Generate each clover leaf - % -------------------------------------------------------------------- - for i = 1:n_leaves - - % --- Rotation matrices --- - angle_z = (i-1) * theta_az; - - % Rotate around Z to spread evenly - Rz = [cos(angle_z), -sin(angle_z), 0; - sin(angle_z), cos(angle_z), 0; - 0, 0, 1]; - - % Rotate around Y to tilt downward (toward focus) - Ry = [cos(elevation_angle), 0, sin(elevation_angle); - 0, 1, 0; - -sin(elevation_angle), 0, cos(elevation_angle)]; - - R = Rz * Ry; - - % --- Rotate center --- - center_rot = (R * (center0 - parent_center_mm)) + parent_center_mm; - - % --- Rotate elements --- - elems_rot = (R * positions_local')' + center_rot'; - - % --- Fit sphere to find apex (validation) --- - ROC_leaf = matrix_tp.curved.curv_radius_mm; - - residuals = @(c) vecnorm(elems_rot - c, 2, 2) - ROC_leaf; - - % Least squares optimization - center_fit = lsqnonlin(residuals, mean(elems_rot)); - - % Compute apex (bowl center point) - dir_vec = mean(elems_rot) - center_fit; - dir_vec = dir_vec / norm(dir_vec); - apex = center_fit + ROC_leaf * dir_vec; - - % Distance to parent center (sanity check) - dist_focus = norm(parent_center_mm' - apex); - - % [DEBUG] visualize leaf orientation - if parameters.simulation.debug == 1 - legend_entries(i) = sprintf('Leaf %d (dist: %.2f mm)', i, dist_focus); - - scatter3(elems_rot(:,1), elems_rot(:,2), elems_rot(:,3), ... - 15, colors(i,:), 'filled'); - - h_leaves(i) = plot3([apex(1), parent_center_mm(1)], ... - [apex(2), parent_center_mm(2)], ... - [apex(3), parent_center_mm(3)], 'k--'); - end - - % --- Store --- - elem_all = [elem_all; elems_rot]; - end - - % -------------------------------------------------------------------- - % Finalize outputs - % -------------------------------------------------------------------- - elem_pos_m = elem_all' / 1e3; % back to meters - - % -------------------------------------------------------------------- - % Debug plot - % -------------------------------------------------------------------- - % [DEBUG] visualize leaf orientation - if parameters.simulation.debug == 1 - h_parent = scatter3(parent_center_mm(1), parent_center_mm(2), ... - parent_center_mm(3), 100, 'r', 'filled'); - h_focus = scatter3(focus_pos_m(1)*1e3, focus_pos_m(2)*1e3, focus_pos_m(3)*1e3, 100, 'b', 'filled'); - h_center = scatter3(trans_pos_m(1)*1e3, trans_pos_m(2)*1e3, trans_pos_m(3)*1e3, 100, 'g', 'filled'); - - % Combine handles for legend - legend_handles = [h_leaves, h_parent, h_focus, h_center]; - - % Labels - par_bowl_label = "Middle of parent bowl, ROC " + sprintf('%.2f', ... - ROC_parent_mm) + " mm"; - legend_labels = [legend_entries, ... - par_bowl_label, "Focus", "Transducer center"]; - - legend(legend_handles, legend_labels); - - xlabel('X [mm]'); - ylabel('Y [mm]'); - zlabel('Z [mm]'); - title(sprintf('Clover Array (%d leaves) ROC sub-arrray %.1f mm', n_leaves, ROC_leaf)); - - grid on; - view([20 25 30]); - - output_file = fullfile(parameters.io.debug_dir, ... - sprintf('sub-%03d_%s_clover%s.png', ... - parameters.subject_id, parameters.simulation.medium, ... - parameters.io.output_affix)); - - saveas(h, output_file); - close(h); - end - -end \ No newline at end of file diff --git a/functions/source/create_matrix_karray.m b/functions/source/create_matrix_karray.m deleted file mode 100644 index 433a6bca..00000000 --- a/functions/source/create_matrix_karray.m +++ /dev/null @@ -1,191 +0,0 @@ -function [karray, transducer_pars] = create_matrix_karray(kgrid, karray, parameters, transducer_pars, elem_pos_m, trans_pos, focus_pos) -%CREATE_MATRIX_KARRAY Adds elements of a matrix transducer to a kWave array. -% -% Inputs: -% kgrid - kWave grid object -% karray - kWaveArray object to which elements are added -% parameters - Simulation parameters struct -% transducer_pars - Struct containing transducer geometry, type, curvature, and element properties -% elem_pos_m - Nx3 matrix of element positions in meters -% trans_pos - 1x3 transducer reference position (indices in kgrid) -% focus_pos - 1x3 focus position (indices in kgrid) -% -% Output: -% karray - Updated kWaveArray object with elements added - - % Extract matrix transducer configuration from the transducer parameters - matrix_tp = transducer_pars.array_shape.matrix; - - % Convert positions from kgrid indices to meters - trans_pos_m = [kgrid.x_vec(trans_pos(1)), ... - kgrid.y_vec(trans_pos(2)), ... - kgrid.z_vec(trans_pos(3))]'; - - focus_pos_m = [kgrid.x_vec(focus_pos(1)), ... - kgrid.y_vec(focus_pos(2)), ... - kgrid.z_vec(focus_pos(3))]'; - - natural_focus_pos_m = trans_pos_m + [0, 0, matrix_tp.curved.curv_radius_mm / 1000]'; - - % Apply Clover setup if requested - if matrix_tp.is_clover_setup - elem_pos_m = create_clover_array(parameters, matrix_tp, elem_pos_m, trans_pos_m, focus_pos_m); - end - - transducer_pars.n_elements = size(elem_pos_m, 2); - - % Initialize source amplitudes (uniform) - transducer_pars.source_amp = transducer_pars.source_amp(1) * ones(1, transducer_pars.n_elements); - - % Wavelength and wavenumber for phase calculation - lambda = parameters.medium_properties.water.sound_speed / transducer_pars.source_freq_hz; - k = 2 * pi / lambda; - - % Initialize source phases, scaled vectors, tx, ty, tz - source_phase_rad = zeros(1, transducer_pars.n_elements); - scaled_vectors = zeros(3, transducer_pars.n_elements); - tx = zeros(1, transducer_pars.n_elements); - ty = zeros(1, transducer_pars.n_elements); - tz = zeros(1, transducer_pars.n_elements); - - % Loop over each element and add it to karray - for ind = 1:transducer_pars.n_elements - el_pos_m_i = elem_pos_m(:, ind); - - % Vector from element to natural focus to position elements to - % natural focus - vec_to_nat_focus = natural_focus_pos_m - el_pos_m_i; - norm_vec = vec_to_nat_focus / norm(vec_to_nat_focus); - scaled_vectors(:, ind) = vec_to_nat_focus; - - % Compute rotation matrix to align default normal [0;0;1] to vec_to_focus - z0 = [0;0;1]; - v = cross(z0, norm_vec); - s = norm(v); - c = dot(z0, norm_vec); - vx = [ 0 -v(3) v(2); v(3) 0 -v(1); -v(2) v(1) 0 ]; - R = eye(3) + vx + vx^2*((1-c)/(s^2+eps)); - - - % Convert rotation matrix to ZYX Euler angles (extrinsic) - yaw = atan2d(R(2,1), R(1,1)); % around z - pitch = atan2d(-R(3,1), sqrt(R(3,2)^2 + R(3,3)^2)); % around y - roll = atan2d(R(3,2), R(3,3)); % around x - - tx(ind) = roll; - ty(ind) = pitch; - tz(ind) = yaw; - - % Determine element type - switch lower(matrix_tp.element_shape) - case 'rect' - karray.addRectElement(el_pos_m_i, matrix_tp.elem_height_mm, matrix_tp.elem_width_mm, [roll, pitch, yaw]); - - case 'disc' - % Disc with same area as rectangular element - diameter = sqrt(matrix_tp.elem_height_mm * matrix_tp.elem_width_mm * 4 / pi); - karray.addDiscElement(el_pos_m_i, diameter, natural_focus_pos_m); - - case 'bowl' - r_c = matrix_tp.curved.curv_radius_mm / 1e3; - A_target = matrix_tp.elem_height_mm * matrix_tp.elem_width_mm; - a_min = 0.001; % Lower bound of aperture radius (in m) - a_max = r_c * 0.999; % Slightly less than full bowl radius - - - % Define the function to solve: A_cap(a) - A_target = 0 - area_diff = @(a) 2*pi*r_c*(r_c - sqrt(r_c^2 - a^2)) - A_target; - - % Check if the function changes sign in the interval - if sign(area_diff(a_min)) == sign(area_diff(a_max)) - error('No sign change in interval: cannot find bowl diameter. Possibly A_target is out of bounds.'); - end - - a_solution = fzero(area_diff, [a_min, a_max]); - diameter = 2 * a_solution; - karray.addBowlElement(el_pos_m_i, r_c, diameter, natural_focus_pos_m); - end - - % Calculate phase delay based on set focus - distance = sqrt((el_pos_m_i(1) - focus_pos_m(1))^2 + (el_pos_m_i(2) - focus_pos_m(2))^2 + (el_pos_m_i(3) - focus_pos_m(3))^2); - - source_phase_rad(ind) = mod(k * distance, 2 * pi); - end - - transducer_pars.array_shape.matrix = matrix_tp; - - transducer_pars.source_phase_rad = source_phase_rad; - transducer_pars.source_phase_deg = rad2deg(source_phase_rad); - - % [DEBUG] visualize matrix element orientation - if parameters.simulation.debug == 1 - h = figure; - hold on; - axis equal; - - scatter3(elem_pos_m(1,:), elem_pos_m(2,:), elem_pos_m(3,:), 'b.'); - scatter3(natural_focus_pos_m(1,:), natural_focus_pos_m(2,:), natural_focus_pos_m(3,:), 'r.'); - plot3(focus_pos_m(1), focus_pos_m(2), focus_pos_m(3), 'go', 'MarkerFaceColor', 'g'); - - for ind = 1:transducer_pars.n_elements - el_pos = elem_pos_m(:, ind); - - % --- Ground truth direction (element -> focus) - vec_truth = focus_pos_m - el_pos; - vec_truth = vec_truth / norm(vec_truth); - - % --- Euler angles (degrees -> radians) - roll = deg2rad(tx(ind)); - pitch = deg2rad(ty(ind)); - yaw = deg2rad(tz(ind)); - - % --- Rotation matrix (extrinsic ZYX) - Rz = [cos(yaw) -sin(yaw) 0; - sin(yaw) cos(yaw) 0; - 0 0 1]; - - Ry = [cos(pitch) 0 sin(pitch); - 0 1 0; - -sin(pitch) 0 cos(pitch)]; - - Rx = [1 0 0; - 0 cos(roll) -sin(roll); - 0 sin(roll) cos(roll)]; - - R = Rz * Ry * Rx; - - % --- Predicted element normal - vec_pred = R * [0; 0; 1]; - - % --- Scale vectors for visualization - scale = norm(focus_pos_m - el_pos); - vec_pred = vec_pred * scale; - vec_truth = vec_truth * scale; - - % --- Plot vectors - quiver3(el_pos(1), el_pos(2), el_pos(3), ... - vec_pred(1), vec_pred(2), vec_pred(3), ... - 0, 'r', 'LineWidth', 1.5); - - quiver3(el_pos(1), el_pos(2), el_pos(3), ... - vec_truth(1), vec_truth(2), vec_truth(3), ... - 0, 'g--', 'LineWidth', 1.2); - - end - - view(0, 0); - xlabel('x [m]'); ylabel('y [m]'); zlabel('z [m]'); - title('Element Orientation Validation (Red = Euler, Green = Ground Truth)') - grid on; - - legend({'Element Position','Natural Focus','Focus','From Euler Angles','Ground Truth'}) - - base_name = sprintf('sub-%03d_%s_transducer_element_orientation%s', ... - parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix); - - saveas(h, fullfile(parameters.io.debug_dir, [base_name '.fig'])) - saveas(h, fullfile(parameters.io.debug_dir, [base_name '.png'])) - - close(h) - end -end \ No newline at end of file diff --git a/functions/source/grid_transducer_location.m b/functions/source/grid_transducer_location.m index af10914a..f8bfb8e2 100644 --- a/functions/source/grid_transducer_location.m +++ b/functions/source/grid_transducer_location.m @@ -27,13 +27,11 @@ parameters.transducer = parameters.transducer(1); end - if strcmp(parameters.transducer.array_shape.type, 'annular') - % 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') - parameters.transducer.trans_pos = []; - parameters.transducer.focus_pos = []; - end + % 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') + parameters.transducer.trans_pos = []; + parameters.transducer.focus_pos = []; end if (~isfield(parameters.transducer, 'trans_pos') || isempty(parameters.transducer.trans_pos)) ... diff --git a/functions/source/source_create.m b/functions/source/source_create.m index a13ee9f2..4ada9dcd 100644 --- a/functions/source/source_create.m +++ b/functions/source/source_create.m @@ -41,17 +41,17 @@ %% Convert element diameters from mm to grid points (for all transducers) + transducer_pars = parameters.transducer; + grid.resolution_mm = parameters.grid.resolution_mm; + for it = 1:nT tp = transducer_pars(it); - if strcmp(tp.array_shape.type, 'annular') - tp.array_shape.annular.Elements_OD = 2 * floor(tp.array_shape.annular.Elements_OD_mm / parameters.grid.resolution_mm / 2) + 1; - tp.array_shape.annular.Elements_ID = 2 * floor(tp.array_shape.annular.Elements_ID_mm / parameters.grid.resolution_mm / 2) + 1; - tp.array_shape.annular.Elements_ID(tp.array_shape.annular.Elements_ID_mm == 0) = 0; - - end - - tp.radius_grid = round(tp.curv_radius_mm / parameters.grid.resolution_mm); + 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.resolution_mm); if it == 1 % initialise struct array with full field set of tp @@ -111,20 +111,20 @@ % outer element aperture if numel(parameters.grid.dims) == 3 bowl = makeBowl(grid_dims, trans_pos_i, tp.radius_grid, ... - tp.array_shape.annular.Elements_OD(el_i), focus_pos_i); + tp.Elements_OD(el_i), focus_pos_i); else bowl = makeArc(grid_dims, trans_pos_i, tp.radius_grid, ... - tp.array_shape.annular.Elements_OD(el_i), focus_pos_i); + tp.Elements_OD(el_i), focus_pos_i); end % subtract inner aperture if applicable - if tp.array_shape.annular.Elements_ID(el_i) > 0 + if tp.Elements_ID(el_i) > 0 if numel(parameters.grid.dims) == 3 bowl = bowl - makeBowl(grid_dims, trans_pos_i, ... - tp.radius_grid, tp.array_shape.annular.Elements_ID(el_i), focus_pos_i); + tp.radius_grid, tp.Elements_ID(el_i), focus_pos_i); else bowl = bowl - makeArc(grid_dims, trans_pos_i, ... - tp.radius_grid, tp.array_shape.annular.Elements_ID(el_i), focus_pos_i); + tp.radius_grid, tp.Elements_ID(el_i), focus_pos_i); end end @@ -165,14 +165,19 @@ else disp('Setting up kWaveArray (might take a bit of time)'); - % Note: Both `parameters` and `tp` are passed to this function to - % allow support for multiple transducers in future implementations. tp = transducer_pars(1); % use first (and only) transducer here % 3D/2D positions for kWaveArray are taken from the first row trans_pos_1 = trans_pos(1, :); focus_pos_1 = focus_pos(1, :); + % CW per-element for this transducer + cw_signal = createCWSignals( ... + kgrid.t_array, ... + tp.source_freq_hz, ... + tp.source_amp, ... + tp.source_phase_rad); % [n_elements x Nt] + % Determine if axisymmetric mode should be enabled if numel(parameters.grid.dims) == 2 && ... isfield(parameters.grid, 'axisymmetric') && parameters.grid.axisymmetric == 1 @@ -188,94 +193,30 @@ 'UpsamplingRate', 10, ... 'BLIType', 'sinc'); - switch tp.array_shape.type - case 'annular' - % Set focus position and transducer position vectors in physical coordinates - 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))]; - - karray.addAnnularArray(pos_vec, ... - tp.curv_radius_mm * 1e-3, ... - [tp.array_shape.annular.Elements_ID_mm; tp.array_shape.annular.Elements_OD_mm] * 1e-3, ... - focus_vec); - - 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))]; - focus_vec = [kgrid.x_vec(focus_pos_1(1)), kgrid.y_vec(focus_pos_1(2))]; - - karray.addArcElement(pos_vec, ... - tp.curv_radius_mm * 1e-3, ... - tp.array_shape.annular.Elements_OD_mm * 1e-3, ... - focus_vec); - end - case 'matrix' - matrix_tp = tp.array_shape.matrix; - - trans_pos_m = [kgrid.x_vec(tp.trans_pos(1)); - kgrid.y_vec(tp.trans_pos(2)); - kgrid.z_vec(tp.trans_pos(3))]; - - focus_pos_m = [kgrid.x_vec(tp.focus_pos(1)); - kgrid.y_vec(tp.focus_pos(2)); - kgrid.z_vec(tp.focus_pos(3))]; - - switch matrix_tp.matrix_shape.type - case 'define_here' - [elem_pos_m, tp] = convert_to_element_pos(parameters, tp, trans_pos_m, focus_pos_m); - case 'extract_from_file' - elem_pos_m = extract_element_pos(parameters, tp, trans_pos_m); - otherwise - error('Matrix shape %s is unknown or not implemented.', matrix_tp.matrix_shape.type) - end + % Set focus position and transducer position vectors in physical coordinates + 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))]; - % [DEBUG] visualize element distribution - if parameters.simulation.debug == 1 - % Convert positions to mm for plotting - elem_pos_mm = elem_pos_m' * 1e3; - - h = figure; - scatter3(elem_pos_mm(:,1), elem_pos_mm(:,2), elem_pos_mm(:,3), 60, 'filled'); - axis equal - xlabel('X [mm]') - ylabel('Y [mm]') - zlabel('Z [mm]') - view([0 90]) - title('Transducer Element Distribution') - grid on; - - % Build filenames - fig_filename = fullfile(parameters.io.debug_dir, ... - sprintf('sub-%03d_%s_transducer_element_distribution%s.fig', ... - parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); - - png_filename = fullfile(parameters.io.debug_dir, ... - sprintf('sub-%03d_%s_transducer_element_distribution%s.png', ... - parameters.subject_id, parameters.simulation.medium, parameters.io.output_affix)); - - % Save outputs - saveas(h, fig_filename, 'fig') - saveas(h, png_filename, 'png') - close(h) - end - - [karray, tp] = create_matrix_karray(kgrid, karray, parameters, tp, elem_pos_m, trans_pos, focus_pos); + karray.addAnnularArray(pos_vec, ... + tp.curv_radius_mm * 1e-3, ... + [tp.Elements_ID_mm; tp.Elements_OD_mm] * 1e-3, ... + focus_vec); - otherwise - error('Array shape %s is unknown or not implemented.', tp.array_shape.type) - end + elseif numel(parameters.grid.dims) == 2 && axisymmetric == false - % CW per-element for this transducer - cw_signal = createCWSignals( ... - kgrid.t_array, ... - tp.source_freq_hz, ... - tp.source_amp, ... - tp.source_phase_rad); % [n_elements x Nt] + % 2D arc-shaped element + pos_vec = [kgrid.x_vec(trans_pos_1(1)), kgrid.y_vec(trans_pos_1(2))]; + focus_vec = [kgrid.x_vec(focus_pos_1(1)), kgrid.y_vec(focus_pos_1(2))]; + + karray.addArcElement(pos_vec, ... + tp.curv_radius_mm * 1e-3, ... + tp.Elements_OD_mm * 1e-3, ... + focus_vec); + end - if axisymmetric == true && strcmp(tp.array_shape.type, 'annular') + if axisymmetric == true kgrid_mirrored = kWaveGrid(kgrid.Nx, kgrid.dx, 2*kgrid.Ny - 1, kgrid.dy); karray_full = kWaveArray('Axisymmetric', false, 'BLITolerance', 0.01, 'UpsamplingRate', 100); @@ -283,9 +224,9 @@ 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]; - + for el_i = 1:tp.n_elements - el_OD_m = tp.array_shape.annular.Elements_OD_mm(el_i) * 1e-3; + el_OD_m = tp.Elements_OD_mm(el_i) * 1e-3; y_shift = (el_i - (tp.n_elements+1)/2) * el_OD_m; element_pos = position_base + [0, y_shift]; @@ -364,9 +305,7 @@ source.p_mask = binary_mask; source.p = distributed_source_signal; - - show_binary_mask_transducer(karray, kgrid, parameters); end - transducer_pars(1) = tp; + end end diff --git a/functions/thermal/thermal_analysis.m b/functions/thermal/thermal_analysis.m index f2d2e8c0..fc02e0ee 100644 --- a/functions/thermal/thermal_analysis.m +++ b/functions/thermal/thermal_analysis.m @@ -100,37 +100,16 @@ % plot heating superimposed on segmentation if ndims(results_heating.maxT) == 3 - - slices = struct( ... - 'dim', {'x', 'y', 'z'}, ... - 'pos', {parameters.transducer(1).focus_pos(1), ... - parameters.transducer(1).focus_pos(2), ... - parameters.transducer(1).focus_pos(3)}); - - for i = 1:numel(slices) - slice = slices(i); - [~,~,~,~,~,~,~,h]=plot_overlay(... + [~,~,~,~,~,~,~,h]=plot_overlay(... results_heating.maxT, ... segmentation, ... source_labels, ... parameters, ... - {slice.dim, slice.pos}, ... + {'y', parameters.transducer(1).focus_pos(2)}, ... parameters.transducer(1).trans_pos, ... parameters.transducer(1).focus_pos, ... highlighted_pos, ... 'overlay_color_range', temp_color_range); - - % Construct output filename - output_plot_filename = fullfile(parameters.output_dir,... - sprintf('sub-%03d_%s_maxT_%s_%s.png',... - parameters.subject_id, ... - parameters.simulation_medium, ... - slice.dim, ... - parameters.results_filename_affix)); - - saveas(h, output_plot_filename, 'png') - close(h); - end elseif ndims(results_heating.maxT) == 2 [h]=plot_overlay_2d(... results_heating.maxT, ... @@ -142,13 +121,9 @@ highlighted_pos, ... 'overlay_color_range', temp_color_range, ... 'bg_bw_range', [0, numel(fieldnames(parameters.layers))]); - - output_plot_filename = fullfile(parameters.io.output_dir,... - sprintf('sub-%03d_%s_maxT%s.png',... - parameters.subject_id, ... - parameters.simulation_medium, ... - parameters.io.output_affix)); - saveas(h, output_plot_filename, 'png') - close(h); end -end \ No newline at end of file + output_plot_filename = fullfile(parameters.io.output_dir,... + sprintf('sub-%03d_%s_maxT%s.png',... + 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/transducer/convert_to_element_pos.m b/functions/transducer/convert_to_element_pos.m deleted file mode 100644 index 06b3c394..00000000 --- a/functions/transducer/convert_to_element_pos.m +++ /dev/null @@ -1,209 +0,0 @@ -function [elem_pos_m, tp] = convert_to_element_pos(parameters, tp, trans_pos_m, focus_pos_m) -%CONVERT_TO_ELEMENT_POS Generate element positions for matrix transducers -% -% This function generates the 3-D element coordinates of a matrix -% array transducer based on the configuration defined in the parameter -% structure. Supported grid layouts are: -% -% • rectangular grid -% • Fibonacci spiral grid -% • Fermat spiral grid (via makeCartBowl) -% -% The resulting element coordinates are optionally projected onto a -% spherical cap when the transducer is defined as curved. -% -% INPUTS -% parameters Global simulation parameters -% tp Transducer parameter structure -% trans_pos_m [3x1] transducer position in meters -% focus_pos_m [3x1] focus position in meters -% -% OUTPUTS -% elem_pos_m Element center coordinates [m] (3 × N) -% tp Updated transducer parameter structure -% -% NOTES -% • Element coordinates are returned in k-Wave format (3 × N) -% • Internal calculations are performed in millimeters unless noted -% • Curvature is applied only when tp.array_shape.matrix.is_curved = true - - % ---------------------------------------------------------------------- - % Extract configuration - % ----------------------------------------------------------------------- - - % Extract matrix transducer configuration from the transducer parameters - matrix_tp = tp.array_shape.matrix; - - % Extract defined matrix shape parameters for reading element positions - defined = matrix_tp.matrix_shape.define_here; - grid_shape = defined.grid_shape; - - % Ensure column vectors - trans_pos_m = trans_pos_m(:); - focus_pos_m = focus_pos_m(:); - - switch grid_shape.type - case 'fibonacci' - - fib = grid_shape.fibonacci; - - N = fib.n_elements; % number of elements - D = matrix_tp.outer_diameter_mm; % aperture diameter [mm] - R = matrix_tp.curv_radius_mm; % radius of curvature [mm] - - a = D/2; % aperture radius [mm] - ga = pi*(3 - sqrt(5)); % golden angle - - elem_pos_mm = zeros(N,3); - - for i = 1:N - - % Uniform point distribution inside aperture - r = a * sqrt((i - 0.5)/N); - theta = ga * (i - 1); - - x = r*cos(theta); - y = r*sin(theta); - - % Project onto spherical cap if curved - if matrix_tp.is_curved - z = R - sqrt(R^2 - x^2 - y^2); - else - z = 0; - end - - elem_pos_mm(i,:) = [x y z]; - - end - - % --- Estimate average element pitch ------------------------------- - avg_dist = 0; - for i = 1:N - dists = vecnorm(elem_pos_mm - elem_pos_mm(i,:),2,2); % Euclidean distances - dists(i) = inf; % Ignore self - avg_dist = avg_dist + min(dists); % Nearest neighbor distance - end - - avg_pitch = avg_dist / N; % Average center-to-center spacing - - kerf = fib.kerf_mm; % assumed kerf width [mm] - element_size = avg_pitch - kerf; - - fprintf('Estimated avg pitch: %.3f mm\n', avg_pitch); - fprintf('Recommended element size (kerf %.3f mm): %.3f mm\n', ... - kerf, element_size); - - % Convert to meters + translate - elem_pos_m = (elem_pos_mm / 1e3)' + trans_pos_m; - - case 'rect' - - rect = grid_shape.rect; - - % --- Compute array dimensions -------------------------------------- - tran_width = (rect.n_elem_row * tp.elem_width_mm) + ... - ((rect.n_elem_row - 1) * rect.elem_spacing_width_mm); - - tran_height = (rect.n_elem_col * tp.elem_height_mm) + ... - ((rect.n_elem_col - 1) * rect.elem_spacing_height_mm); - - % --- Element center coordinates ------------------------------------ - x_vec = linspace(-tran_width/2 + tp.elem_width_mm/2,... - tran_width/2 - tp.elem_width_mm/2,... - rect.n_elem_row); - - y_vec = linspace(-tran_height/2 + tp.elem_height_mm/2,... - tran_height/2 - tp.elem_height_mm/2,... - rect.n_elem_col); - - [X,Y] = meshgrid(x_vec,y_vec); - - % Flat array coordinates - X = X + trans_pos_m(1); - Y = Y + trans_pos_m(2); - Z = trans_pos_m(3) * ones(size(X)); - - % [DEBUG] visualize grid - if parameters.debug == 1 - h = figure; - scatter(X(:), Y(:), 60, 'filled') - axis equal - xlabel('X [m]') - ylabel('Y [m]') - title('Grid - Element Center Positions') - grid on - - output_plot_filename = fullfile(parameters.debug_dir,... - sprintf('sub-%03d_%s_transducer_grid%s.png',... - parameters.subject_id,... - parameters.simulation_medium,... - parameters.results_filename_affix)); - - saveas(h,output_plot_filename,'png') - close(h) - end - - % Restrict to circular aperture - dx = X(:) - trans_pos_m(1); - dy = Y(:) - trans_pos_m(2); - distances = sqrt(dx.^2 + dy.^2); - - radius = matrix_tp.Elements_OD_mm(end) * 1e-3 / 2; - mask = distances <= radius; - - elem_pos_m = [X(:) Y(:) Z(:)]; - elem_pos_m = elem_pos_m(mask,:); - - % Update number of elements - tp.array_shape.matrix.n_elements = size(elem_pos_m,1); - - % Duplicate source amplitude per element - tp.source_amp = tp.source_amp(1) * ... - ones(1,tp.array_shape.matrix.n_elements); - - % [DEBUG] visualize circular aperture - if parameters.debug == 1 - h = figure; - scatter(elem_pos_m(:,1)*1e3,elem_pos_m(:,2)*1e3,60,'filled') - axis equal - xlabel('X [mm]') - ylabel('Y [mm]') - title('Circular Aperture - Element Center Positions') - grid on - - saveas(h,output_plot_filename,'png') - close(h) - end - - % Apply curvature (spherical cap) - if matrix_tp.is_curved - - ROC = matrix_tp.curved.curv_radius_mm / 1000; - R2 = ROC^2; - - sagitta_term = R2 - elem_pos_m(:,1).^2 - elem_pos_m(:,2).^2; - - if any(sagitta_term < 0) - error('Some elements fall outside the spherical cap.'); - end - - elem_pos_m(:,3) = elem_pos_m(:,3) + ROC - sqrt(sagitta_term); - - end - - elem_pos_m = elem_pos_m'; - - case 'fermat' - elem_pos_m = makeCartBowl( ... - trans_pos_m', ... - matrix_tp.curved.curv_radius_mm * 1e-3, ... - matrix_tp.outer_diameter_mm * 1e-3, ... - focus_pos_m', ... - tp.n_elements, ... - true); - - otherwise - error('Grid shape %s is unknown or not implemented.', grid_shape.type) - end - -end \ No newline at end of file diff --git a/functions/transducer/extract_element_pos.m b/functions/transducer/extract_element_pos.m deleted file mode 100644 index 5f5f81f8..00000000 --- a/functions/transducer/extract_element_pos.m +++ /dev/null @@ -1,143 +0,0 @@ -function elem_pos_m = extract_element_pos(parameters, tp, trans_pos_m) -% EXTRACT_ELEMENT_POS Extracts matrix transducer element positions from file. -% -% This function reads element position coordinates from an external file -% and converts them into expected element position coordinates. The -% positions are defined in physical space (typically in millimetres) and -% are transformed into metres relative to the simulation grid. -% -% The resulting element coordinates are shifted to the desired transducer -% location within the k-Wave simulation grid. -% -% Input: -% parameters Global simulation parameters -% tp - Struct containing transducer parameters. -% trans_pos_m [3x1] transducer position in meters -% -% Output: -% elem_pos_m - 3 x N matrix containing element positions in metres, -% expressed in simulation coordinates. - - % Extract matrix transducer configuration from the transducer parameters - matrix_tp = tp.array_shape.matrix; - - % Extract file-based matrix shape parameters for reading element positions - file_ext = matrix_tp.matrix_shape.extract_from_file; - - % --------------------------------------------------------------------- - % Read element positions from file - % --------------------------------------------------------------------- - - tran_info = readtable(file_ext.file_path); - - row_start = file_ext.start_row; - row_end = row_start + file_ext.n_elements - 1; - - col_start = file_ext.start_col; - - phys_positions_mm = table2array( ... - tran_info(row_start:row_end, col_start:col_start+2) ... - ); - - % --------------------------------------------------------------------- - % Optional: randomly select subset of elements - % --------------------------------------------------------------------- - if file_ext.select_random_subset - - if file_ext.subset.random_seed - rng('shuffle'); % seed RNG based on current time - end - - phys_positions_mm = datasample(phys_positions_mm, ... - file_ext.subset.subset_n_elements, ... - 1, ... - 'Replace', ... - false); - end - - % --------------------------------------------------------------------- - % Convert positions from mm to metres and center coordinates - % --------------------------------------------------------------------- - ROC = matrix_tp.curved.curv_radius_mm; - - % Translate origin from [0,0,ROC] to [0,0,0], convert mm→m, and flip Z-axis - phys_positions_m = (phys_positions_mm - [0, 0, ROC]) .* [1, 1, -1] / 1000; - - % --------------------------------------------------------------------- - % Optional: project elements onto new radius of curvature - % --------------------------------------------------------------------- - - if file_ext.project_on_new_ROC - - new_ROC_mm = file_ext.ROC_projection.new_ROC_mm; - - r = hypot(phys_positions_m(:,1), phys_positions_m(:,2)); - r_max = max(r); - - sag_old = ROC - sqrt(ROC^2 - r_max^2); - sag_new = new_ROC_mm - sqrt(new_ROC_mm^2 - r_max^2); - - scale_factor = sag_new / sag_old; - - z_ref = max(phys_positions_m(:,3)); - z_offset = phys_positions_m(:,3) - z_ref; - - new_z = z_ref + z_offset * scale_factor; - - phys_positions_m(:,3) = new_z; - - % --------------------------------------------------------------------- - % Optional: projection sanity check - % --------------------------------------------------------------------- - - % Extract X, Y, Z for convenience - x = phys_positions_m(:,1); - y = phys_positions_m(:,2); - z = phys_positions_m(:,3); - - % Solve least-squares sphere fit (optional, purely for information) - A = [2*x, 2*y, 2*z, ones(size(x))]; - b = x.^2 + y.^2 + z.^2; - params = A \ b; - - xc = params(1); yc = params(2); zc = params(3); c = params(4); - R_fit = sqrt(xc^2 + yc^2 + zc^2 + c); - fprintf('Best-fit radius = %.6f m\n', R_fit); - - % [DEBUG] visualize grid - if parameters.debug == 1 - h = figure('Name', 'Transducer Element Distribution'); - hold on; grid on; - - % Plot original element positions - scatter3(x, y, z, 50, 'b', 'filled', 'DisplayName', 'Original positions'); - - % Plot projected element positions - scatter3(x, y, new_z, 50, 'r', 'filled', 'DisplayName', 'Projected positions'); - - axis equal; - xlabel('X [m]'); ylabel('Y [m]'); zlabel('Z [m]'); - title('Transducer Element Distribution (3D)'); - legend('Location', 'best'); - view([90 0]); % Top-down view - - % Save figure - fig_name = sprintf('sub-%03d_%s_transducer_redone_element_distribution%s', ... - parameters.subject_id, parameters.simulation_medium, parameters.results_filename_affix); - - saveas(h, fullfile(parameters.debug_dir, [fig_name '.fig'])); - saveas(h, fullfile(parameters.debug_dir, [fig_name '.png'])); - - close(h); - end - - end - - % --------------------------------------------------------------------- - % Translate positions into simulation grid coordinates - % --------------------------------------------------------------------- - elem_pos_m = phys_positions_m + trans_pos_m'; - - elem_pos_m = elem_pos_m'; - -end \ No newline at end of file diff --git a/functions/transducer/get_arc.m b/functions/transducer/get_arc.m index 1ccc8b6d..6682bf38 100644 --- a/functions/transducer/get_arc.m +++ b/functions/transducer/get_arc.m @@ -22,7 +22,7 @@ radius (1,1) double angleStart (1,1) double angleEnd (1,1) double - npoints (1,1) double = 200 + npoints (1,1) int8 = 200 end % Generate angles uniformly between `angleStart` and `angleEnd` diff --git a/functions/transducer/get_transducer_box.m b/functions/transducer/get_transducer_box.m index e7a62ef8..8e544963 100644 --- a/functions/transducer/get_transducer_box.m +++ b/functions/transducer/get_transducer_box.m @@ -1,4 +1,4 @@ -function [transducer_box, ex_plane_pos_trig, geom_focus_pos, dist_to_ep_mm] = get_transducer_box(trans_pos, focus_pos, natural_foc, grid_step, parameters, is_plot) +function [transducer_box, ex_plane_pos_trig, geom_focus_pos, dist_to_ep_mm] = get_transducer_box(trans_pos, focus_pos, grid_step, parameters, plot) % GET_TRANSDUCER_BOX Computes the transducer box dimensions and positions. % @@ -10,7 +10,6 @@ % Input: % trans_pos - [1x2] array specifying the transducer position in grid coordinates. % focus_pos - [1x2] array specifying the focus position in grid coordinates. -% natural_foc - [1x2] array specifying the natural focus position in grid coordinates. % grid_step - Scalar specifying the grid step size (in mm). % parameters - Struct containing transducer properties (e.g., curvature radius, element diameters). % plot - Boolean flag to enable/disable visualization of the transducer box (default: 1). @@ -24,37 +23,23 @@ arguments trans_pos (1, 2) % Transducer position in grid coordinates focus_pos (1, 2) % Focus position in grid coordinates - natural_foc (1, 2) % Natural focus position in grid coordinates grid_step (1, 1) % Grid step size in mm parameters struct % Struct containing transducer properties - is_plot = 1 % Enable/disable visualization (default: enabled) + plot = 1 % Enable/disable visualization (default: enabled) end - %% Compute focal slope0 - % Calculate unit vector pointing from focus to transducer - if parameters.transducer(1).align_transducer_with_focus - focal_slope = (trans_pos - focus_pos) / norm(trans_pos - focus_pos); - - else - focal_slope = (trans_pos - natural_foc) / norm(trans_pos - natural_foc); - end - - focal_angle = atan2(focal_slope(2),focal_slope(1)); + %% Compute focal slope and angle + % Calculate unit vector pointing from focus to transducer and its angle + focal_slope = (trans_pos - focus_pos) / norm(trans_pos - focus_pos); + focal_angle = atan2(focal_slope(2), focal_slope(1)); %% Compute geometric focus position % Calculate geometric focus position based on curvature radius and focal angle - geom_focus_pos = trans_pos - (parameters.transducer(1).curv_radius_mm) / grid_step * focal_slope; + geom_focus_pos = trans_pos - (parameters.transducer(1).curv_radius_mm) / grid_step * [cos(focal_angle), sin(focal_angle)]; %% Compute distance to exit plane % Maximum outer diameter of transducer elements - switch parameters.transducer(1).array_shape.type - case 'annular' - max_od = max(parameters.transducer(1).array_shape.annular.Elements_OD_mm); - case 'matrix' - max_od = parameters.transducer(1).array_shape.matrix.outer_diameter_mm; - otherwise - error('Array type %s is unknown or not implemented.', parameters.transducer(1).array_shape.type) - end + max_od = max(parameters.transducer(1).Elements_OD_mm); % Distance from geometric focus to exit plane in mm dist_to_ep_mm = 0.5 * sqrt(4 * parameters.transducer(1).curv_radius_mm^2 - max_od^2); @@ -64,20 +49,18 @@ %% Compute exit plane position % Calculate exit plane position based on focal angle and distance to exit plane - ex_plane_pos_trig = geom_focus_pos + dist_to_ep_grid * focal_slope; + ex_plane_pos_trig = geom_focus_pos + dist_to_ep_grid * [cos(focal_angle), sin(focal_angle)]; %% Compute orthogonal angle for bounding box calculation % Orthogonal angle perpendicular to focal slope ort_angle = atan(-focal_slope(1) / focal_slope(2)); - arc_halfangle = atan(max_od/2/dist_to_ep_grid/grid_step); - %% Compute bounding box dimensions % Radius of bounding box based on maximum outer diameter of elements - r = max_od / 2 / grid_step; + r = max(parameters.transducer(1).Elements_OD_mm) / 2 / grid_step; % Depth of transducer in grid units - trans_full_depth = parameters.transducer(1).depth_mm / grid_step; + trans_full_depth = 16 / grid_step; % Back end position of transducer based on depth and focal slope trans_back = ex_plane_pos_trig + trans_full_depth * focal_slope; @@ -89,7 +72,7 @@ [ex_plane_pos_trig(2) + r * sin(ort_angle), ex_plane_pos_trig(1) + r * cos(ort_angle)]]; %% Visualization (optional) - if is_plot + if plot overlay_weight = 0; % Weight for overlay color blending overlay_color = [0, 0.2, 0.7]; % Overlay color (blue) lineWidth = 1; % Line width for visualization @@ -109,21 +92,6 @@ 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/ parameters.grid_step_mm, ... - focal_angle-arc_halfangle, ... - focal_angle+arc_halfangle ); - plot(arc_y, arc_x, 'Color', boxColor, 'LineWidth', lineWidth, ... - 'LineSmoothing', LineSmoothing) end end diff --git a/functions/transducer/transducer_setup.m b/functions/transducer/transducer_setup.m index 43b98680..da89f878 100644 --- a/functions/transducer/transducer_setup.m +++ b/functions/transducer/transducer_setup.m @@ -54,100 +54,35 @@ error('Transducer and focus positions should have the size [1 2] or [1 3]') end - % Initialize computational grids for the transducer mask and source label matrix - transducer_mask = zeros(grid_dims); % Binary mask representing active transducer regions - source_label = zeros(grid_dims); % Label matrix for identifying individual elements - switch transducer_pars.array_shape.type - case 'annular' - % 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_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_res_mm); % Radius in grid points - - % Loop through each transducer element to create its geometry - for el_i = 1:transducer_pars.n_elements - % Create the outer bowl geometry for the current element - bowl = makeBowl(grid_dims, trans_pos, transducer_pars.radius_grid, transducer_pars.Elements_OD(el_i), focus_pos); - - % If the inner diameter is greater than zero, subtract the inner bowl geometry - if transducer_pars.Elements_ID(el_i) > 0 - bowl = bowl - makeBowl(grid_dims, trans_pos, transducer_pars.radius_grid, transducer_pars.Elements_ID(el_i), focus_pos); - end - - % Add the current element's bowl geometry to the binary mask - transducer_mask = transducer_mask + bowl; - - % Assign a unique label to this element in the source label matrix - source_label = source_label + el_i * bowl; - end - case 'matrix' - matrix_tp = transducer_pars.array_shape.matrix; - - trans_pos_m = trans_pos * grid_step_mm / 1e3; - focus_pos_m = focus_pos * grid_step_mm / 1e3; - - switch matrix_tp.matrix_shape.type - case 'define_here' - [elem_pos_m, transducer_pars] = convert_to_element_pos(parameters, transducer_pars, trans_pos_m, focus_pos_m); - case 'extract_from_file' - elem_pos_m = extract_element_pos(parameters, transducer_pars, trans_pos_m); - otherwise - error('Matrix shape %s is unknown or not implemented.', matrix_tp.matrix_shape.type) - end - - natural_focus_pos_m = trans_pos_m + [0, 0, matrix_tp.curved.curv_radius_mm / 1000]'; + % 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_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 - % Apply Clover setup if requested - if transducer_pars.is_clover_setup - elem_pos_m = create_clover_array(elem_pos_m, transducer_pars, trans_pos_m); - end + % Handle cases where inner diameter is zero (e.g., for flat elements) + transducer_pars.Elements_ID(transducer_pars.Elements_ID_mm == 0) = 0; - transducer_pars.n_elements = size(elem_pos_m, 2); + % Convert the curvature radius from millimeters to grid points + transducer_pars.radius_grid = round(transducer_pars.curv_radius_mm / grid_res_mm); % Radius in grid points - % Convert to grid units - elem_pos_grid = round(elem_pos_m * 1e3 / grid_step_mm); - - natural_focus_pos_grid = round(natural_focus_pos_m * 1e3 / grid_step_mm); - - % Loop through each transducer element to create its geometry - for el_i = 1:transducer_pars.n_elements - el_pos_grid_i = elem_pos_grid(:, el_i); - - r_c = matrix_tp.curved.curv_radius_mm / 1e3; - A_target = matrix_tp.elem_height_mm * matrix_tp.elem_width_mm; - a_min = 0.001; % Lower bound of aperture radius (in m) - a_max = r_c * 0.999; % Slightly less than full bowl radius - - % Define the function to solve: A_cap(a) - A_target = 0 - area_diff = @(a) 2*pi*r_c*(r_c - sqrt(r_c^2 - a^2)) - A_target; - - % Check if the function changes sign in the interval - if sign(area_diff(a_min)) == sign(area_diff(a_max)) - error('No sign change in interval: cannot find bowl diameter. Possibly A_target is out of bounds.'); - end - - a_solution = fzero(area_diff, [a_min, a_max]); - diameter = 2 * a_solution; - - % Convert to grid dimensions - r_c_grid = r_c * 1e3 / grid_step_mm; - diameter_grid = diameter * 1e3 / grid_step_mm; - - bowl = makeBowl(grid_dims, el_pos_grid_i, r_c_grid, diameter_grid, natural_focus_pos_grid); - - % Add the current element's bowl geometry to the binary mask - transducer_mask = transducer_mask + bowl; + % Initialize computational grids for the transducer mask and source label matrix + transducer_mask = zeros(grid_dims); % Binary mask representing active transducer regions + source_label = zeros(grid_dims); % Label matrix for identifying individual elements - % Assign a unique label to this element in the source label matrix - source_label = source_label + el_i * bowl; - end - otherwise - error('Array type %s is unknown or not implemented.', transducer_pars.array_shape.type) + % Loop through each transducer element to create its geometry + for el_i = 1:transducer_pars.n_elements + % Create the outer bowl geometry for the current element + bowl = makeBowl(grid_dims, trans_pos, transducer_pars.radius_grid, transducer_pars.Elements_OD(el_i), focus_pos); + + % If the inner diameter is greater than zero, subtract the inner bowl geometry + if transducer_pars.Elements_ID(el_i) > 0 + bowl = bowl - makeBowl(grid_dims, trans_pos, transducer_pars.radius_grid, transducer_pars.Elements_ID(el_i), focus_pos); + end + + % Add the current element's bowl geometry to the binary mask + transducer_mask = transducer_mask + bowl; + + % Assign a unique label to this element in the source label matrix + source_label = source_label + el_i * bowl; end end \ No newline at end of file diff --git a/simple_main.m b/simple_main.m index 095e5023..c57e5514 100644 --- a/simple_main.m +++ b/simple_main.m @@ -8,7 +8,6 @@ % add paths addpath(genpath('functions')) -addpath(genpath('configs')) addpath(genpath('toolboxes')) parameters = load_parameters('tutorial_config.yaml'); % load the configuration file

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 xXW|q( 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 U&Nd#k6bbxOme-K0mVt)+UpHx`xBvhE diff --git a/documentation/img/figure_4.png b/documentation/img/figure_4.png deleted file mode 100644 index 81193b4961bd4c10dfda8d44529ed791aac7558a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45160 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 CvvKAC diff --git a/documentation/img/figure_6.png b/documentation/img/figure_6.png deleted file mode 100644 index 976fcf704bfc16a70c14de725c35939c3d8416ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68114 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 DCRjx0 diff --git a/documentation/img/figure_7.png b/documentation/img/figure_7.png deleted file mode 100644 index b3bc2654f234e4bc24f8dce0370b47b8969882db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 63377 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 W-Cx+QB(#hH2s~Z=T-G@yGywovUV<0^ 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/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 From 679c8ba7dbd2e597a98613abef3f862b6c0c268a Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 14:55:14 +0100 Subject: [PATCH 085/144] [doc] update link to (outdated) tutorial --- documentation/doc_getting-started.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index 4b91af2c..bbb50dbe 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -4,20 +4,13 @@ See [Installation](doc_installation.md). -### [Deprecated] Run 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. - ### [Optional] Explore demos -A simplified 2D benchmarking example can be found [here](https://github.com/jkosciessa/PRESTUS_2D_demo). It is a lightweight example that is designed to run on local CPU ressources. +A simplified 2D benchmarking example can be found [here](https://github.com/jkosciessa/PRESTUS_2D_demo). It is a lightweight example that is designed to run on local CPU ressources. + +A 3D demo using SimNIBS' Ernie template is provided as a [DataLad dataset](https://gin.g-node.org/PRESTUS/sim_ernie/). [Note: This example is based on a prior PRESTUS version. It still has to be updated, but provides a practical example of a possible data management scheme.] -A 3D demo using SimNIBS' Ernie template is provided as a [DataLad dataset](https://gin.g-node.org/PRESTUS/sim_ernie/). [Note: This example is based on a prior PRESTUS version. It still has to be updated, but provides a practical example of a possible data management scheme.] +A (currently outdated) demo that focuses on transducer calibration can be found [here](https://github.com/jkosciessa/PRESTUS_bin/blob/main/tutorial/PRESTUS_intro_tutorial.md). ### Create a study-specific config file From 0d75ccba2f9673aeb93697fc152da5e452590649 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 15:11:40 +0100 Subject: [PATCH 086/144] [doc] update quick start guide --- documentation/doc_getting-started.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index bbb50dbe..8a46a9a4 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -12,6 +12,10 @@ A 3D demo using SimNIBS' Ernie template is provided as a [DataLad dataset](https A (currently outdated) demo that focuses on transducer calibration can be found [here](https://github.com/jkosciessa/PRESTUS_bin/blob/main/tutorial/PRESTUS_intro_tutorial.md). +### Create virtual transducer settings + +See [Transducer] and [Calibration](doc_calibration.md). + ### Create a study-specific config file Configuration metadata must be fully specified in (a set of) config files (in the .yaml format) placed in the 'configs' folder (see [the configuration overview](doc_parameters.md). Some parameters found in the 'default_config' are mandatory for the pipeline to run while others can be left out or are expected to be changed based on the requirements of the analysis. The different parameters that can be used are found in the `default_config.yaml` and in [the configuration overview](doc_parameters.md). The default config is not intended to be altered. Instead, parameter changes should be specified via an application-specific config file (or after loading the default config and before performing the subject call in MATLAB). @@ -28,7 +32,6 @@ See [Backend](doc_backend.md). ### Specify I/O and Planning Images - Specify folder management for inputs, segmentations, and simulations: ``` @@ -59,9 +62,11 @@ It may be desirable to run an intial SimNIBS call prior to running the full pipe To this end, `run_grid_setup`, `run_medium_setup`, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, and `run_posthoc_water_sims` can be deactivated (set to `0`). This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). -> It is currently not possible to run SimNIBS in `matlab` submission mode. If this is chosen, MATLAB will print the terminal command to run manually. +### [Optional] Create a pseudoCT + +See [pseudoCT](doc_pseudoCT.md). -### Run the single_subject_pipeline (and iterate) +### Run the single_subject_pipeline | [Optional] iterate across parameters When the configuration is fully specified, the full pipeline only needs a subject id (a number consisting of no more than 3 digits). To run multiple subjects or setups in parallel, the `single_subject_pipeline` can be submitted using high performance computing jobs (see [HPC documentation](doc_hpc.md)). From e5aa59f215d5970cab93d36c3a03bd5b0ff1ce42 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 15:20:31 +0100 Subject: [PATCH 087/144] [pct] hotfix: uncomment key script parts --- functions/pct/pct_create_pseudoCT.sh | 156 +++++++++++++-------------- 1 file changed, 78 insertions(+), 78 deletions(-) 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). From 569c8f06bd386f3f5a46634cfe1186461ff8ca7c Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 15:53:19 +0100 Subject: [PATCH 088/144] [pct] hotfix for debug plot --- functions/medium/medium_setup.m | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index d6da6332..07b2a9a3 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -93,22 +93,25 @@ 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); + 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); + 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); + title(sprintf('Attention mapping: %s', pct_mapping_attenuation)) output_plot = fullfile(parameters.debug_dir, ... - sprintf('pCT_histograms_%s.png',parameters.pseudoCT_variant)); + sprintf('pCT_histograms%s.png',parameters.results_filename_affix)); exportgraphics(h, output_plot, 'Resolution', 150); close(h); end From 5fd957b96a6a1766cb5f4a50b9472ad62a03c52a Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 18:21:44 +0100 Subject: [PATCH 089/144] [neuronav] update localite readin for GUM files --- documentation/doc_functions.md | 3 +- examples/demo_localite.m | 64 +++-- .../neuronav_compute_series_statistics.m | 232 ++++++++++++------ .../neuronav/neuronav_convert_native_to_MNI.m | 2 +- .../neuronav_convert_trigger_to_voxels.m | 23 +- .../neuronav/neuronav_create_marker_average.m | 36 --- .../neuronav/neuronav_export_session_csv.m | 5 +- .../neuronav_select_and_average_localite.m | 134 ---------- functions/neuronav/neuronav_select_localite.m | 170 +++++++++++++ 9 files changed, 370 insertions(+), 299 deletions(-) delete mode 100644 functions/neuronav/neuronav_create_marker_average.m delete mode 100644 functions/neuronav/neuronav_select_and_average_localite.m create mode 100644 functions/neuronav/neuronav_select_localite.m diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index d47d277f..3417376e 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -76,10 +76,9 @@ The following documents the functions provided in PRESTUS. | `neuronav_convert_MNI_to_native` | NEURONAV | Transform coordinates from MNI space to native subject space | | `neuronav_convert_native_to_MNI` | NEURONAV | Convert native RAS coordinates to MNI space | | `neuronav_convert_trigger_to_voxels` | NEURONAV | Convert Localite trigger positions to voxel (image) coordinates. | -| `neuronav_create_marker_average` | NEURONAV | Build an averaged Localite-trigger structure for export. | | `neuronav_export_session_csv` | NEURONAV | Export per-session coordinate arrays to CSV with labeled voxel and RAS (mm) positions. | | `neuronav_get_group_mean_mni` | NEURONAV | Compute group-level average MNI coordinates (both mm and voxel). | -| `neuronav_select_and_average_localite` | NEURONAV | Select most recent Localite XML for a session. | +| `neuronav_select_localite` | NEURONAV | Select most recent Localite XML for a session. | | `position_transducer_localite` | NEURONAV | Determines transducer and focus positions in voxel space using Localite data and MRI header. | | `pct_create_pseudoCT` | PSEUDO-CT | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | | `pct_skullmapping` | PSEUDO-CT | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. | diff --git a/examples/demo_localite.m b/examples/demo_localite.m index 2e8c005d..1cbeb14f 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.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"; + % 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/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 944b00be..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_bowl * 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_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 From 7175f4c096e5cf0d2ea566ff140a08e7b77c2985 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 18:31:07 +0100 Subject: [PATCH 090/144] [pct] hotfix --- functions/medium/medium_pct_soundspeed.m | 2 +- functions/medium/medium_setup.m | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/medium/medium_pct_soundspeed.m b/functions/medium/medium_pct_soundspeed.m index 4e080371..d21be28b 100644 --- a/functions/medium/medium_pct_soundspeed.m +++ b/functions/medium/medium_pct_soundspeed.m @@ -1,4 +1,4 @@ -function [sound_speed] = medium_pct_soundspeed(parameters, medium, sound_speed, pseudoCT, skull_idx, algorithm) +function [sound_speed] = medium_pct_soundspeed(parameters, medium, sound_speed, density, pseudoCT, skull_idx, algorithm) switch algorithm case 'k-plan' diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index 07b2a9a3..bc952064 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -78,7 +78,7 @@ else pct_mapping_soundspeed = 'none'; end - [sound_speed] = medium_pct_soundspeed(parameters, medium, sound_speed, pseudoCT, skull_idx, pct_mapping_soundspeed); + [sound_speed] = medium_pct_soundspeed(parameters, medium, sound_speed, density, pseudoCT, skull_idx, pct_mapping_soundspeed); % map skull bone attenuation with the desired algorithm if isfield(parameters, "pct_mapping_attenuation") From 61a05090d335d2032a65a4754d74781f6141d79c Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Tue, 10 Mar 2026 19:17:19 +0100 Subject: [PATCH 091/144] [hotfix] correctly grab labels from medium not layers bug notable when dropping requested layers --- functions/head/head_smooth_and_crop.m | 10 ++++------ functions/head/skull_fill_holes.m | 14 ++++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/functions/head/head_smooth_and_crop.m b/functions/head/head_smooth_and_crop.m index f6f92bd9..d6eb1d56 100644 --- a/functions/head/head_smooth_and_crop.m +++ b/functions/head/head_smooth_and_crop.m @@ -11,12 +11,9 @@ % This function turns the original `layered` segmentations into medium masks such % that the setup_medium.m function can fill in the tissue-dependent parameters. - - % Note that tissue masks will assume the labels specified in parameters.layers. + % Tissue masks will contain IDs according to the order of tissues in parameters.medium. grid_step_mm = parameters.grid_step_mm; - - labels = fieldnames(parameters.layers); % Segmentations will be postprocessed. % Incl. smoothing layer transitions & filling potential skull segmentation gaps. @@ -28,9 +25,10 @@ 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 diff --git a/functions/head/skull_fill_holes.m b/functions/head/skull_fill_holes.m index 882b3312..1c5d1ba8 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). @@ -17,7 +17,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. % @@ -30,7 +29,7 @@ % - 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 +37,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.use_pseudoCT == 0 && any(contains(labels, 'skull_cortical')) + labels_medium = fieldnames(parameters.medium); + labels_requested = fieldnames(parameters.layers); + + if parameters.use_pseudoCT == 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 From f45b9a2bb26be674cea5b558acf574dae7502ca9 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 11:32:26 +0100 Subject: [PATCH 092/144] [bug] skull expansion labels could mismatch when removing layers --- functions/head/skull_rubber_wrap.m | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/functions/head/skull_rubber_wrap.m b/functions/head/skull_rubber_wrap.m index 0ea671eb..00ce86d2 100644 --- a/functions/head/skull_rubber_wrap.m +++ b/functions/head/skull_rubber_wrap.m @@ -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); - 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 = (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; From 19c1187f400b47b21f3c61b381f87235500dbfa7 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 12:11:44 +0100 Subject: [PATCH 093/144] [smooth, param] smoothing changed to FWHM mm, parameter name changed --- configs/default_config.yaml | 4 +- documentation/doc_functions.md | 2 +- documentation/doc_parameters.md | 2 +- functions/head/preproc_medium_mask.m | 6 +- functions/head/skull_fill_holes.m | 3 - functions/head/smooth_img.m | 108 +++++++++++++++++---------- functions/medium/medium_setup.m | 22 +++--- functions/pct/pct_skullmapping.m | 7 +- 8 files changed, 91 insertions(+), 63 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index c1b5a587..48cee7a2 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -237,11 +237,11 @@ pad_mm: 0 # Apply symmetric padding to grid prior to transducer + PML setup? csf_mask_expansion_factor: 40 # Expansion factor for the cerebro-spinal fluid brain mask to capture head areas of interest in simulation grid_max_expand: 40 # Prime number grid expansion size to (potentially) speed up computations -# Thresholds for smoothing the skull and other masks +# Thresholds for smoothing masks (and acoustic properties) smooth_method: 'gaussian' # Filter type ['gaussian' (default)|'box'] smooth_threshold_skull: 0.5 # (higher threshold -> thinner mask) smooth_threshold_other: 0.5 # (higher threshold -> thinner mask) -smooth_window: 2 # Smoothing FWHM [grid voxels] +smooth_fwhm_mm: 1 # Smoothing FWHM [mm] smooth_properties: false # Apply smoothing with parameters above to acoustic property maps? # Settings for shrinkwrap (if requested) diff --git a/documentation/doc_functions.md b/documentation/doc_functions.md index 3417376e..e6e62f8b 100644 --- a/documentation/doc_functions.md +++ b/documentation/doc_functions.md @@ -81,7 +81,7 @@ The following documents the functions provided in PRESTUS. | `neuronav_select_localite` | NEURONAV | Select most recent Localite XML for a session. | | `position_transducer_localite` | NEURONAV | Determines transducer and focus positions in voxel space using Localite data and MRI header. | | `pct_create_pseudoCT` | PSEUDO-CT | Generate Hounsfield pseudoCT from SimNIBS PETRA-UTE (replacing T2) via N4 bias correction, linear skull mapping, partial volume correction, smoothing, and tissue masks. | -| `pct_skullmapping` | PSEUDO-CT | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. | +| `pct_skullmapping` | PSEUDO-CT | Computes pseudo-CT mapping for cortical and trabecular bone using UTE histograms. [deprecated, debug] | | `pct_soft_tissue_peak` | PSEUDO-CT | Identifies the soft tissue peak from UTE intensity distribution histograms. | | `fit_pairwiselinear` | PSEUDO-CT | Perform a pairwise linear fit between HU and density values with optional plot. | | `plot_coronal_slices` | PLOT | (DEPRECATED) Visualizes coronal slices of a 3D image with optional legends for labeled images. | diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index 43a5d638..a0e926f9 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -52,7 +52,7 @@ see [doc_preproc.md](doc_preproc.md) | `smooth_method` | Smoothing filter type [`gaussian`(default) / `box`] | | | `smooth_threshold_skull` | Threshold for smoothing the skull mask; higher values result in thinner masks. | | | `smooth_threshold_other` | Threshold for smoothing other masks; higher values result in thinner masks. | | -| `smooth_window` | FWHM of smoothing `gaussian` or `box` with (default: 2) [voxel] | | +| `smooth_fwhm_mm` | FWHM of smoothing kernel [mm] (default: 1) | | | `skullfillmethod` | Method for filling holes in the skull ['rubberwrap'(default)/'imclose'] | | | `wrapradius` | Skull rubber wrap radius [grid voxels] (default: 10) | Larger = tighter rubber that ignores bigger dents; recommended: 2-10 | | `smooth_properties` | Apply smoothing with parameters above to acoustic property maps? | `false`(default)/`true` | diff --git a/functions/head/preproc_medium_mask.m b/functions/head/preproc_medium_mask.m index 66393095..79fd6662 100644 --- a/functions/head/preproc_medium_mask.m +++ b/functions/head/preproc_medium_mask.m @@ -46,7 +46,7 @@ threshold = parameters.smooth_threshold_other; end - layer_mask_smoothed = smooth_img(layer_mask, parameters.smooth_window, ... + layer_mask_smoothed = smooth_img(layer_mask, parameters.smooth_fwhm_mm, parameters.grid_step_mm, ... threshold, parameters.smooth_method); % assign tissue-specific medium ID medium_masks(layer_mask_smoothed ~= 0) = find(strcmp(medium_labels, label_name)); @@ -59,14 +59,14 @@ 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, ... + layer_mask_smoothed = smooth_img(layer_mask, parameters.smooth_fwhm_mm, parameters.grid_step_mm, ... parameters.smooth_threshold_skull, parameters.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, ... + trabecular_mask_smoothed = smooth_img(trabecular_mask, parameters.smooth_fwhm_mm, parameters.grid_step_mm, ... parameters.smooth_threshold_skull, parameters.smooth_method); medium_masks(trabecular_mask_smoothed ~= 0) = trabecular_i; end diff --git a/functions/head/skull_fill_holes.m b/functions/head/skull_fill_holes.m index 1c5d1ba8..b3fd0bed 100644 --- a/functions/head/skull_fill_holes.m +++ b/functions/head/skull_fill_holes.m @@ -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. @@ -25,7 +23,6 @@ % 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: diff --git a/functions/head/smooth_img.m b/functions/head/smooth_img.m index 0eaf02a0..da99b031 100644 --- a/functions/head/smooth_img.m +++ b/functions/head/smooth_img.m @@ -1,70 +1,96 @@ -function thresholded_img = smooth_img(unsmoothed_img, fwhm_voxels, threshold, method) -%SMOOTH_IMG - Smooth tissue masks with voxel-space FWHM +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, 2, 0.5, 'gaussian') +% smoothed = smooth_img(binary_mask, 1.0, 0.5, 0.5, 'gaussian') % % INPUT: % unsmoothed_img - Input image [Nx Ny Nz] or [Nx Ny] -% fwhm_voxels - FWHM in grid voxels +% 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 - Smoothed (binary if threshold > 0) % -% DEFAULT: fwhm_voxels=2 (e.g., 1mm equivalent @ 0.5mm grid) +% DEFAULT: fwhm_mm=1.0, voxel_size_mm=0.5 % % HOW IT WORKS: -% GAUSSIAN: FWHM → σ = fwhm/2.35482 → imgaussfilt3(img, σ) -% Creates smooth tissue gradients (center-high, edge-low weights) -% BOX: Uniform average over round(fwhm_voxels)^3 cube -% Equal weights across kernel. +% 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} - fwhm_voxels (1,1) double {mustBePositive} = 2.0 + 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); - - % Convert FWHM(voxels) → σ(voxels) for Gaussian - sigma_voxels = fwhm_voxels / 2.35482; % FWHM = 2.35482 × σ - - if fwhm_voxels > 0 - fprintf('Smoothing: FWHM=%.1f voxels → σ=%.2f voxels\n', fwhm_voxels, sigma_voxels); - + + % 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" - % === GAUSSIAN: Weighted average, bell-shaped kernel === - % Weights: exp(-r²/(2σ²)). Center=1.0, edges~0.01 - smoothed_img = imgaussfilt(img, sigma_voxels); - - case "box" - % === BOX: Uniform average over cubic kernel === - % All voxels in kernel get equal weight (1/N_total) - kernel_size = max(3, round(fwhm_voxels)); % e.g. fwhm=4 → 4×4 - kernel = ones(kernel_size) / kernel_size^2; % Sum=1.0 - smoothed_img = imfilter(img, kernel, 'replicate'); + % 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" - % === 3D GAUSSIAN: Rotational symmetric smoothing === - % Each voxel = weighted average of 6σ-radius neighborhood - smoothed_img = imgaussfilt3(img, sigma_voxels); - + % imgaussfilt3 sigma also in voxels [web:4] + smoothed_img = imgaussfilt3(img, sig); + case "box" - % === 3D BOX: Equal average over cubic volume === - % e.g. fwhm=4 → [4 4 4] cube = 64 voxels, each weight=1/64 - kernel_size = max(3, round(fwhm_voxels)); - kernel = ones([kernel_size kernel_size kernel_size]) / kernel_size^3; - smoothed_img = convn(img, kernel, 'same'); + 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); @@ -72,11 +98,11 @@ else smoothed_img = img; % No smoothing end - + % Post-process binarization (optional) if threshold > 0 - thresholded_img = smoothed_img > threshold; % Hard threshold + thresholded_img = smoothed_img > threshold; else - thresholded_img = smoothed_img; % Keep continuous probabilities + thresholded_img = smoothed_img; end end diff --git a/functions/medium/medium_setup.m b/functions/medium/medium_setup.m index bc952064..157d289d 100644 --- a/functions/medium/medium_setup.m +++ b/functions/medium/medium_setup.m @@ -186,18 +186,22 @@ %% smooth medium masks if isfield(parameters, 'smooth_properties') && parameters.smooth_properties == true - disp("Smoothing acoustic proprty maps ..."); + disp("Smoothing acoustic property maps ..."); tmp_density = density; % keep unsmoothed image for figure - sound_speed = smooth_img(sound_speed, parameters.smooth_window, 0, parameters.smooth_method); - density = smooth_img(density, parameters.smooth_window, 0, parameters.smooth_method); - alpha_coeff_fixed = smooth_img(alpha_coeff_fixed, parameters.smooth_window, 0, parameters.smooth_method); - thermal_conductivity = smooth_img(thermal_conductivity, parameters.smooth_window, 0, parameters.smooth_method); - specific_heat = smooth_img(specific_heat, parameters.smooth_window, 0, parameters.smooth_method); - perfusion_coeff = smooth_img(perfusion_coeff, parameters.smooth_window, 0, parameters.smooth_method); - absorption_fraction = smooth_img(absorption_fraction, parameters.smooth_window, 0, parameters.smooth_method); - temp_0 = smooth_img(temp_0, parameters.smooth_window, 0, parameters.smooth_method); + fwhm_mm = parameters.smooth_fwhm_mm; + grid_mm = parameters.grid_step_mm; + smooth_method = parameters.smooth_method; + + 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.debug == 1 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); From dc70a4a4ac2df7e4a4ddaaeabb73ad8b3400870c Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 13:33:18 +0100 Subject: [PATCH 094/144] [hcp] refactor slurm submission, relabel temp files --- .../hpc/single_subject_pipeline_with_slurm.m | 303 ++++++++++-------- 1 file changed, 170 insertions(+), 133 deletions(-) diff --git a/functions/hpc/single_subject_pipeline_with_slurm.m b/functions/hpc/single_subject_pipeline_with_slurm.m index fab04b31..00634e4f 100644 --- a/functions/hpc/single_subject_pipeline_with_slurm.m +++ b/functions/hpc/single_subject_pipeline_with_slurm.m @@ -2,176 +2,213 @@ function single_subject_pipeline_with_slurm(subject_id, parameters, wait_for_job 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) + wait_for_job logical = false + timelimit string = "04:00:00" + memorylimit (1,1) double = 40 options.sequential_configs struct = struct() end - % Save that this parameter set is using slurm for further branching + % Setup and validation parameters.submit_medium = 'slurm'; + validate_slurm_parameters(parameters); + [log_dir, path_to_pipeline] = setup_output_directory(parameters, subject_id); + + % Generate consistent temp filenames + [temp_data_path, temp_m_path, temp_slurm_path, temp_m_file] = generate_temp_files(log_dir); + + % Create data file and MATLAB script + save(temp_data_path, 'subject_id', 'parameters'); + write_matlab_script(temp_m_path, temp_data_path, path_to_pipeline, options.sequential_configs); + + % Create and submit SLURM job + write_slurm_script(temp_slurm_path, parameters, subject_id, timelimit, memorylimit, temp_m_file, log_dir); + job_id = submit_slurm_job(temp_slurm_path, log_dir, parameters, subject_id); + + % Wait for completion if requested + if wait_for_job + wait_for_job_completion(job_id); + end + + disp('Continuing with the MATLAB script...'); +end + +%% Helper functions +function validate_slurm_parameters(parameters) 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; + warning('Interactive mode disabled for SLURM jobs.'); + parameters.interactive = false; 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'"); + assert(matches(parameters.overwrite_files, ["always", "never"]), ... + 'overwrite_files must be "always" or "never" for SLURM jobs.'); +end - % 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 +function [log_dir, path_to_pipeline] = setup_output_directory(parameters, subject_id) + if isfield(parameters, 'subject_subfolder') && parameters.subject_subfolder output_dir = fullfile(parameters.sim_path, sprintf('sub-%03d', subject_id)); else - output_dir = fullfile(parameters.sim_path); + output_dir = parameters.sim_path; end - if ~isfolder(output_dir) - mkdir(output_dir); - 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')); + if ~isfolder(log_dir), mkdir(log_dir); end - subj_id_string = sprintf('sub-%03d', subject_id); + [path_to_pipeline, ~, ~] = fileparts(which('single_subject_pipeline')); +end - % 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"); +function [temp_data_path, temp_m_path, temp_slurm_path, temp_m_file] = generate_temp_files(log_dir) + % get timestamp + timestamp = datestr(now, 'yyyymmdd_HHMMSS'); + temp_base = tempname(log_dir); + + % get temporary id (to avoid competing parallel calls) + [~, temp_base_name] = fileparts(temp_base); + temp_base_name = temp_base_name(end-7:end); % Last 8 chars only - temp_m_file = tempname(log_dir); - fid = fopen([temp_m_file '.m'], 'w+'); + % .mat + temp_data_path = fullfile(log_dir, sprintf('temp_data_%s_%s.mat', timestamp, temp_base_name)); + % .m + temp_m_file = sprintf('temp_matlab_%s_%s', timestamp, temp_base_name); + temp_m_path = fullfile(log_dir, [temp_m_file, '.m']); + % .sh + temp_slurm_file = sprintf('temp_slurm_%s_%s.sh', timestamp, temp_base_name); + temp_slurm_path = fullfile(log_dir, temp_slurm_file); +end - % 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']); +function write_matlab_script(temp_m_path, temp_data_path, path_to_pipeline, sequential_configs) + fid = fopen(temp_m_path, 'w+'); + + if ~isempty(fieldnames(sequential_configs)) + save(temp_data_path, 'sequential_configs', '-append'); + 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_path); 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']); + 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_path); end fclose(fid); - [~,temp_m_file_name,~] = fileparts(temp_m_file); - - if ~isfield(parameters, 'slurm_job_prefix') - parameters.slurm_job_prefix = 'PRESTUS'; - end +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 +function write_slurm_script(temp_slurm_path, parameters, subject_id, timelimit, memorylimit, temp_m_file, log_dir) + subj_id_string = sprintf('sub-%03d', subject_id); + job_name = get_job_name(parameters, subj_id_string); + + fid = fopen(temp_slurm_path, 'w+'); + fprintf_slurm_header(fid, job_name, parameters, subj_id_string, log_dir, timelimit, memorylimit); + if get_gpu_request(parameters) fprintf(fid, 'nvidia-smi\n'); end fprintf(fid, 'module load matlab/R2023b\n'); - fprintf(fid, 'matlab -batch "%s"\n', temp_m_file_name); + fprintf(fid, 'matlab -batch "%s"\n', temp_m_file); fclose(fid); +end - % Create the full command to submit the batch script - sbatch_call = sprintf('sbatch %s.sh', temp_slurm_file); - - % Execute the full command +function job_id = submit_slurm_job(temp_slurm_path, log_dir, parameters, subject_id) + sbatch_call = sprintf('sbatch %s', temp_slurm_path); 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) + + subj_id_string = sprintf('sub-%03d', subject_id); + job_name = get_job_name(parameters, subj_id_string); + + fprintf('Submitted the job to the cluster with a command \n%s \nSee logs in %s\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 + + if status ~= 0 + error('SLURM submission failed: %s', out); + end + + job_ids = regexp(out, '\d+', 'match'); + if isempty(job_ids) disp(out); + error('No job ID returned from SLURM'); end + job_id = str2double(job_ids{1}); + fprintf('Job "%s" (ID: %i) submitted successfully\n', job_name, job_id); +end + +function wait_for_job_completion(job_id) + disp('User has chosen to wait until job is finished...'); + job_completed = false; - if status == 0 - disp('Job submitted successfully'); + while ~job_completed + check_cmd = sprintf('sacct -j %i -o State --noheader | tail -n 1', job_id); + [status_check, out] = system(check_cmd); - % 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; + if status_check == 0 + job_state = strtrim(out); + disp(['Job status: ', job_state]); - 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 + if strcmp(job_state, 'RUNNING') + pause(n_sec); + elseif strcmp(job_state, 'PENDING') + pause(n_sec); + elseif strcmp(job_state, 'COMPLETED') + disp('Job completed successfully.'); + job_completed = true; + else + pause(n_sec); + end + else + disp('Failed to check job status.'); + disp(out); + check_cmd = sprintf('scontrol show job %s', job_id); + [status_check, ~] = system(check_cmd); + if status_check ~= 0 + disp('Job is no longer listed. Assuming it completed.'); + job_completed = true; 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 + break; end end + end +end + +function job_name = get_job_name(parameters, subj_id_string) + if ~isfield(parameters, 'slurm_job_prefix') + parameters.slurm_job_prefix = 'PRESTUS'; + end + job_name = [parameters.slurm_job_prefix '_' subj_id_string]; +end + +function request_gpu = get_gpu_request(parameters) + if isfield(parameters, 'hpc_partition') && ~isempty(parameters.hpc_partition) && ~strcmp(parameters.hpc_partition, '') + request_gpu = true; + elseif strcmp(parameters.code_type, 'matlab_gpu') || strcmp(parameters.code_type, 'cpp_gpu') + request_gpu = true; else - disp('Command failed to submit the job.'); - disp(out); % Display the error message + request_gpu = false; end +end + +function fprintf_slurm_header(fid, job_name, parameters, subj_id_string, log_dir, timelimit, memorylimit) + fprintf(fid, '#!/bin/bash\n'); + fprintf(fid, '#SBATCH --job-name=%s\n', job_name); - % Continue with MATLAB script - disp('Continuing with the MATLAB script...'); + % Partition + if isfield(parameters, 'hpc_partition') && ~isempty(parameters.hpc_partition) && ~strcmp(parameters.hpc_partition, '') + fprintf(fid, '#SBATCH --partition=%s\n', parameters.hpc_partition); + elseif get_gpu_request(parameters) + fprintf(fid, '#SBATCH --partition=gpu\n'); + end + + % GPU resources + if isfield(parameters, 'hpc_gpu') && ~isempty(parameters.hpc_gpu) && ~strcmp(parameters.hpc_gpu, '') + fprintf(fid, '#SBATCH --gres=%s\n', parameters.hpc_gpu); + elseif get_gpu_request(parameters) + fprintf(fid, '#SBATCH --gres=gpu:1\n'); + end + + % Reservation + if isfield(parameters, 'hpc_reservation') && ~isempty(parameters.hpc_reservation) && ~strcmp(parameters.hpc_reservation, '') + fprintf(fid, '#SBATCH --reservation=%s\n', parameters.hpc_reservation); + end + + % Resources + fprintf(fid, '#SBATCH --mem=%iG\n', memorylimit); + fprintf(fid, '#SBATCH --time=%s\n', 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); end \ No newline at end of file From 5a4506f27ecbace48c7560da0736a3ae2a1cf3bb Mon Sep 17 00:00:00 2001 From: sirmrmarty Date: Wed, 11 Mar 2026 16:02:04 +0100 Subject: [PATCH 095/144] [feature] fitting velocity exactly to desired intensity --- configs/calibration_config.yaml | 1 + .../calibration/calibration_transducer.m | 8 ++ .../calibration/fit_velocity_to_intensity.m | 74 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 functions/calibration/fit_velocity_to_intensity.m diff --git a/configs/calibration_config.yaml b/configs/calibration_config.yaml index be8cf2ae..79b9b27e 100644 --- a/configs/calibration_config.yaml +++ b/configs/calibration_config.yaml @@ -42,6 +42,7 @@ opt_weights: 0 # Weighting of the original profile during fitting (0 = uniform weighting, >1 increasingly narrow Gaussian FWHM) opt_seed: 251024 # Random seed for optimization opt_upper_velocity: 0.2 # Upper velocity in global search + fit_velocity_to_intensity: true # Correct velocity after global search to match desired peak intensity # Other skip_front_peak_mm: 10 # Distance to ignore from the start of axial profile (mm) to avoid near-field peak artifacts in reporting \ No newline at end of file diff --git a/functions/calibration/calibration_transducer.m b/functions/calibration/calibration_transducer.m index 9f94e305..c92556d5 100644 --- a/functions/calibration/calibration_transducer.m +++ b/functions/calibration/calibration_transducer.m @@ -136,6 +136,14 @@ 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); + end + % Recalculate analytical solution with optimized phases and velocity profile_oneil_opt = ... recompute_oneil_solution(... diff --git a/functions/calibration/fit_velocity_to_intensity.m b/functions/calibration/fit_velocity_to_intensity.m new file mode 100644 index 00000000..608ef8b6 --- /dev/null +++ b/functions/calibration/fit_velocity_to_intensity.m @@ -0,0 +1,74 @@ +function [corrected_velocity, I_peak_before, I_peak_after] = fit_velocity_to_intensity(... + parameters, profile_oneil, opt_phases, opt_velocity, desired_intensity) +% 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). +% +% 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]. +% +% 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.water.sound_speed, ... + parameters.medium.water.density, ... + (axial_position - 0.5) * 1e-3); + + % Convert pressure to intensity [W/cm^2] + I_axial = p_axial .^ 2 / (2 * parameters.medium.water.sound_speed * parameters.medium.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 + + % Correct velocity analytically: I ∝ v² => v_new = v_old * sqrt(I_desired / I_peak) + correction_factor = sqrt(desired_intensity / I_peak_before); + corrected_velocity = opt_velocity * correction_factor; + I_peak_after = desired_intensity; + + % 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('Peak intensity: %.2f -> %.2f W/cm^2 (desired: %.2f)\n', ... + I_peak_before, I_peak_after, desired_intensity); + +end From 25177ef748cf22b3a9e78f136a93aac2dccc3ad3 Mon Sep 17 00:00:00 2001 From: Julian Kosciessa Date: Wed, 11 Mar 2026 16:35:53 +0100 Subject: [PATCH 096/144] [pct, param] allow to turn off nifti creation, include pCT in html if used --- configs/default_config.yaml | 1 + documentation/doc_getting-started.md | 2 +- documentation/doc_parameters.md | 5 +- functions/core/generate_simulation_report.m | 206 ++++++++++++++------ functions/plot/plot_overlay.m | 6 +- functions/plot/plot_transducer_overlay.m | 16 +- functions/single_subject_pipeline.m | 12 +- 7 files changed, 170 insertions(+), 78 deletions(-) diff --git a/configs/default_config.yaml b/configs/default_config.yaml index 48cee7a2..f3dd471b 100755 --- a/configs/default_config.yaml +++ b/configs/default_config.yaml @@ -53,6 +53,7 @@ run_medium_setup: 1 # Map medium acoustic properties Mandatory for simulat run_source_setup: 1 # Set up acoustic source? Mandatory for simulations. run_acoustic_sims: 1 # Run acoustic simulations? run_heating_sims: 0 # Run heating simulations? Run once acoustic simulation provides sensible results. +run_nifti_creation: 1 # Run nifti creation? run_posthoc_water_sims: 1 # Run simulations in free water with identical setup? generate_report: 1 # Generate a self-contained HTML simulation report at the end of the pipeline? diff --git a/documentation/doc_getting-started.md b/documentation/doc_getting-started.md index 8a46a9a4..faf3b38d 100644 --- a/documentation/doc_getting-started.md +++ b/documentation/doc_getting-started.md @@ -60,7 +60,7 @@ See [Placement](doc_placement.md). It may be desirable to run an intial SimNIBS call prior to running the full pipeline incl. segmentation postprocessing, source setup, acoustic and thermal simulations. By default, existing segmentations will be reused and not overwritten unless explicitly requested with `overwrite_simnibs` (regardless of `overwrite_files`). -To this end, `run_grid_setup`, `run_medium_setup`, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, and `run_posthoc_water_sims` can be deactivated (set to `0`). This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). +To this end, `run_grid_setup`, `run_medium_setup`, `run_source_setup`, `run_acoustic_sims`, `run_heating_sims`, `run_nifti_creation`, and `run_posthoc_water_sims` can be deactivated (set to `0`). This separate step is required to inform the skull layer [using pseudoCTs](doc_pseudoCT.md). ### [Optional] Create a pseudoCT diff --git a/documentation/doc_parameters.md b/documentation/doc_parameters.md index a0e926f9..258b2e41 100644 --- a/documentation/doc_parameters.md +++ b/documentation/doc_parameters.md @@ -31,8 +31,9 @@ see [doc_preproc.md](doc_preproc.md) | `run_grid_setup` | Setup grid and run head processing? Mandatory for simulations. | (`1 = yes, 0 = no`) | | `run_medium_setup` | Map medium acoustic properties? Mandatory for simulations. | (`1 = yes, 0 = no`) | | `run_source_setup` | Set up acoustic source? Mandatory for simulations. | (`1 = yes, 0 = no`) | -| `run_acoustic_sims` | Run acoustic simulations. | (`1 = yes, 0 = no`) | -| `run_heating_sims` | Run heating simulations. | (`1 = yes, 0 = no`) | +| `run_acoustic_sims` | Run acoustic simulations? | (`1 = yes, 0 = no`) | +| `run_heating_sims` | Run heating simulations? | (`1 = yes, 0 = no`) | +| `run_nifti_creation` | Run Nifti creation? | (`1 = yes, 0 = no`) | | `run_posthoc_water_sims` | Run water simulations following head simulations. | (`1 = yes, 0 = no`) | | `generate_report` | Generate a self-contained HTML simulation report at the end of the pipeline? | (`1 = yes, 0 = no`) | | `n_sim_dims` | Simulation type (2D / 3D). | `2` = 2D (`kspaceFirstOrder2D`), `3` = 3D(`kspaceFirstOrder3D`). If not specified, it is inferred from `default_grid_dims`. For `axisymmetric` setups (see below), specify 2D. | diff --git a/functions/core/generate_simulation_report.m b/functions/core/generate_simulation_report.m index 92d12f25..55c7fc97 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 = ''; @@ -113,7 +104,18 @@ end end - % Section 6: Positioning (open by default) + % Section 6: pseudoCT [Optional] (collapsed by default) + try + if isfield(parameters, 'use_pseudoCT') && parameters.use_pseudoCT + 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,7 +131,7 @@ 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 html_parts{end+1} = collapsible_section('Thermal Results', ... @@ -139,7 +141,7 @@ 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 html_parts{end+1} = collapsible_section('Debug Information', ... @@ -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'); @@ -287,20 +289,20 @@ name = metric_names{i}; info = limits.(name); - % 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) - value = val(end); % last row if multiple - end - end - - % Special handling for pressure: use dynamic unit scaling - if strcmp(name, 'max_pressure_Pa') && ~isnan(value) - [value, display_unit] = scale_pressure(value); - end + % 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) + value = val(end); % last row if multiple + end + end + + % Special handling for pressure: use dynamic unit scaling + if strcmp(name, 'max_pressure_Pa') && ~isnan(value) + [value, display_unit] = scale_pressure(value); + end % Determine color color = safety_color(value, info.limit); @@ -309,15 +311,15 @@ html = [html sprintf('