Skip to content

Commit 0dad242

Browse files
authored
Merge pull request #186 from bwheelz36/add_fluence
implemented two new plotting methods.
2 parents 9f66b9b + 0c79fbf commit 0dad242

File tree

5 files changed

+632
-3
lines changed

5 files changed

+632
-3
lines changed

.github/workflows/build_docs.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88

99
jobs:
1010
deploy:
11-
runs-on: ubuntu-20.04
11+
runs-on: ubuntu-latest
1212
permissions:
1313
contents: write
1414
concurrency:
@@ -33,7 +33,7 @@ jobs:
3333
poetry run make github
3434
- name: Deploy
3535
uses: peaceiris/actions-gh-pages@v3
36-
if: ${{ github.ref == 'refs/heads/master' }}
36+
if: ${{ github.ref == 'refs/heads/main' }}
3737
with:
3838
github_token: ${{ secrets.GITHUB_TOKEN }}
3939
publish_dir: ./docs

ParticlePhaseSpace/DataLoaders.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ def _import_data(self):
437437
self.data[self._columns['particle type']] = particle_types_pdg
438438
self.data[self._columns['particle id']] = np.arange(
439439
len(self.data)) # may want to replace with track ID if available?
440-
self.data[self._columns['time']] = pd.Series(0 * np.ones(self.data.shape[0]), dtype="category") # may want to replace with time feature if available?
440+
self.data[self._columns['time']] = pd.Series(0 * np.ones(self.data.shape[0]))
441441
# figure out the momentums:
442442
DirCosineX = data['Cosine X']
443443
DirCosineY = data['Cosine Y']

ParticlePhaseSpace/_ParticlePhaseSpace.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

544731
class _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

docsrc/examples.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Examples
99
/new_data_loader
1010
/IAEA
1111
/new_data_exporter
12+
/plotting
1213
/transform
1314
/units
1415
/grid_merge

docsrc/plotting.ipynb

Lines changed: 388 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)