Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 94 additions & 25 deletions tools/Python/mcrun/mcrun.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,34 @@ def add_mcrun_options(parser):
help='Read parameters from file FILE')

add('-N', '--numpoints',
type=int, metavar='NP',
help='Set number of scan points')
metavar='NP',
help='Set number of scan points. A single integer applies the same '
'point count to every scanned parameter (the default, and the '
'only valid form without -M). With -M/--multi, a comma-separated '
'list (e.g. -N=5,10,20) instead gives each scanned parameter its '
'own point count, in the same order the parameters are listed '
'on the command line.')

add('--seeds',
metavar='SEEDS',
help='Set range of seeds to scan (each must be: SEED != 0)')

add('-L', '--list',
action='store_true',
help='Use a fixed list of points for linear scanning')
help='Use a fixed list of points for scanning, walking every scanned '
'parameter\'s list together in lockstep (all lists must then be '
'the same length). Combine with -M/--multi instead to take the '
'cartesian product of each parameter\'s own list (lists may then '
'have different lengths).')

add('-M', '--multi',
action='store_true',
help='Run a multi-dimensional scan')
help='Run a multi-dimensional scan (the cartesian product of every '
'scanned parameter\'s own points, rather than walking them all '
'in lockstep). Combine with -L/--list (each parameter\'s '
'explicit list can then have a different length) or give -N '
'a comma-separated list (see -N/--numpoints) for per-parameter '
'point counts.')

add("--scan_split",
type=int,
Expand Down Expand Up @@ -595,31 +609,89 @@ def main():
if options.list and options.seeds:
raise OptionValueError('--seeds cannot be used with --list')

# Parse -N/--numpoints (a plain string now, not auto-int'd by optparse -
# see add_mcrun_options()): with -M/--multi it may be a comma-separated
# list of integers, one per scanned parameter in the same order the
# parameters were given on the command line, rather than a single
# integer applied uniformly to every dimension. A list form without -M
# is rejected outright: a plain (co-linear) scan walks every parameter
# in lockstep over the same number of steps, so per-dimension point
# counts don't apply there. Unreachable when --list was also given,
# thanks to the check just above.
numpoints_list = None
if options.numpoints is not None:
numpoints_parts = str(options.numpoints).split(',')
if len(numpoints_parts) > 1:
if not options.multi:
raise OptionValueError(
'A comma-separated list for -N/--numpoints (e.g. -N=5,10,20) is only valid '
'together with -M/--multi.')
try:
numpoints_list = [int(p) for p in numpoints_parts]
except ValueError:
raise OptionValueError('-N/--numpoints list must contain only integers: "%s"' % options.numpoints)
if any(n < 2 for n in numpoints_list):
raise OptionValueError(
'Cannot scan using only one data point - every entry in -N/--numpoints must be at least 2.')
options.numpoints = None # resolved into numpoints_list/numpoints_dict instead, below
else:
try:
options.numpoints = int(numpoints_parts[0])
except ValueError:
raise OptionValueError(
'-N/--numpoints must be an integer (or, with -M, a comma-separated list of integers): "%s"'
% options.numpoints)

if options.list:
if len(intervals) == 0:
raise OptionValueError(
'--list was chosen but no lists was presented.')
pointlist = list(intervals.values())
points = len(pointlist[0])
if not (all(map(lambda i: len(i) == points, intervals.values()))):
if options.multi:
# -L + -M: cartesian product across each parameter's own
# explicit list of points - unlike plain -L (which walks every
# list together in lockstep, requiring them all to be the same
# length), each dimension is independent here, so the lists
# may have different lengths.
interval_points = MultiInterval.from_list(intervals)
options.numpoints = 1
for values in intervals.values():
options.numpoints *= len(values)
else:
pointlist = list(intervals.values())
points = len(pointlist[0])
if not (all(map(lambda i: len(i) == points, intervals.values()))):
raise OptionValueError(
'All variables must have an equal amount of points.')
interval_points = LinearInterval.from_list(
points, intervals)
options.numpoints = points

elif numpoints_list is not None:
# -M + -N=a,b,c,...: per-dimension point counts, no explicit lists
if len(numpoints_list) != len(intervals):
raise OptionValueError(
'All variables must have an equal amount of points.')
interval_points = LinearInterval.from_list(
points, intervals)
'-N/--numpoints list has %d entr%s but %d parameter%s being scanned (%s); '
'provide exactly one point-count per scanned parameter, in the same order.' % (
len(numpoints_list), 'y' if len(numpoints_list) == 1 else 'ies',
len(intervals), '' if len(intervals) == 1 else 's are',
', '.join(intervals)))
numpoints_dict = dict(zip(intervals.keys(), numpoints_list))
interval_points = MultiInterval.from_range(numpoints_dict, intervals)
total = 1
for n in numpoints_list:
total *= n
options.numpoints = total

scan = options.multi or options.numpoints
if (options.numpoints is not None and options.numpoints < 2) or (scan and options.numpoints is None):
raise OptionValueError((f'Cannot scan variable(s) {", ".join(intervals)} using only one data point. '
'Please use -N to specify the number of points.'))
## ## This *was* unreachable due to its indentation. Should it be removed entirely?
# # Check that input is valid decimals
# if not all(map(lambda i: len(i) == 2 and all(map(is_decimal, i)), intervals.values())):
# raise OptionValueError(f'Could not parse intervals -- result: {intervals}')
else:
scan = options.multi or options.numpoints
if (options.numpoints is not None and options.numpoints < 2) or (scan and options.numpoints is None):
raise OptionValueError((f'Cannot scan variable(s) {", ".join(intervals)} using only one data point. '
'Please use -N to specify the number of points.'))

if options.multi is not None:
interval_points = MultiInterval.from_range(options.numpoints, intervals)
elif options.numpoints is not None:
interval_points = LinearInterval.from_range(options.numpoints, intervals)
if options.multi is not None:
interval_points = MultiInterval.from_range(options.numpoints, intervals)
elif options.numpoints is not None:
interval_points = LinearInterval.from_range(options.numpoints, intervals)


# Check that mpi and scan split are not both used. Default to mpi if they are
Expand All @@ -628,9 +700,6 @@ def main():

# Parameters for linear scanning present
if interval_points and (options.scan_split is None):
# In case of list, update with number of list points
if options.list:
options.numpoints=len(pointlist[0])
scanner = Scanner(mcstas, intervals)
scanner.set_points(interval_points)
if (not options.dir == ''):
Expand Down
125 changes: 112 additions & 13 deletions tools/Python/mcrun/optimisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ def build_mccodesim_header(options, intervals: dict, detectors: list, version: s
# TODO: figure out correct scan type
numpoints = 1 if options.optimize else options.numpoints

# -L list scan: use the position (1..N) within the list, matching
# build_header()'s existing convention for -L scans above - meaningful
# for a non-numeric list (e.g. filenames), where a literal min()/max()
# of the raw strings would be lexicographic and essentially
# meaningless, and harmless for a numeric one (the actual per-point
# values are written into mccode.dat itself; this is just the
# header's overall axis-range hint). Equidistant (-N/-M, non-list)
# scans are untouched, keeping their existing min()/max() behaviour.
if options.list:
xmin, xmax = 1, len(first_key_interval)
else:
xmin, xmax = min(first_key_interval), max(first_key_interval)

values = {
'instr': options.instr,
'date': datetime.strftime(datetime.now(), '%a %b %d %H %M %Y'),
Expand All @@ -145,8 +158,8 @@ def build_mccodesim_header(options, intervals: dict, detectors: list, version: s
'xvars': interval_names,
'yvars': ' '.join(f'({d}_I,{d}_ERR' for d in detectors),

'xmin': min(first_key_interval),
'xmax': max(first_key_interval),
'xmin': xmin,
'xmax': xmax,

'filename': basename(options.optimise_file) or 'mccode.dat',
'variables': ' '.join(intervals.keys()) + ' '.join(f'{d}_I {d}_ERR' for d in detectors),
Expand Down Expand Up @@ -189,6 +202,50 @@ def point_at(N, key, minmax, step):
return step * (high - low) / Decimal(N - 1) + low


def resolve_scan_value(key, value, intervals):
""" Returns a numeric representation of one scanned parameter's value
for one scan point, for writing into mccode.dat's per-point data
row (mccode.dat's format is a matrix of numbers - see module
docstring/build_header() - so every column needs one, regardless
of what kind of value the parameter itself actually is).

A genuinely numeric value (the overwhelming majority of scans, and
the only kind LinearInterval/MultiInterval.from_range() ever
produce) passes straight through unchanged - this function has no
effect at all outside of an -L/--list scan with a non-numeric
list.

A non-numeric value (e.g. a -L scan like
filename=Na2Ca3Al2F14.laz,YBaCuO.lau,Fe.laz,Cu.laz) is replaced by
its own *index* within intervals[key] - the position it appears
at in the original -L list, e.g. that list gives indices 0,1,2,3
respectively - so mccode.dat keeps a properly numeric column for
this parameter too, and remains plottable against it (as a
categorical/index axis) rather than needing the actual string
embedded in a number matrix.

Each scanned parameter is resolved independently, unlike the
previous behaviour of collapsing the ENTIRE row down to a single
step-index the moment ANY ONE scanned parameter was non-numeric -
which silently discarded every OTHER parameter's real value too
(numeric ones included), and produced only one parameter column
regardless of how many were actually being scanned - a mismatch
against the header's declared xvars/variables count that broke
every downstream plotting tool, since they parse a fixed number
of parameter columns based on that count. """
try:
return float(value)
except (TypeError, ValueError):
pass
try:
return float(list(intervals[key]).index(value))
except (KeyError, ValueError):
# value isn't literally in intervals[key] (shouldn't normally
# happen, since scan points are always built FROM intervals[key] -
# but fall back to a stable value rather than crashing outright)
return float(abs(hash(value)) % 1000000)


class LinearInterval:
""" Intervals for linear scanning """

Expand All @@ -211,6 +268,13 @@ class MultiInterval:

@staticmethod
def from_range(N, intervals):
""" N is either a single int (the same point count applied to
every scanned dimension - the original behaviour) or a dict
mapping each interval key to its own point count (mcrun's
-N=a,b,c,... list-form, only valid together with -M, letting
different parameters be sampled at different resolutions -
e.g. a coarse 3-point sweep on one axis against a fine
20-point sweep on another). """
print(f"MultiInterval from {N=} and {intervals=}")
# base case: no intervals yields empty dict
if len(intervals) == 0:
Expand All @@ -219,12 +283,34 @@ def from_range(N, intervals):
# recursively generate the multi dict
intervals = intervals.copy()
key, minmax = intervals.popitem()
for step in range(N):
point = point_at(N, key, minmax, step)
n_here = N[key] if isinstance(N, dict) else N
for step in range(n_here):
point = point_at(n_here, key, minmax, step)
for dic in MultiInterval.from_range(N, intervals):
dic[key] = point
yield dic

@staticmethod
def from_list(intervals):
""" Cartesian product across each key's own explicit list of
points (mcrun's -L/--list combined with -M/--multi). Unlike
LinearInterval.from_list() (co-linear: every key's list is
walked together in lockstep, so all lists must be the same
length), each key here is varied independently, so the lists
may have different lengths - which is also how different
parameters naturally end up with different numbers of scan
points in this mode, without needing a separate -N. """
print(f"MultiInterval from_list {intervals=}")
if len(intervals) == 0:
yield {}
return
intervals = intervals.copy()
key, values = intervals.popitem()
for value in values:
for dic in MultiInterval.from_list(intervals):
dic[key] = value
yield dic


class InvalidInterval(McRunException):
pass
Expand All @@ -247,7 +333,15 @@ def _simulate_point(args):

for key in intervals:
mcstas.set_parameter(key, point[key])
par_values.append(point[key])
# set_parameter() above needs the real value (a genuine instrument
# filename parameter needs the actual string, not an index) - only
# what goes into the OUTPUT ROW (par_values, eventually written to
# mccode.dat) needs the numeric-or-index resolution. Unlike
# Scanner.run() (only reachable for -L scans), this path is shared
# with plain equidistant multi-dim scans too, but
# resolve_scan_value() is a no-op for those - every value is
# already numeric there.
par_values.append(resolve_scan_value(key, point[key], intervals))

current_dir = f'{mcstas_dir}/{i}'
mcstas.run(pipe=False, extra_opts={'dir': current_dir})
Expand Down Expand Up @@ -322,17 +416,22 @@ def run(self):
LOG.info(f"Write step detectors line into {self.outfile}")
values = ['%s %s' % (d.intensity, d.error) for d in detectors]

# Normal equidistant scan
if not self.mcstas.options.list:
# Normal equidistant scan: LinearInterval/MultiInterval
# .from_range() only ever produce numeric values, so
# this is unchanged.
line = '%s %s\n' % (' '.join(map(str, par_values)), ' '.join(values))
else:
try:
# Check if parameters are numeric/float
par_floats = [float(x) for x in par_values]
line = '%s %s\n' % (' '.join(map(str, par_floats)), ' '.join(values))
except:
# otherwise use simple 'index' (may be scanning e.g. a filename)
line = '%s %s\n' % (str(i), ' '.join(values))
# -L list scan: resolve each scanned parameter's
# value independently (see resolve_scan_value()) -
# a genuinely numeric value passes straight
# through, and only a non-numeric one (e.g. a
# filename) becomes its own index within that
# parameter's own list, keeping one proper numeric
# column per scanned parameter either way.
resolved = [resolve_scan_value(key, val, self.intervals)
for key, val in zip(self.intervals.keys(), par_values)]
line = '%s %s\n' % (' '.join(map(str, resolved)), ' '.join(values))
outfile.write(line)
outfile.flush()

Expand Down
Loading