OPAL (Object Oriented Parallel Accelerator Library)  2024.1
OPAL
minimal_runner.py
Go to the documentation of this file.
1 # Basic lattice set up defaults - either use as an example or inherit and
2 # overload required methods
3 #
4 # Copyright (c) 2023, Chris Rogers, STFC Rutherford Appleton Laboratory, Didcot, UK
5 #
6 # This file is part of OPAL.
7 #
8 # OPAL is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with OPAL. If not, see <https://www.gnu.org/licenses/>.
15 #
16 
17 """
18 Minimal set of methods to build an OPAL simulation and run it.
19 This takes about 0.1 s to run on my average desktop PC.
20 """
21 import os
22 import sys
23 import tempfile
24 
25 import pyopal.objects.track_run
26 import pyopal.objects.beam
27 import pyopal.objects.distribution
28 import pyopal.objects.line
29 import pyopal.elements.ring_definition
30 import pyopal.elements.local_cartesian_offset
31 import pyopal.objects.field_solver
32 import pyopal.objects.track
33 import pyopal.objects.option
34 
35 class MinimalRunner(object):
36  """Class to run a minimal OPAL setup.
37 
38  Individual methods make_foo handle set up of each OPAL "global" object:
39  - self.field_solver
40  - self.distribution
41  - self.beam
42  - self.track
43  - self.track_run
44  - self.line
45  - self.ring
46  Explicitly, the OPAL routines are only called in make_foo and run_one so
47  that there is no OPAL things initialised and run_one_fork can be safely
48  called.
49 
50  There are three hooks for user to overload and do stuff:
51  - make_element_iterable: add extra elements to the line
52  - preprocess: add some stuff to do just before tracking starts
53  - postprocess: add some stuff to do just after tracking ends
54  user can do postprocessing after calling run_one, but run_one_fork isolates
55  memory allocation into a forked process so parent process does not have
56  access to memory for e.g. checking details of the field maps, etc.
57  """
58  def __init__(self):
59  """Initialise to empty data"""
60  self.field_solver = None
61  self.distribution = None
62  self.line = None
63  self.ring = None
64  self.beam = None
65  self.track = None
66  self.run = None
67  self.track_run = None
68  self.option = None
69 
70  self.tmp_dir = tempfile.mkdtemp()
71  self.distribution_filename = os.path.join(self.tmp_dir,
72  "distribution.dat")
73  self.max_steps = 100
74  self.r0 = 1.0 # [m]
75  self.momentum = 0.1 # [GeV/c]
76  self.mass = 0.93827208816 # [GeV/c^2]
77  self.steps_per_turn = 100
78  self.time_per_turn = 1e-6 # [seconds]
79  self.run_name = None # default is "PyOpal"
80  self.exit_code = 0
81  self.verbose = 2 # 2 is everything; 1 is warnings and errors; 0 is errors
82 
83  def make_field_solver(self):
84  """Make an empty fieldsolver
85 
86  The fieldsolver has the job of solving the space charge problem on
87  successive time steps. In this example, the FieldSolver is switched off
88  (i.e. set to type = "NONE").
89  """
90  self.field_solver = pyopal.objects.field_solver.FieldSolver()
91  self.field_solver.set_opal_name("DefaultFieldSolver")
92  self.field_solver.type = "NONE"
93  self.field_solver.mesh_size_x = 5
94  self.field_solver.mesh_size_y = 5
95  self.field_solver.mesh_size_t = 5
96  self.field_solver.parallelize_x = False
97  self.field_solver.parallelize_y = False
98  self.field_solver.parallelize_t = False
99  self.field_solver.boundary_x = "open"
100  self.field_solver.boundary_y = "open"
101  self.field_solver.boundary_t = "open"
102  self.field_solver.bounding_box_increase = 2
103  self.field_solver.register()
104 
105  def make_distribution(self):
106  """Make a distribution
107 
108  The distribution is the initial beam distribution of the PyOPAL
109  simulation. In this example, a distribution loaded from a tempfile is
110  called, where the tempfile is written to disk dynamically at runtime.
111  """
112  dist_file = open(self.distribution_filename, "w+")
113  dist_file.write(self.distribution_str)
114  dist_file.flush()
115  dist_file.close()
116  self.distribution = pyopal.objects.distribution.Distribution()
117  self.distribution.set_opal_name("DefaultDistribution")
118  self.distribution.type = "FROMFILE"
119  self.distribution.filename = self.distribution_filename
120  self.distribution.register()
121  return self.distribution
122 
123  def make_beam(self):
124  """Make a beam
125 
126  The beam holds the global/default beam distribution information, such as
127  the mass of particles in the beam, number of particles in the beam and
128  so on.
129  """
130  beam = pyopal.objects.beam.Beam()
131  beam.set_opal_name("DefaultBeam")
132  beam.mass = self.mass
133  beam.momentum = self.momentum
134  beam.charge = 1.0
135  beam.beam_frequency = 1e-6/self.time_per_turn # MHz
136  beam.number_of_slices = 10
137  beam.number_of_particles = int(self.distribution_str.split()[0])
138  beam.momentum_tolerance = 0 # disable momentum checking
139  beam.register()
140  self.beam = beam
141 
142  @classmethod
143  def null_drift(cls):
144  """Returns a drift of length 0
145 
146  OPAL requires at least one element in each beam line. For this simplest
147  example a drift of length 0 is used.
148  """
149  drift = pyopal.elements.local_cartesian_offset.LocalCartesianOffset()
150  drift.set_opal_name("DefaultDrift")
151  drift.end_position_x=0.0
152  drift.end_position_y=0.0
153  drift.end_normal_x=0.0
154  drift.end_normal_y=1.0
155  return drift
156 
157  def make_ring(self):
158  """Make a RingDefinition object.
159 
160  The RingDefinition holds default parameters for a ring initial
161  conditions, in particular the initial cylindrical coordinates for the
162  first element placement and beam, and the minimum and maximum radius
163  allowed before particles are considered lost. The ring can be appended
164  to self.line and used with OPAL cyclotron mode.
165  """
166  self.ring = pyopal.elements.ring_definition.RingDefinition()
167  self.ring.set_opal_name("DefaultRing")
168  self.ring.lattice_initial_r = self.r0
169  self.ring.beam_initial_r = self.r0
170  self.ring.minimum_r = self.r0/2
171  self.ring.maximum_r = self.r0*100
172  self.ring.is_closed = False
173 
174  def make_option(self):
175  """Options enable setting of global control variables.
176 
177  No options are set by default. For a full list of variables see the
178  Option docs.
179  """
180  self.option = pyopal.objects.option.Option()
181  self.option.info = self.verbose > 1
182  self.option.warn = self.verbose > 0
183  self.option.execute()
184 
185  def make_line(self):
186  """Make a Line object.
187 
188  The Line holds a sequence of beam elements.
189  """
190  self.line = pyopal.objects.line.Line()
191  self.line.set_opal_name("DefaultLine")
192  try:
193  self.line.append(self.ring)
194  except Exception:
195  print(self.ring_error)
196  raise
197  self.line.append(self.null_drift())
198  an_element_iter = self.make_element_iterable()
199  for element in an_element_iter:
200  self.line.append(element)
201  self.line.register()
202 
203  def make_track(self):
204  """Make a track object.
205 
206  The track object handles the interface between tracking and the beam
207  elements.
208  """
209  track = pyopal.objects.track.Track()
210  track.line = "DefaultLine"
211  track.beam = "DefaultBeam"
212  track.max_steps = [self.max_steps]
213  track.steps_per_turn = self.steps_per_turn
214  self.track = track
215  self.track.execute()
216 
217  def make_track_run(self):
218  """Make a TrackRun
219 
220  The TrackRun handles the interface between the Track, distribution,
221  field solver and calls the actual tracking routines.
222  """
223  run = pyopal.objects.track_run.TrackRun()
224  run.method = "CYCLOTRON-T"
225  run.keep_alive = True
226  run.beam_name = "DefaultBeam"
227  run.distribution = ["DefaultDistribution"]
228  run.field_solver = "DefaultFieldSolver"
229  self.track_run = run
230 
232  """
233  Return an iterable (e.g. list) of elements to append to the line
234 
235  By default, returns an empty list. User can overload this method.
236  """
237  return []
238 
239  def preprocess(self):
240  """Perform any preprocessing steps just before the trackrun is executed
241 
242  This method can be overloaded with user required steps.
243  """
244  pass
245 
246  def postprocess(self):
247  """Perform any postprocessing steps after the tracking is executed
248 
249  This method can be overloaded with user required steps.
250  """
251  pass
252 
253  def execute(self):
254  """Set up and run a simulation"""
255  here = os.getcwd()
256  try:
257  os.chdir(self.tmp_dir)
258  self.make_option()
259  self.make_distribution()
260  self.make_field_solver()
261  self.make_beam()
262  self.make_ring()
263  self.make_line()
264  self.make_track()
265  self.make_track_run()
266  if self.run_name:
267  self.track_run.set_run_name(self.run_name)
268  self.preprocess()
269  self.track_run.execute()
270  self.postprocess()
271  except:
272  raise
273  finally:
274  if self.verbose:
275  print("Finished running in directory", os.getcwd())
276  os.chdir(here)
277 
278  def execute_fork(self):
279  """
280  Set up and run a simulation in a fork of the current process. The
281 
282  This method is memory safe - resources are only created in the
283  forked process, which is destroyed when the process concludes. The
284  downside is that resources (e.g. lattice objects, etc) are destroyed
285  when the process concludes. If something is needed, overload the
286  preprocess and postprocess routines to do anything just before or just
287  after tracking.
288 
289  Returns the return code of the forked process, given by self.exit_code
290  from the forked MinimalRunner. This can be used as a simple flag for
291  comms from the child to the parent process (e.g. for testing purposes).
292  For example, postprocess(self) can be overloaded to set an exit code.
293 
294  Tested in linux, I don't know about OSX. Unlikely to work in any
295  Windows environment.
296  """
297  a_pid = os.fork()
298  if a_pid == 0: # the child process
299  self.execute()
300  # hard exit returning exit_code - don't want to end up in any exit
301  # handling stuff, just die ungracefully now the simulation has run
302  os._exit(self.exit_code)
303  else:
304  retvalue = os.waitpid(a_pid, 0)[1]
305  return retvalue
306 
307  distribution_str = """1
308 0.0 0.0 0.0 0.0 0.0 0.0
309 """
310 
311  ring_error = "Failed to append ring to the line. Try running make_ring()"+\
312  " before calling make_line()."
313 
314 def main():
315  runner = MinimalRunner()
316  runner.execute_fork()
317 
318 if __name__ == "__main__":
319  main()