33from matplotlib import pyplot as plt
44from scipy .ndimage import rotate
55import click
6+ import copy
67
78class Visualization :
89 """
@@ -90,24 +91,26 @@ def plot_comparaison_analytical(self, Bz_analytical, simulated_Bz, geometry_type
9091 axes [0 ].plot (np .linspace (- dimensions [0 ]// 2 , dimensions [0 ]// 2 , dimensions [0 ]), simulated_Bz [:, dimensions [0 ]// 2 , dimensions [0 ]// 2 ],'--' , label = 'Simulated' )
9192 axes [0 ].set_xlabel ('x position [mm]' )
9293 axes [0 ].set_ylabel ('Field variation [ppm]' )
94+
9395 axes [0 ].set_ylim (vmin , vmax )
9496 axes [0 ].legend ()
9597
96- axes [1 ].plot (np .linspace (- dimensions [0 ]// 2 , dimensions [0 ]// 2 , dimensions [0 ]), Bz_analytical [dimensions [0 ]// 2 , :, dimensions [0 ]// 2 ], label = 'Theory' )
97- axes [1 ].plot (np .linspace (- dimensions [0 ]// 2 , dimensions [0 ]// 2 , dimensions [0 ]), simulated_Bz [dimensions [0 ]// 2 , :, dimensions [0 ]// 2 ],'--' , label = 'Simulated' )
98+ axes [1 ].plot (np .linspace (- dimensions [1 ]// 2 , dimensions [1 ]// 2 , dimensions [1 ]), Bz_analytical [dimensions [0 ]// 2 , :, dimensions [2 ]// 2 ], label = 'Theory' )
99+ axes [1 ].plot (np .linspace (- dimensions [1 ]// 2 , dimensions [1 ]// 2 , dimensions [1 ]), simulated_Bz [dimensions [0 ]// 2 , :, dimensions [2 ]// 2 ],'--' , label = 'Simulated' )
98100 axes [1 ].set_xlabel ('y position [mm]' )
99101 axes [1 ].set_ylabel ('Field variation [ppm]' )
100102 axes [1 ].set_ylim (vmin , vmax )
101103 axes [1 ].legend ()
102104
103- axes [2 ].plot (np .linspace (- dimensions [0 ]// 2 , dimensions [0 ]// 2 , dimensions [0 ]), Bz_analytical [dimensions [0 ]// 2 , dimensions [0 ]// 2 , :], label = 'Theory' )
104- axes [2 ].plot (np .linspace (- dimensions [0 ]// 2 , dimensions [0 ]// 2 , dimensions [0 ]), simulated_Bz [dimensions [0 ]// 2 , dimensions [0 ]// 2 , :],'--' , label = 'Simulated' )
105+ axes [2 ].plot (np .linspace (- dimensions [2 ]// 2 , dimensions [2 ]// 2 , dimensions [2 ]), Bz_analytical [dimensions [0 ]// 2 , dimensions [1 ]// 2 , :], label = 'Theory' )
106+ axes [2 ].plot (np .linspace (- dimensions [2 ]// 2 , dimensions [2 ]// 2 , dimensions [2 ]), simulated_Bz [dimensions [0 ]// 2 , dimensions [1 ]// 2 , :],'--' , label = 'Simulated' )
105107 axes [2 ].set_xlabel ('z position [mm]' )
106108 axes [2 ].set_ylabel ('Field variation [ppm]' )
107109 axes [2 ].set_ylim (vmin , vmax )
108110 axes [2 ].legend ()
109111
110112 plt .tight_layout ()
113+ plt .savefig (f'{ geometry_type } _analytical_vs_simulated.png' , dpi = 300 )
111114 plt .show ()
112115
113116class Spherical (Visualization ):
@@ -142,7 +145,7 @@ def mask(self):
142145 """
143146 [x , y , z ] = np .meshgrid (np .linspace (- (self .matrix [0 ]- 1 )/ 2 , (self .matrix [0 ]- 1 )/ 2 , self .matrix [0 ]),
144147 np .linspace (- (self .matrix [1 ]- 1 )/ 2 , (self .matrix [1 ]- 1 )/ 2 , self .matrix [1 ]),
145- np .linspace (- (self .matrix [2 ]- 1 )/ 2 , (self .matrix [2 ]- 1 )/ 2 , self .matrix [2 ]))
148+ np .linspace (- (self .matrix [2 ]- 1 )/ 2 , (self .matrix [2 ]- 1 )/ 2 , self .matrix [2 ]), indexing = 'ij' )
146149
147150 r = np .sqrt (x ** 2 + y ** 2 + z ** 2 )
148151
@@ -168,7 +171,7 @@ def analytical_sol(self):
168171
169172 [x , y , z ] = np .meshgrid (np .linspace (- (self .matrix [0 ]- 1 )/ 2 , (self .matrix [0 ]- 1 )/ 2 , self .matrix [0 ]),
170173 np .linspace (- (self .matrix [1 ]- 1 )/ 2 , (self .matrix [1 ]- 1 )/ 2 , self .matrix [1 ]),
171- np .linspace (- (self .matrix [2 ]- 1 )/ 2 , (self .matrix [2 ]- 1 )/ 2 , self .matrix [2 ]))
174+ np .linspace (- (self .matrix [2 ]- 1 )/ 2 , (self .matrix [2 ]- 1 )/ 2 , self .matrix [2 ]), indexing = 'ij' )
172175
173176
174177 r = np .sqrt (x ** 2 + y ** 2 + z ** 2 )
@@ -213,9 +216,9 @@ def mask(self):
213216 """
214217 [x , y , z ] = np .meshgrid (np .linspace (- (self .matrix [0 ]- 1 )/ 2 , (self .matrix [0 ]- 1 )/ 2 , self .matrix [0 ]),
215218 np .linspace (- (self .matrix [1 ]- 1 )/ 2 , (self .matrix [1 ]- 1 )/ 2 , self .matrix [1 ]),
216- np .linspace (- (self .matrix [2 ]- 1 )/ 2 , (self .matrix [2 ]- 1 )/ 2 , self .matrix [2 ]))
219+ np .linspace (- (self .matrix [2 ]- 1 )/ 2 , (self .matrix [2 ]- 1 )/ 2 , self .matrix [2 ]), indexing = 'ij' )
217220
218- r = x ** 2 + y ** 2
221+ r = x ** 2 + y ** 2
219222
220223 mask = r <= self .R ** 2
221224
@@ -247,7 +250,7 @@ def analytical_sol(self):
247250
248251 [x , y , z ] = np .meshgrid (np .linspace (- (self .matrix [0 ]- 1 )/ 2 , (self .matrix [0 ]- 1 )/ 2 , self .matrix [0 ]),
249252 np .linspace (- (self .matrix [1 ]- 1 )/ 2 , (self .matrix [1 ]- 1 )/ 2 , self .matrix [1 ]),
250- np .linspace (- (self .matrix [2 ]- 1 )/ 2 , (self .matrix [2 ]- 1 )/ 2 , self .matrix [2 ]))
253+ np .linspace (- (self .matrix [2 ]- 1 )/ 2 , (self .matrix [2 ]- 1 )/ 2 , self .matrix [2 ]), indexing = 'ij' )
251254
252255 r = np .sqrt (x ** 2 + y ** 2 + z ** 2 )
253256
@@ -273,19 +276,52 @@ def analytical_sol(self):
273276
274277 return Bz_analytical
275278
279+ def analytical_sphere_external_field (dx , dy , dz , chi_internal , chi_external , radius ):
280+ """
281+ Calculate analytical external field for a sphere at relative position (dx, dy, dz).
282+
283+ Formula: Bz/B0 = 1/3 * (chi_i - chi_e) * a^3/r^3 * (3*cos^2(theta) - 1) + 1/3 * chi_e
284+
285+ Args:
286+ dx, dy, dz: Position relative to sphere center (in voxels)
287+ chi_internal: Susceptibility inside sphere (ppm)
288+ chi_external: Susceptibility outside sphere (ppm)
289+ radius: Sphere radius (in voxels)
290+
291+ Returns:
292+ Field value at position (dx, dy, dz) in ppm
293+ """
294+ r = np .sqrt (dx ** 2 + dy ** 2 + dz ** 2 )
295+
296+ if r == 0 :
297+ # At center
298+ return chi_external / 3.0
299+
300+ # cos(theta) where theta is angle from z-axis
301+ cos_theta = dz / r
302+
303+ # External field formula
304+ field = (1.0 / 3.0 ) * (chi_internal - chi_external ) * (radius ** 3 / r ** 3 ) * (3 * cos_theta ** 2 - 1 ) + chi_external / 3.0
305+
306+ return field
307+
276308@click .command (help = "Compare the analytical solution to the simulated solution for a spherical or cylindrical geometry." )
277309@click .option ('-t' , '--geometry-type' ,required = True ,
278310 type = click .Choice (['spherical' , 'cylindrical' ]),
279311 help = 'Type of geometry for the simulation' )
280- @click .option ('-b' , '--buffer' , default = 2 ,
312+ @click .option ('-b' , '--buffer' , default = 50 ,
281313 help = 'Buffer value for zero-padding.' )
282- def compare_to_analytical (geometry_type , buffer ):
314+ def compare_to_analytical (geometry_type , buffer , matrix = [ 128 , 128 , 128 ], image_res = [ 1 , 1 , 1 ], radius = 15 , chi = 9 ):
283315 """
284316 Main function to compare simulated fields to analytical solutions.
285317
286318 Parameters:
287319 - geometry_type (str): The type of geometry to simulate ('spherical' or 'cylindrical').
288320 - buffer (float): The buffer size for the simulation.
321+ - matrix ([int, int, int]): Volume dimensions in terms of number of pixels.
322+ - image_res ([float, float, float]): Image resolution in mm ([x,y,z]).
323+ - radius (float): Radius (mm) of the geometrical object.
324+ - chi (float): Susceptibility difference in ppm.
289325
290326 Returns:
291327 - None
@@ -301,10 +337,35 @@ def compare_to_analytical(geometry_type, buffer):
301337 and a susceptibility difference of 9 ppm for the spherical and cylindrical geometries.
302338 """
303339
304- matrix = np .array ([128 ,128 ,128 ])
305- image_res = np .array ([1 ,1 ,1 ]) # mm
306- R = 15 # mm
307- sus_diff = 9 # ppm
340+ compare_to_analytical_internal (geometry_type , buffer , matrix , image_res , radius , chi )
341+
342+
343+ def compare_to_analytical_internal (geometry_type , buffer , matrix = [128 ,128 ,128 ], image_res = [1 ,1 ,1 ], radius = 15 , chi = 9 ):
344+
345+ # Type check the matrix argument
346+ if not isinstance (matrix , (list , tuple , np .ndarray )):
347+ raise TypeError ("matrix must be a list, tuple, or numpy array." )
348+ if len (matrix ) != 3 :
349+ raise ValueError ("matrix must have 3 elements (x, y, z dimensions)." )
350+ if not all (isinstance (dim , int ) for dim in matrix ):
351+ raise TypeError ("All elements of matrix must be integers." )
352+ if not all (dim > 0 for dim in matrix ):
353+ raise ValueError ("All matrix dimensions must be positive." )
354+ matrix = np .array (matrix ) # Convert to numpy array for easier use later
355+
356+ # Type check the image_res argument
357+ if not isinstance (image_res , (list , tuple , np .ndarray )):
358+ raise TypeError ("image_res must be a list, tuple, or numpy array." )
359+ if len (image_res ) != 3 :
360+ raise ValueError ("image_res must have 3 elements (x, y, z resolutions)." )
361+ if not all (isinstance (res , (int , float )) for res in image_res ):
362+ raise TypeError ("All elements of image_res must be numbers (int or float)." )
363+ if not all (res > 0 for res in image_res ):
364+ raise ValueError ("All image_res values must be positive." )
365+ image_res = np .array (image_res ) # Convert to numpy array
366+
367+ R = radius # mm
368+ sus_diff = chi # ppm
308369
309370 dicto = {'spherical' : Spherical (matrix , image_res , R , sus_diff ),
310371 'cylindrical' : Cylindrical (matrix , image_res , R , sus_diff )}
@@ -315,9 +376,12 @@ def compare_to_analytical(geometry_type, buffer):
315376
316377 # compute Bz variation
317378 calculated_Bz = compute_bz (sus_dist , image_res , buffer )
379+
318380 # analytical solution
319381 Bz_analytical = geometry .analytical_sol ()
320382
321383 # plot the results
322384 geometry .plot_susceptibility_and_fieldmap (sus_dist , calculated_Bz , geometry_type )
323385 geometry .plot_comparaison_analytical (Bz_analytical , calculated_Bz , geometry_type )
386+
387+ return calculated_Bz , Bz_analytical
0 commit comments