@@ -540,6 +540,193 @@ def n_particles_v_time(self, n_bins: int = 100, grid: bool = False): # pragma:
540540 plt .grid ()
541541 plt .tight_layout ()
542542
543+ def radial_energy_boxplot (self , n_bins = 5 , beam_direction = 'z' , rlim = None ):
544+ """
545+ Plots boxplots of kinetic energy distribution per radial bin
546+
547+ :param n_bins: Number of radial bins (boxplots)
548+ :type n_bins: int
549+ :param beam_direction: Direction of beam ('z', 'x', or 'y'); default 'z'
550+ :type beam_direction: str
551+ :param rlim: Tuple (r_min, r_max); if None, uses full range
552+ :type rlim: list or None
553+ """
554+ box_alpha = 0.7
555+ box_color = 'dodgerblue'
556+
557+ ps_data = self ._PS ._ps_data
558+
559+ # Compute radial coordinate
560+ if beam_direction == 'z' :
561+ x = ps_data [self ._PS .columns ['x' ]]
562+ y = ps_data [self ._PS .columns ['y' ]]
563+ r = np .sqrt (x ** 2 + y ** 2 )
564+ elif beam_direction == 'x' :
565+ y = ps_data [self ._PS .columns ['y' ]]
566+ z = ps_data [self ._PS .columns ['z' ]]
567+ r = np .sqrt (y ** 2 + z ** 2 )
568+ elif beam_direction == 'y' :
569+ x = ps_data [self ._PS .columns ['x' ]]
570+ z = ps_data [self ._PS .columns ['z' ]]
571+ r = np .sqrt (x ** 2 + z ** 2 )
572+ else :
573+ raise ValueError ("beam_direction must be 'x', 'y', or 'z'" )
574+
575+ # Set r limits
576+ if rlim is None :
577+ r_min , r_max = r .min (), r .max ()
578+ else :
579+ r_min , r_max = rlim
580+
581+ # Bin edges and bin centers
582+ r_edges = np .linspace (r_min , r_max , n_bins + 1 )
583+ r_centers = 0.5 * (r_edges [:- 1 ] + r_edges [1 :])
584+
585+ # Get kinetic energy
586+ if not self ._PS .columns ['Ek' ] in ps_data .columns :
587+ self ._PS .fill .kinetic_E ()
588+ ps_data = self ._PS ._ps_data
589+ ek = ps_data [self ._PS .columns ['Ek' ]]
590+
591+ # Collect energy per bin
592+ energy_bins = []
593+ for i in range (n_bins ):
594+ in_bin = (r >= r_edges [i ]) & (r < r_edges [i + 1 ])
595+ energy_bins .append (ek [in_bin ])
596+
597+ # Prepare boxplot data (remove empty bins)
598+ boxplot_data = [eb .values for eb in energy_bins if len (eb ) > 0 ]
599+ r_centers_plot = [r_centers [i ] for i , eb in enumerate (energy_bins ) if len (eb ) > 0 ]
600+
601+ fig , ax = plt .subplots (figsize = (10 , 6 ))
602+ bp = ax .boxplot (
603+ boxplot_data ,
604+ positions = r_centers_plot ,
605+ widths = (r_edges [1 ] - r_edges [0 ]) * 0.7 ,
606+ patch_artist = True ,
607+ showfliers = False , # Hide outliers for cleaner look
608+ boxprops = dict (facecolor = box_color , color = 'navy' , alpha = box_alpha , linewidth = 1.5 ),
609+ whiskerprops = dict (color = 'navy' , linewidth = 1.5 ),
610+ capprops = dict (color = 'navy' , linewidth = 1.5 ),
611+ medianprops = dict (color = 'orange' , linewidth = 2 )
612+ )
613+
614+ ax .set_xlabel (f"r [{ self ._PS ._units .length .label } ]" , fontsize = 14 )
615+ ax .set_ylabel (f"Kinetic Energy [{ self ._PS ._units .energy .label } ]" , fontsize = 14 )
616+ ax .set_title ("Energy Distribution In Radial Bins" , fontsize = 16 )
617+ ax .set_xlim (r_min , r_max )
618+ ax .grid (True , linestyle = '--' , alpha = 0.5 )
619+ plt .tight_layout ()
620+ plt .show ()
621+
622+ def fluence_map_2D (self , beam_direction : str = 'z' , at : float = 0.0 , quantity : str = 'energy' ,
623+ bins : int = 100 , normalize : bool = False ,
624+ xlim = None , ylim = None ):
625+ """
626+ Plot fluence (particle or energy per unit area) as a 2D map in the plane orthogonal to beam_direction.
627+
628+ The phase space data is firt projected to the "at" coordinate. any data which would never make it there is discarded.
629+
630+ :param beam_direction: Direction of beam travel ('x', 'y', or 'z')
631+ :type beam_direction: str, optional
632+ :param at: Position of plane along beam direction
633+ :type at: float, optional
634+ :param quantity: 'particle' for particle fluence, 'energy' for energy fluence
635+ :type quantity: str, optional
636+ :param bins: Number of bins per axis
637+ :type bins: int, optional
638+ :param normalize: If True, normalize fluence map to max value
639+ :type normalize: bool, optional
640+ :param xlim: x-axis limits (tuple or list)
641+ :type xlim: tuple or list, optional
642+ :param ylim: y-axis limits (tuple or list)
643+ :type ylim: tuple or list, optional
644+ :return: fluence_map (2D numpy array), (xedges, yedges)
645+ """
646+
647+ # Project all particles to the plane
648+ plot_ps = self ._PS .transform .project_to_plane (beam_direction = beam_direction , at = at )
649+ ps_data = plot_ps ._ps_data
650+ # filter any particles not at the requested plane (travelling in wrong direction)
651+ if beam_direction == 'x' :
652+ coord = ps_data [plot_ps .columns ['x' ]]
653+ elif beam_direction == 'y' :
654+ coord = ps_data [plot_ps .columns ['y' ]]
655+ elif beam_direction == 'z' :
656+ coord = ps_data [plot_ps .columns ['z' ]]
657+ else :
658+ raise ValueError (f"beam direction: { beam_direction } is not supported." )
659+
660+ tol = 1e-6
661+ mask = np .abs (coord - at ) < tol
662+ # Filter for particles actually "on" the plane
663+ ps_data = ps_data [mask ]
664+ columns = plot_ps .columns
665+ units = plot_ps ._units
666+
667+ # Determine orthogonal axes
668+ if beam_direction == 'x' :
669+ axis1 , axis2 = columns ['y' ], columns ['z' ]
670+ elif beam_direction == 'y' :
671+ axis1 , axis2 = columns ['x' ], columns ['z' ]
672+ elif beam_direction == 'z' :
673+ axis1 , axis2 = columns ['x' ], columns ['y' ]
674+ else :
675+ raise ValueError ("beam_direction must be one of 'x', 'y', or 'z'" )
676+
677+ x = ps_data [axis1 ]
678+ y = ps_data [axis2 ]
679+
680+ # Set limits if not specified
681+ if xlim is None :
682+ # Default to percentiles to avoid extreme outliers
683+ xlim = (np .percentile (x , 1 ), np .percentile (x , 99 ))
684+ if ylim is None :
685+ ylim = (np .percentile (y , 1 ), np .percentile (y , 99 ))
686+
687+ xedges = np .linspace (xlim [0 ], xlim [1 ], bins + 1 )
688+ yedges = np .linspace (ylim [0 ], ylim [1 ], bins + 1 )
689+
690+ # Mask out particles outside bounds to avoid weird bins
691+ mask = (x >= xlim [0 ]) & (x <= xlim [1 ]) & (y >= ylim [0 ]) & (y <= ylim [1 ])
692+ x = x [mask ]
693+ y = y [mask ]
694+ weights = ps_data ['weight' ][mask ]
695+
696+ if quantity == 'energy' :
697+ plot_ps .fill .kinetic_E () # Fill on projected phase space
698+ ps_data = plot_ps ._ps_data
699+ if isinstance (weights .dtype , pd .CategoricalDtype ):
700+ weights = weights .astype (float )
701+ weights = weights * ps_data [columns ['Ek' ]][mask ]
702+ elif quantity != 'particle' :
703+ raise ValueError ("quantity must be 'particle' or 'energy'" )
704+
705+ H , _ , _ = np .histogram2d (x , y , bins = [xedges , yedges ], weights = weights )
706+ bin_area = (xedges [1 ] - xedges [0 ]) * (yedges [1 ] - yedges [0 ])
707+ fluence_map = H / bin_area
708+
709+ if normalize and fluence_map .max () > 0 :
710+ fluence_map = fluence_map / fluence_map .max ()
711+
712+ plt .figure (figsize = (8 , 6 ))
713+ plt .imshow (fluence_map .T , origin = 'lower' , aspect = 'auto' ,
714+ extent = [xedges [0 ], xedges [- 1 ], yedges [0 ], yedges [- 1 ]],
715+ cmap = 'inferno' )
716+ if quantity == "energy" :
717+ plt .colorbar (label = f"Energy fluence [{ units .energy .label } /{ units .length .label } $^2$]" )
718+ else :
719+ plt .colorbar (label = f"Particle fluence [particles/{ units .length .label } $^2$]" )
720+ plt .xlabel (axis1 )
721+ plt .ylabel (axis2 )
722+ plt .title (
723+ f"{ quantity .capitalize ()} fluence map in plane orthogonal to { beam_direction } @ { at } " )
724+ plt .xlim (xlim )
725+ plt .ylim (ylim )
726+ plt .tight_layout ()
727+ plt .show ()
728+
729+
543730
544731class _Transform (_PhaseSpace_MethodHolder ):
545732 """
@@ -687,6 +874,59 @@ def project(self, direction: str = 'z', distance: float = 100, in_place: bool =
687874 new_instance = self ._return_position_update (new_x , new_y , new_z , in_place )
688875 return new_instance # nb: None if in_place = True
689876
877+ def project_to_plane (self , beam_direction : str = 'z' , at : float = 0.0 , in_place : bool = False ):
878+ """
879+ Projects all particles to the specified plane (e.g., z=at, y=at, x=at) along their trajectories.
880+ Assumes straight-line motion.
881+ If a particle would NEVER reach the requested plane, it will keep it's oroginal positions.
882+
883+ :param beam_direction: Direction normal to the plane ('x', 'y', or 'z')
884+ :type beam_direction: str
885+ :param at: Position of plane along beam_direction
886+ :type at: float
887+ :param in_place: If True, modify in place. If False, return new PhaseSpace
888+ :type in_place: bool
889+ :return: new PhaseSpace object with all particles on the plane
890+ """
891+ # Get required columns
892+ col = self ._PS .columns
893+ psd = self ._PS ._ps_data
894+
895+ for axis in [col ['x' ], col ['y' ], col ['z' ], col ['px' ], col ['py' ], col ['pz' ]]:
896+ if isinstance (psd [axis ].dtype , pd .CategoricalDtype ):
897+ psd [axis ] = psd [axis ].cat .codes .astype (float )
898+
899+ # Calculate displacement needed to bring each particle to the plane
900+ if beam_direction == 'x' :
901+ d = at - psd [col ['x' ]]
902+ # Avoid divide by zero
903+ px = psd [col ['px' ]].replace (0 , np .nan )
904+ new_y = psd [col ['y' ]] + d * psd [col ['py' ]] / px
905+ new_z = psd [col ['z' ]] + d * psd [col ['pz' ]] / px
906+ new_x = np .full_like (psd [col ['x' ]], at )
907+ elif beam_direction == 'y' :
908+ d = at - psd [col ['y' ]]
909+ py = psd [col ['py' ]].replace (0 , np .nan )
910+ new_x = psd [col ['x' ]] + d * psd [col ['px' ]] / py
911+ new_z = psd [col ['z' ]] + d * psd [col ['pz' ]] / py
912+ new_y = np .full_like (psd [col ['y' ]], at )
913+ elif beam_direction == 'z' :
914+ d = at - psd [col ['z' ]] # projection amount
915+ pz = psd [col ['pz' ]].replace (0 , np .nan )
916+ new_x = psd [col ['x' ]] + d * psd [col ['px' ]] / pz
917+ new_y = psd [col ['y' ]] + d * psd [col ['py' ]] / pz
918+ new_z = np .full_like (psd [col ['z' ]], at )
919+ else :
920+ raise ValueError ("beam_direction must be one of 'x', 'y', or 'z'" )
921+
922+ # Replace any nan (from divide by zero) with current position
923+ new_x = np .where (np .isnan (new_x ), psd [col ['x' ]], new_x )
924+ new_y = np .where (np .isnan (new_y ), psd [col ['y' ]], new_y )
925+ new_z = np .where (np .isnan (new_z ), psd [col ['z' ]], new_z )
926+
927+ # Use existing _return_position_update for in_place or return new
928+ return self ._return_position_update (new_x , new_y , new_z , in_place ) # nb: None if in_place = True
929+
690930 def regrid (self , quantities : (list , None ) = None , n_bins : (int , list ) = 10 , in_place = False ):
691931 """
692932 this re-grids each quantity in quantities onto a new grid. The new grid is defined by
0 commit comments