Skip to content

Commit 565d661

Browse files
authored
Merge pull request ComputationalRadiationPhysics#5378 from chillenzer/free-formula-density-decluttered
Free formula density for PICMI
2 parents 2ad7819 + f637d37 commit 565d661

File tree

31 files changed

+1125
-184
lines changed

31 files changed

+1125
-184
lines changed

.gitlab-ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
include:
2-
- local: '/share/ci/pypicongpu.yml'
2+
- local: "/share/ci/pypicongpu.yml"
33

44
stages:
55
- validate
@@ -179,7 +179,7 @@ pypicongpu-generate-full-matrix:
179179
paths:
180180
- compile.yml
181181
tags:
182-
- x86_64
182+
- x86_64
183183
interruptible: true
184184

185185
pypicongpu-full-matrix:

lib/python/picongpu/picmi/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@
1212

1313
from . import diagnostics
1414

15-
from .distribution import FoilDistribution, UniformDistribution, GaussianDistribution, CylindricalDistribution
15+
from .distribution import (
16+
FoilDistribution,
17+
UniformDistribution,
18+
GaussianDistribution,
19+
CylindricalDistribution,
20+
AnalyticDistribution,
21+
)
22+
1623
from .interaction import Interaction
1724
from .interaction.ionization.fieldionization import (
1825
ADK,
@@ -41,6 +48,7 @@
4148
"FoilDistribution",
4249
"UniformDistribution",
4350
"GaussianDistribution",
51+
"AnalyticDistribution",
4452
"ADK",
4553
"ADKVariant",
4654
"BSI",
Lines changed: 131 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
"""
22
This file is part of PIConGPU.
3-
Copyright 2021-2024 PIConGPU contributors
4-
Authors: Hannes Troepgen, Brian Edward Marre
3+
Copyright 2021-2025 PIConGPU contributors
4+
Authors: Hannes Troepgen, Brian Edward Marre, Julian Lenz
55
License: GPLv3+
66
"""
77

8+
from numpy import vectorize
89
from ...pypicongpu import species
9-
from ...pypicongpu import util
1010

11-
import picmistandard
1211

12+
import logging
1313
import typeguard
1414
import typing
1515
import sympy
16-
import math
16+
import traceback
1717

1818
"""
1919
note on rms_velocity:
@@ -38,36 +38,141 @@
3838

3939

4040
@typeguard.typechecked
41-
class AnalyticDistribution(picmistandard.PICMI_AnalyticDistribution):
42-
"""Analytic Particle Distribution as defined by PICMI @todo"""
41+
class AnalyticDistribution:
42+
"""
43+
This class represents a plasma with a density defined by an analytic expression.
44+
45+
The function must be constructed using sympy functions
46+
to enable code generation and manipulation.
47+
This is a slight deviation from the PICMI standard.
48+
Furthermore, we don't implement substitution of variables
49+
as suggested in the PICMI standard.
50+
Instead we propose that you write your function
51+
with further variables as keyword arguments
52+
and substitute them yourself before handing it over to AnalyticDistribution.
53+
See the end-to-end tests for examples of this.
54+
55+
Writing such functions (or rather writing sympy in general)
56+
comes with a few pitfalls but also advantages as listed below.
57+
Make sure that you familiarise yourself with writing sympy.
58+
59+
Advantages:
60+
- The sympy language is closer to mathematical language than to coding
61+
which might make it more natural to use for some physicists.
62+
- You can extract the exact distribution
63+
that was used from the member `density_expression`
64+
and use it any way you'd use any sympy expression.
65+
In particular, you can print it to various formats,
66+
say, LaTeX for automated inclusion in papers.
67+
- We can easily evaluate it from within python.
68+
This is what the __call__ operator does.
69+
However, code generation here can have some difficulties
70+
with advanced broadcasting for numpy variables.
71+
The operator implements a fallback in such cases.
72+
This fallback might be slightly slower on large inputs.
73+
Try rewriting your function, in case you experience performance problems.
74+
75+
Pitfalls:
76+
- We don't handle vectors yet.
77+
But that's probably not too important for density expressions.
78+
Just be explicit handling multiple vector components for now.
79+
- Some operations might compile to suboptimal code
80+
concerning numerical performance and stability.
81+
Experts might want to inspect the generated C++ code
82+
and/or check the unit tests for the PMAccPrinter
83+
to find the precise mapping of sympy expressions
84+
to PMAcc code.
85+
Please approach us if you should stumble across this.
86+
- Control flow is an interesting topic in this regard.
87+
With respect to your three position coordinates
88+
(which will be sympy symbols internally),
89+
you must use pure sympy, e.g.,
90+
replacing if-conditions with sympy.Piecewise and so on.
91+
With respect to further parameters,
92+
you're free to use any python construct you want
93+
(if-conditions, loops, etc.).
94+
- sympy.Piecewise has the potentially surprising property
95+
that any pieces that you leave undefined are interpreted as nan.
96+
This implies that adding two complementary sympy.Piecewise
97+
renders the whole expression nan and not -- as you might expect --
98+
defined on the union of the defined regions.
99+
There are two options to circumvent this:
100+
Either you can define multiple sympy.Piecewise with
101+
(0.0, True) as the last condition which means 0 everywhere else.
102+
(Make sure it's the last!)
103+
Summing those up, works just as you'd expect.
104+
Alternatively, you can define only the (expression, condition) tuples
105+
and assemble them in a sympy.Piecewise in one go.
106+
That's the way chosen in the end-to-end tests.
107+
108+
Parameters:
109+
density_expression (Callable):
110+
A Python function that takes x, y, z coordinates (in SI units)
111+
and returns the density (in SI units) at that point.
112+
It should use sympy functionality.
113+
directed_velocity (3-tuple of float):
114+
A collective velocity for the particle distribution.
115+
(currently untested)
116+
"""
117+
118+
def __init__(self, density_expression, directed_velocity=(0.0, 0.0, 0.0)):
119+
self.density_function = density_expression
120+
self.rms_velocity = (0.0, 0.0, 0.0)
121+
self.directed_velocity = tuple(float(v) for v in directed_velocity)
122+
x, y, z = sympy.symbols("x,y,z")
123+
self.density_expression = (
124+
# We add the simplify because of the following:
125+
# Translating to C++ requires ALL cases of a Piecewise to be defined
126+
# such that there's a fallback for if-conditions.
127+
# The user might have written their formula piecing together
128+
# multiple partial Piecewise instances.
129+
# Without simplification, sympy tries to translate them individually.
130+
# and fails to do so even if they supplement each other
131+
# into a function defined everywhere.
132+
sympy.simplify(self.density_function(x, y, z))
133+
# density_expression might be independent of any or all of the three variables.
134+
# (This might even happen due to the simplification.)
135+
# In order to be sure to arrive at a function of these three variables,
136+
# we add this trivial additional term.
137+
+ (0 * x * y * z)
138+
)
139+
self.warned_about_lambdify_failure = False
140+
141+
def get_as_pypicongpu(self, grid) -> species.operation.densityprofile.DensityProfile:
142+
x, y, z = sympy.symbols("x,y,z")
143+
return species.operation.densityprofile.FreeFormula(density_expression=self.density_expression)
43144

44145
def picongpu_get_rms_velocity_si(self) -> typing.Tuple[float, float, float]:
45-
return tuple(self.rms_velocity)
46-
47-
def get_as_pypicongpu(self) -> species.operation.densityprofile.DensityProfile:
48-
util.unsupported("momentum expressions", self.momentum_expressions)
49-
util.unsupported("fill in", self.fill_in)
50-
51-
# TODO
52-
profile = object()
53-
profile.lower_bound = tuple(map(lambda x: -math.inf if x is None else x, self.lower_bound))
54-
profile.upper_bound = tuple(map(lambda x: math.inf if x is None else x, self.upper_bound))
55-
56-
# final (more thorough) formula checking will be invoked inside
57-
# pypicongpu on translation to CPP
58-
sympy_density_expression = sympy.sympify(self.density_expression).subs(self.user_defined_kw)
59-
profile.expression = sympy_density_expression
60-
61-
return profile
146+
return self.rms_velocity
62147

63148
def get_picongpu_drift(self) -> typing.Optional[species.operation.momentum.Drift]:
64149
"""
65150
Get drift for pypicongpu
66151
:return: pypicongpu drift object or None
67152
"""
68-
if [0, 0, 0] == self.directed_velocity:
153+
if all(v == 0 for v in self.directed_velocity):
69154
return None
70155

71156
drift = species.operation.momentum.Drift()
72-
drift.fill_from_velocity(tuple(self.directed_velocity))
157+
drift.fill_from_velocity(self.directed_velocity)
73158
return drift
159+
160+
def __call__(self, *args, **kwargs):
161+
try:
162+
# This produces faster code but the code generation is not perfect.
163+
# There are cases where the generated code can't handle broadcasting properly.
164+
return sympy.lambdify(sympy.symbols("x,y,z"), self.density_expression, "numpy")(*args, **kwargs)
165+
except ValueError:
166+
if not self.warned_about_lambdify_failure:
167+
message = (
168+
"Sympy did not manage to produce proper numpy code for your AnalyticDistribution. "
169+
"If you run into performance problems, try to rewrite your function. "
170+
"Here's the original error message:"
171+
)
172+
logging.warning(message)
173+
logging.warning(traceback.format_exc())
174+
logging.warning("Continuing operation using a slower serialised version now.")
175+
self.warned_about_lambdify_failure = True
176+
# This basically calls the original function in a big loop.
177+
# Slower but more reliable in some cases of difficult broadcasting.
178+
return vectorize(self.density_function)(*args, **kwargs)

lib/python/picongpu/picmi/distribution/CylindricalDistribution.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ...pypicongpu import species
99
from ...pypicongpu import util
10+
import numpy as np
1011

1112
from .Distribution import Distribution
1213

@@ -47,12 +48,15 @@ class CylindricalDistribution(Distribution):
4748
exponential_pre_plasma_cutoff: float | None
4849
"""cutoff of the exponential pre-plasma ramp, [m]"""
4950

51+
cell_size: tuple[float, float, float] | None = None
52+
5053
# @details pydantic provides an automatically generated __init__/constructor method which allows initialization off
5154
# all attributes as keyword arguments
5255

5356
# @note user may add additional attributes by hand, these will be available but not type verified
5457

55-
def get_as_pypicongpu(self) -> species.operation.densityprofile.Cylinder:
58+
def get_as_pypicongpu(self, grid) -> species.operation.densityprofile.Cylinder:
59+
self.cell_size = grid.get_cell_size()
5660
util.unsupported("fill in not active", self.fill_in, True)
5761

5862
profile = species.operation.densityprofile.Cylinder()
@@ -99,3 +103,52 @@ def get_as_pypicongpu(self) -> species.operation.densityprofile.Cylinder:
99103
profile.cylinder_axis = self.cylinder_axis
100104

101105
return profile
106+
107+
def __call__(
108+
self,
109+
x,
110+
y,
111+
z,
112+
):
113+
if self.cell_size is None:
114+
message = (
115+
"Due to inconsistencies in the backend, evaluation of this function requires information about the cell_size."
116+
" You can either set it manually "
117+
" or you can perform anything that includes writing the input files on your simulation object."
118+
" This is a temporary workaround and will be fixed in the future."
119+
)
120+
raise NotImplementedError(message)
121+
122+
# The definition of this density uses the origin of the cell
123+
# while the call operator uses the center.
124+
x += -0.5 * self.cell_size[0]
125+
y += -0.5 * self.cell_size[1]
126+
z += -0.5 * self.cell_size[2]
127+
128+
# Just for convenience:
129+
pre_l = self.exponential_pre_plasma_length or 0.0
130+
pre_c = self.exponential_pre_plasma_cutoff or 0.0
131+
132+
# Every vector is expressed as a linear combination of basis vectors.
133+
# This is abstracted away in `_make_vector`.
134+
cylinder_axis = np.array(self.cylinder_axis) / np.linalg.norm(self.cylinder_axis)
135+
args = (x, y, z)
136+
positions = np.moveaxis(np.broadcast_arrays(x, y, z), 0, -1)
137+
r = np.linalg.norm(
138+
np.cross(
139+
positions
140+
- np.reshape(
141+
self.center_position,
142+
((len(np.shape(positions)) - 1) * (1,)) + (3,),
143+
),
144+
cylinder_axis,
145+
),
146+
axis=-1,
147+
)
148+
radius = np.sqrt(self.radius**2 - pre_l**2) - pre_l
149+
result = np.zeros(np.broadcast_shapes(*map(np.shape, args)))
150+
151+
result[r < radius] = 1.0
152+
mask = (r >= radius) * (r < radius + pre_c)
153+
result[mask] = np.exp((radius - r) / pre_l)[mask]
154+
return self.density * result

lib/python/picongpu/picmi/distribution/FoilDistribution.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import typeguard
1414
import typing
15+
import numpy as np
1516

1617
"""
1718
note on rms_velocity:
@@ -40,7 +41,7 @@ class FoilDistribution(picmistandard.PICMI_FoilDistribution):
4041
def picongpu_get_rms_velocity_si(self) -> typing.Tuple[float, float, float]:
4142
return tuple(self.rms_velocity)
4243

43-
def get_as_pypicongpu(self) -> species.operation.densityprofile.DensityProfile:
44+
def get_as_pypicongpu(self, grid) -> species.operation.densityprofile.DensityProfile:
4445
util.unsupported("fill in", self.fill_in)
4546
util.unsupported("lower bound", self.lower_bound, (None, None, None))
4647
util.unsupported("upper bound", self.upper_bound, (None, None, None))
@@ -107,3 +108,29 @@ def get_picongpu_drift(self) -> typing.Optional[species.operation.momentum.Drift
107108
drift = species.operation.momentum.Drift()
108109
drift.fill_from_velocity(tuple(self.directed_velocity))
109110
return drift
111+
112+
def __call__(self, x, y, z):
113+
# We do this to get the correct shape after broadcasting:
114+
result = 0.0 * (x + y + z)
115+
116+
pre_plasma_ramp = (
117+
np.exp((y - self.front) / self.exponential_pre_plasma_length)
118+
if self.exponential_pre_plasma_length is not None
119+
else 0.0
120+
) + result
121+
pre_plasma_mask = (y < self.front) * (y > self.front - self.exponential_pre_plasma_cutoff)
122+
123+
post_plasma_ramp = (
124+
np.exp(((self.front + self.thickness) - y) / self.exponential_post_plasma_length)
125+
if self.exponential_post_plasma_length is not None
126+
else 0.0
127+
) + result
128+
post_plasma_mask = (y > self.front + self.thickness) * (
129+
y < self.front + self.thickness + self.exponential_post_plasma_cutoff
130+
)
131+
132+
result[pre_plasma_mask] = pre_plasma_ramp[pre_plasma_mask]
133+
result[post_plasma_mask] = post_plasma_ramp[post_plasma_mask]
134+
result[(y >= self.front) * (y <= self.front + self.thickness)] = 1.0
135+
136+
return self.density * result

lib/python/picongpu/picmi/distribution/GaussianBunchDistribution.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class GaussianBunchDistribution(picmistandard.PICMI_GaussianBunchDistribution):
4040
def picongpu_get_rms_velocity_si(self) -> typing.Tuple[float, float, float]:
4141
return tuple(self.rms_velocity)
4242

43-
def get_as_pypicongpu(self) -> species.operation.densityprofile.DensityProfile:
43+
def get_as_pypicongpu(self, grid) -> species.operation.densityprofile.DensityProfile:
4444
# @todo respect boundaries, Brian Marre, 2023
4545
profile = object()
4646
profile.lower_bound = (-math.inf, -math.inf, -math.inf)

0 commit comments

Comments
 (0)