Skip to content

Commit bbe5bd9

Browse files
committed
Merge branch 'feature/VHEE_model' into 'develop'
Feature/vhee model See merge request e040/e0404/pyRadPlan!92
2 parents f7492c4 + 625c468 commit bbe5bd9

File tree

15 files changed

+461
-79
lines changed

15 files changed

+461
-79
lines changed

examples/pencilbeam_vhee.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# %% [markdown]
2+
"""# Example for VHEE dose calculation using pencilbeam engine."""
3+
# %% [markdown]
4+
# This example demonstrates how to use the pyRadPlan library to perform dose calculations using the VHEE model.
5+
6+
# IMPORTANT: Please use the ipopt optimizer, because scipy might not converge or converge to fast.
7+
# (Will be updated in the future)
8+
9+
# To display this script in a Jupyter Notebook, you need to install jupytext via pip and run the following command.
10+
# This will create a .ipynb file in the same directory:
11+
12+
# ```bash
13+
# pip install jupytext
14+
# jupytext --to notebook path/to/this/file/pencilbeam_proton.py
15+
16+
# %%
17+
# Import necessary libraries
18+
import logging
19+
20+
import numpy as np
21+
22+
from pyRadPlan import (
23+
IonPlan,
24+
generate_stf,
25+
calc_dose_influence,
26+
fluence_optimization,
27+
plot_slice,
28+
load_tg119,
29+
)
30+
31+
from pyRadPlan.optimization.objectives import SquaredDeviation, SquaredOverdosing
32+
33+
logging.basicConfig(level=logging.INFO)
34+
35+
# %%
36+
# Load TG119 (provided within pyRadPlan)
37+
ct, cst = load_tg119()
38+
39+
# %% [markdown]
40+
# In this section, we create a VHEE therapy plan using the FermiEyges machine. </br>
41+
# We choose "Generic" for FermiEyges or "Focused" for a focused VHEE machine. </br>
42+
# We set the energy to 200 MeV, the bixel width to 5 mm and select 5 gantry angles with 72 degree spacing.
43+
# %%
44+
# Create a plan object - (VHEE | Generic) => FermiEyges
45+
pln = IonPlan(radiation_mode="VHEE", machine="Generic") # Alternative: Focused
46+
pln.prop_opt = {"solver": "ipopt"} # USE IPOPT!
47+
48+
# Some specific Settings for VHEE
49+
pln.prop_stf = {
50+
"energy": 200, # set VHEE energy at [100, 150, 200] MeV
51+
"bixel_width": 5.0,
52+
"gantry_angles": [0, 72, 144, 216, 288],
53+
"couch_angles": [0, 0, 0, 0, 0],
54+
}
55+
56+
# Setting the dose grid resolution
57+
pln.prop_dose_calc = {"dose_grid": {"resolution": [3.0, 3.0, 3.0]}}
58+
59+
# Generate Steering Geometry ("stf")
60+
stf = generate_stf(ct, cst, pln)
61+
62+
# Calculate Dose Influence Matrix ("dij")
63+
dij = calc_dose_influence(ct, cst, stf, pln)
64+
65+
# Optimization
66+
cst.vois[0].objectives = [SquaredOverdosing(priority=10.0, d_max=1.0)] # OAR
67+
cst.vois[1].objectives = [SquaredDeviation(priority=100.0, d_ref=3.0)] # Target
68+
cst.vois[2].objectives = [SquaredOverdosing(priority=10.0, d_max=2.0)] # BODY
69+
70+
# Calculate optimized fluence
71+
fluence = fluence_optimization(ct, cst, stf, dij, pln)
72+
73+
# Compute the result on the CT grid
74+
result = dij.compute_result_ct_grid(fluence)
75+
76+
# %% [markdown]
77+
# Visualize the results
78+
# %%
79+
# Choose a slice to visualize
80+
view_slice = int(np.round(ct.size[2] / 2))
81+
82+
# Visualize
83+
plot_slice(
84+
ct=ct,
85+
cst=cst,
86+
overlay=result["physical_dose"],
87+
view_slice=view_slice,
88+
plane="axial",
89+
overlay_unit="Gy",
90+
)
19.1 KB
Binary file not shown.
124 KB
Binary file not shown.

pyRadPlan/dose/engines/_base_pencilbeam.py

Lines changed: 49 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,13 @@ def _calc_dose(self, ct: CT, cst: StructureSet, stf: SteeringInformation):
183183
# Initialize Ray Geometry
184184
curr_ray = self._init_ray(curr_beam, j)
185185

186+
# Even if the ray hits nothing, we still emit empty bixel columns
187+
# so that CSC structures and bookkeeping stay consistent.
188+
# The following block might be re-enabled in future to skip empty rays.
189+
186190
# check if ray hit anything. If so, skip the computation
187-
if all(not arr.size for arr in curr_ray["rad_depths"]):
188-
continue
191+
# if all(not arr.size for arr in curr_ray["rad_depths"]):
192+
# continue #continue
189193

190194
# TODO: incorporate scenarios correctly
191195
for ct_scen in range(self.mult_scen.num_of_ct_scen):
@@ -458,10 +462,12 @@ def _init_ray(self, beam_info: dict[str], j: int) -> dict[str]:
458462
ray["ray_index"] = j
459463
ray["iso_center"] = beam_info["beam"]["iso_center"]
460464

461-
if "num_of_bixels_per_ray" not in beam_info["beam"]:
462-
ray["num_of_bixels"] = 1
463-
else:
465+
if "num_of_bixels_per_ray" in beam_info["beam"]:
464466
ray["num_of_bixels"] = beam_info["beam"]["num_of_bixels_per_ray"][j]
467+
else:
468+
# Fallback: use the actual number of beamlets on this ray
469+
# This is needed for machines like "Focused" that don't precompute counts.
470+
ray["num_of_bixels"] = len(ray.get("beamlets", [])) or 0
465471

466472
ray["source_point_bev"] = beam_info["beam"]["source_point_bev"]
467473
ray["sad"] = beam_info["beam"]["sad"]
@@ -713,59 +719,45 @@ def _fill_dij(
713719
dict
714720
The updated dose influence matrix.
715721
"""
716-
# Only fill if we actually had bixel (indices) to compute
717-
if bixel and "ix" in bixel and bixel["ix"].any():
718-
sub_scen_idx = [
719-
np.unravel_index(scen_idx, self.mult_scen.scen_mask.shape)[i]
720-
for i in range(self.mult_scen.scen_mask.ndim)
721-
]
722-
sub_scen_idx = tuple(sub_scen_idx)
723-
724-
for q_name in self._computed_quantities:
725-
if self._calc_dose_direct:
726-
# We accumulate the current bixel to the container
722+
# Determine if we have entries to store for this bixel
723+
ix_size = 0
724+
if bixel and "ix" in bixel:
725+
ix_val = bixel["ix"]
726+
try:
727+
ix_size = ix_val.size
728+
except AttributeError:
729+
ix_size = len(ix_val)
730+
731+
sub_scen_idx = tuple(np.unravel_index(scen_idx, self.mult_scen.scen_mask.shape))
732+
733+
for q_name in self._computed_quantities:
734+
if self._calc_dose_direct:
735+
if ix_size > 0:
727736
dij[q_name][sub_scen_idx][bixel["ix"], curr_beam_idx] += (
728737
bixel["weight"] * bixel[q_name]
729738
)
730-
else:
731-
# first we fill the index pointer array
732-
dij[q_name][sub_scen_idx]["indptr"][bixel_counter + 1] = (
733-
dij[q_name][sub_scen_idx]["indptr"][bixel_counter] + bixel["ix"].size
734-
)
735-
736-
# If we haven't preallocated enough, we need to expand the arrays
737-
if (
738-
dij[q_name][sub_scen_idx]["data"].size
739-
< dij[q_name][sub_scen_idx]["nnz"] + bixel["ix"].size
740-
):
739+
else:
740+
data_dict = dij[q_name][sub_scen_idx]
741+
# Advance column pointer even when ix_size == 0 to keep CSC valid
742+
start = data_dict["indptr"][bixel_counter]
743+
end = start + ix_size
744+
data_dict["indptr"][bixel_counter + 1] = end
745+
746+
if ix_size > 0:
747+
need_nnz = data_dict["nnz"] + ix_size
748+
if data_dict["data"].size < need_nnz:
741749
logger.debug("Resizing data and indices arrays for %s...", q_name)
742-
743-
# We estimate the required size by the remaining
744-
# number of beamlets / columns in the sparse matrix
745-
# 1 additional beamlet is added
746-
shape_resize = (self._num_of_columns_dij - bixel_counter) * (
747-
bixel["ix"].size + 1
748-
)
749-
shape_resize += dij[q_name][sub_scen_idx]["data"].size
750-
dij[q_name][sub_scen_idx]["data"].resize((shape_resize,), refcheck=False)
751-
dij[q_name][sub_scen_idx]["indices"].resize(
752-
(shape_resize,), refcheck=False
750+
grow = max(
751+
ix_size, (self._num_of_columns_dij - bixel_counter) * (ix_size + 1)
753752
)
753+
new_size = data_dict["data"].size + grow
754+
data_dict["data"].resize((new_size,), refcheck=False)
755+
data_dict["indices"].resize((new_size,), refcheck=False)
754756

755-
# Fill the corresponding values and indices using indptr
756-
dij[q_name][sub_scen_idx]["data"][
757-
dij[q_name][sub_scen_idx]["indptr"][bixel_counter] : dij[q_name][
758-
sub_scen_idx
759-
]["indptr"][bixel_counter + 1]
760-
] = bixel[q_name]
761-
dij[q_name][sub_scen_idx]["indices"][
762-
dij[q_name][sub_scen_idx]["indptr"][bixel_counter] : dij[q_name][
763-
sub_scen_idx
764-
]["indptr"][bixel_counter + 1]
765-
] = bixel["ix"]
766-
767-
# Store how many nnzs we actually have in the matrix
768-
dij[q_name][sub_scen_idx]["nnz"] += bixel["ix"].size
757+
# Fill values and indices into the allocated slice
758+
data_dict["data"][start:end] = bixel[q_name]
759+
data_dict["indices"][start:end] = bixel["ix"]
760+
data_dict["nnz"] += ix_size
769761

770762
# Bookkeeping of bixel numbers
771763
# remember beam and bixel number
@@ -886,6 +878,8 @@ def calc_geo_dists(
886878
# Normalize the vector
887879
b = b / np.linalg.norm(b)
888880

881+
# Cross product
882+
cross_ab = np.cross(a, b)
889883
# Define rotation matrix
890884
if np.all(a == b):
891885
rot_coords_temp = rot_coords_bev[rad_depth_ix, :]
@@ -897,10 +891,10 @@ def ssc(v: np.ndarray) -> np.ndarray:
897891

898892
derived_rot_mat = (
899893
np.eye(3)
900-
+ ssc(np.cross(a, b))
901-
+ np.dot(ssc(np.cross(a, b)), ssc(np.cross(a, b)))
894+
+ ssc(cross_ab)
895+
+ np.dot(ssc(cross_ab), ssc(cross_ab))
902896
* (1 - np.dot(a, b))
903-
/ (np.linalg.norm(np.cross(a, b)) ** 2)
897+
/ (np.linalg.norm(cross_ab) ** 2)
904898
)
905899
rot_coords_temp = np.dot(rot_coords_bev[rad_depth_ix, :], derived_rot_mat)
906900

pyRadPlan/dose/engines/_base_pencilbeam_particle.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class ParticlePencilBeamEngineAbstract(PencilBeamEngineAbstract):
5252
calc_let: bool
5353
calc_bio_dose: bool
5454
air_offset_correction: bool
55-
lateral_model: Literal["auto", "single", "double", "multi", "fastest"]
55+
lateral_model: Literal["auto", "single", "double", "multi", "fastest", "singleXY"]
5656
cut_off_method: Literal["integral", "relative"]
5757

5858
def __init__(self, pln):
@@ -81,6 +81,7 @@ def _choose_lateral_model(self):
8181
"single": self._machine.has_single_gaussian_kernel,
8282
"double": self._machine.has_double_gaussian_kernel,
8383
"multi": self._machine.has_multi_gaussian_kernel,
84+
"singleXY": self._machine.has_focused_gaussian_kernel,
8485
}
8586

8687
# First we validate the users choice
@@ -204,15 +205,16 @@ def _init_bixel(self, curr_ray, k):
204205
# indices to be used) after cutoff. Storing these allows us to
205206
# use indexing for performance and avoid too many copies
206207
self._get_bixel_indices_on_ray(bixel, curr_ray)
207-
if "ix" not in bixel or bixel["ix"].size == 0:
208-
return bixel
209208

210209
# Get quantities 1:1 from ray. Here we trust Python's memory
211210
# management to not copy the arrays until they are modified.
212211
# This allows us to efficiently access them by indexing in the
213212
# bixel computation
214213
bixel["radial_dist_sq"] = curr_ray["radial_dist_sq"][bixel["sub_ix"]]
215214
bixel["rad_depths"] = curr_ray["rad_depths"][bixel["sub_ix"]]
215+
# Propagate lateral distances if available (needed for singleXY focused model)
216+
if "lat_dists" in curr_ray:
217+
bixel["lat_dists"] = curr_ray["lat_dists"][bixel["sub_ix"]]
216218
# TODO:
217219
# if self.calc_bio_dose:
218220
# bixel["v_tissue_index"] = curr_ray["v_tissue_index"][bixel["sub_ix"]]
@@ -238,6 +240,9 @@ def _interpolate_kernels_in_depth(self, bixel):
238240
# Lateral Kernel Model
239241
if self.lateral_model == "single":
240242
used_kernels["sigma"] = kernel.sigma
243+
elif self.lateral_model == "singleXY":
244+
used_kernels["sigma_x"] = kernel.sigma_x
245+
used_kernels["sigma_y"] = kernel.sigma_y
241246
elif self.lateral_model == "double":
242247
used_kernels["sigma_1"] = kernel.sigma_1
243248
used_kernels["sigma_2"] = kernel.sigma_2
@@ -276,7 +281,7 @@ def _get_ray_geometry_from_beam(self, ray: dict[str], beam_info: dict[str]):
276281
lateral_ray_cutoff = self._get_lateral_distance_from_dose_cutoff_on_ray(ray)
277282

278283
# Ray tracing for beam i and ray j
279-
ix, radial_dist_sq, _, _ = self.calc_geo_dists(
284+
ix, radial_dist_sq, lat_dists, _ = self.calc_geo_dists(
280285
beam_info["bev_coords"],
281286
ray["source_point_bev"],
282287
ray["target_point_bev"],
@@ -292,6 +297,8 @@ def _get_ray_geometry_from_beam(self, ray: dict[str], beam_info: dict[str]):
292297
ray["radial_dist_sq"] = [
293298
radial_dist_sq[beam_ix[ix]] for beam_ix in beam_info["valid_coords"]
294299
]
300+
if lat_dists is not None:
301+
ray["lat_dists"] = [lat_dists[beam_ix[ix]] for beam_ix in beam_info["valid_coords"]]
295302

296303
ray["valid_coords_all"] = np.any(np.vstack(ray["valid_coords"]), axis=1)
297304

@@ -590,6 +597,39 @@ def _calc_lateral_particle_cut_off(self, cut_off_level, stf_element):
590597
dr = np.diff(v_x)
591598
radial_dist_sq = r_mid**2
592599

600+
# Handle different lateral models for integration
601+
if self.lateral_model == "singleXY":
602+
v_theta = np.linspace(0, 2 * np.pi, 361)
603+
v_theta = v_theta[:-1] # remove last to avoid duplication at 2*pi
604+
# angular bin size
605+
d_theta_angle = (v_theta[1] - v_theta[0]) * np.ones_like(v_theta)
606+
607+
# Store original radial mid vector length before expansion
608+
n_radial_bins = r_mid.size # equals numel(vR)-1 in MATLAB
609+
n_angles = v_theta.size
610+
611+
r_mid_mesh, theta_mesh = np.meshgrid(r_mid, v_theta)
612+
613+
# Flatten r_mid_mesh
614+
r_mid = r_mid_mesh.ravel(order="F")
615+
radial_dist_sq = r_mid**2
616+
617+
# Expand dr /repeat
618+
dr = np.repeat(dr, n_angles)
619+
620+
# Flatten theta
621+
theta = theta_mesh.ravel(order="F")
622+
623+
# Expand d_theta: replicate angular step sequence for each radial bin
624+
d_theta = np.tile(d_theta_angle, n_radial_bins)
625+
626+
# Lateral distances per sample point (vector of radii times unit vectors)
627+
sqrt_r2 = np.sqrt(radial_dist_sq)
628+
lat_dists = sqrt_r2[:, np.newaxis] * np.column_stack((np.cos(theta), np.sin(theta)))
629+
else:
630+
lat_dists = np.tile(np.sqrt(radial_dist_sq / 2)[:, np.newaxis], (1, 2))
631+
d_theta = 2 * np.pi
632+
593633
# Number of depth points for which a lateral cutoff is determined
594634
num_depth_val = 35
595635

@@ -753,6 +793,7 @@ def _calc_lateral_particle_cut_off(self, cut_off_level, stf_element):
753793
"energy_ix": energy_ix,
754794
"kernel": base_kernel,
755795
"radial_dist_sq": radial_dist_sq,
796+
"lat_dists": lat_dists,
756797
"sigma_ini_sq": largest_sigma_sq4unique_energies[
757798
cnt - 1
758799
], # TODO: check if this result is correct (rounded but may be right)
@@ -770,9 +811,8 @@ def _calc_lateral_particle_cut_off(self, cut_off_level, stf_element):
770811
self._calc_particle_bixel(bixel)
771812
dose_r = bixel["physical_dose"]
772813

773-
# Do an integration check that the radial dose integrates to the tabulated
774-
# integrated depth dose
775-
cum_area = np.cumsum(2 * np.pi * r_mid * dose_r * dr)
814+
cum_area = np.cumsum(r_mid * dose_r * dr * d_theta)
815+
776816
relative_tolerance = 0.5 # in [%]
777817

778818
if abs((cum_area[-1] / idd[j]) - 1) * 100 > relative_tolerance:

pyRadPlan/dose/engines/_hongpb.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class ParticleHongPencilBeamEngine(ParticlePencilBeamEngineAbstract):
88
# constants
99
short_name = "HongPB"
1010
name = "Hong Particle Pencil-Beam"
11-
possible_radiation_modes = ["protons", "helium", "carbon"]
11+
possible_radiation_modes = ["protons", "helium", "carbon", "VHEE"]
1212

1313
# private methods
1414
def _calc_particle_bixel(self, bixel):
@@ -46,6 +46,21 @@ def _calc_particle_bixel(self, bixel):
4646
),
4747
axis=1,
4848
)
49+
elif self.lateral_model == "singleXY":
50+
# Extract squared distances in the two lateral axes
51+
x_sq = bixel["lat_dists"][:, 0] ** 2
52+
y_sq = bixel["lat_dists"][:, 1] ** 2
53+
54+
sigma_sq_x = kernels["sigma_x"] ** 2 + bixel["sigma_ini_sq"]
55+
sigma_sq_y = kernels["sigma_y"] ** 2 + bixel["sigma_ini_sq"]
56+
sigma_x = np.sqrt(sigma_sq_x)
57+
sigma_y = np.sqrt(sigma_sq_y)
58+
59+
# Anisotropic 2D Gaussian
60+
lateral = np.exp(-(x_sq / (2 * sigma_sq_x) + y_sq / (2 * sigma_sq_y))) / (
61+
2 * np.pi * sigma_x * sigma_y
62+
)
63+
4964
else:
5065
raise ValueError("Invalid Lateral Model")
5166

0 commit comments

Comments
 (0)