From 3b9814967b5b77a3a689cdaaec182edc731046e3 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 30 Aug 2026 07:46:51 +0200 Subject: [PATCH 1/3] Draft implementation of -M with -Na,b,c,d for multi-dimensional scans and -M in combination with -L --- tools/Python/mcrun/mcrun.py | 119 +++++++++++++++++++++++------ tools/Python/mcrun/optimisation.py | 33 +++++++- 2 files changed, 125 insertions(+), 27 deletions(-) diff --git a/tools/Python/mcrun/mcrun.py b/tools/Python/mcrun/mcrun.py index 0e987a1929..21ee4468d6 100644 --- a/tools/Python/mcrun/mcrun.py +++ b/tools/Python/mcrun/mcrun.py @@ -92,8 +92,13 @@ 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', @@ -101,11 +106,20 @@ def add_mcrun_options(parser): 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, @@ -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 @@ -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 == ''): diff --git a/tools/Python/mcrun/optimisation.py b/tools/Python/mcrun/optimisation.py index 1543dbaaa2..06e46b7f99 100644 --- a/tools/Python/mcrun/optimisation.py +++ b/tools/Python/mcrun/optimisation.py @@ -211,6 +211,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: @@ -219,12 +226,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 From 145f571c3efceb321f5eafe576bbd211433669b1 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 30 Aug 2026 08:06:32 +0200 Subject: [PATCH 2/3] Prototype to allow plottable output for scans with non-numeric lists --- tools/Python/mcrun/optimisation.py | 92 ++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 11 deletions(-) diff --git a/tools/Python/mcrun/optimisation.py b/tools/Python/mcrun/optimisation.py index 06e46b7f99..b7b3c8f196 100644 --- a/tools/Python/mcrun/optimisation.py +++ b/tools/Python/mcrun/optimisation.py @@ -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'), @@ -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), @@ -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 """ @@ -276,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}) @@ -351,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() From 36031cbca38bae458a3b8ba0f38e96a1c97f71c5 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Tue, 1 Sep 2026 00:32:54 +0200 Subject: [PATCH 3/3] Add par=min:delta:max syntax for specifying parameter ranges (-N calculated automatically) --- tools/Python/mcrun/mcrun.py | 129 ++++++++++++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 4 deletions(-) diff --git a/tools/Python/mcrun/mcrun.py b/tools/Python/mcrun/mcrun.py index 21ee4468d6..08c96afb79 100644 --- a/tools/Python/mcrun/mcrun.py +++ b/tools/Python/mcrun/mcrun.py @@ -98,7 +98,9 @@ def add_mcrun_options(parser): '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.') + 'on the command line. Not needed at all for a parameter given as ' + '"min:delta:max" (see the usage line above) - the point count is ' + 'computed from the requested bin width instead.') add('--seeds', metavar='SEEDS', @@ -469,10 +471,60 @@ def get_parameters(options): ''' Get fixed and scan/optimise parameters ''' fixed_params = {} intervals = {} + # Per-key point counts implied by the "a:delta:b" syntax below - kept + # separate from intervals (which only ever holds the [a, b] endpoints, + # matching every other scan mode's shape) so main() can tell which + # parameters had an explicit point count baked into their own syntax, + # as opposed to needing one supplied via -N. + equidistant_numpoints = {} for param in options.params: if '=' in param: key, value = param.split('=', 1) + + # "par=a:delta:b" - an equidistant scan specified by its bin + # width (delta) rather than an explicit point count: mcrun + # computes how many points are needed to cover [a, b] in steps + # of (approximately - see rounding below) delta, rather than + # the user needing to work out -N by hand. Checked before the + # comma-based interval parsing below, since a colon can never + # appear in a numeric value/list, so a colon anywhere in the + # value unambiguously means this syntax was intended. + if ':' in value: + parts = value.split(':') + if len(parts) != 3: + raise OptionValueError( + 'Parameter "%s" uses "a:delta:b" syntax but has %d colon-separated part(s) ' + '(expected exactly 3: start:delta:stop): "%s"' % (key, len(parts), value)) + try: + a, delta, b = (float(p) for p in parts) + except ValueError: + raise OptionValueError( + 'Parameter "%s" uses "a:delta:b" syntax but not all three parts are numbers: "%s"' + % (key, value)) + if delta == 0: + raise OptionValueError( + 'Parameter "%s" uses "a:delta:b" syntax with delta=0, which would need ' + 'infinitely many points: "%s"' % (key, value)) + if a == b: + raise OptionValueError( + 'Parameter "%s" uses "a:delta:b" syntax with a == b (%s), so there is nothing ' + 'to scan - use a fixed value "%s=%s" instead.' % (key, a, key, a)) + # Rounds to the nearest point count that covers [a, b] as + # closely as possible to the requested delta - the actual + # step size (recomputed from a, b, and this rounded N, the + # same way every other equidistant scan already works via + # LinearInterval/MultiInterval.from_range()) will usually + # differ very slightly from delta itself, since [a, b] + # isn't guaranteed to be an exact multiple of delta and + # both endpoints are always included. + n_points = max(2, round(abs(b - a) / abs(delta)) + 1) + intervals[key] = [str(a), str(b)] + equidistant_numpoints[key] = n_points + LOG.debug('interval[%s]: %s (a:delta:b syntax, delta=%s -> %d points)', + key, intervals[key], delta, n_points) + continue + interval = value.split(',') # When just one point is present, fix as constant if len(interval) == 1: @@ -482,7 +534,7 @@ def get_parameters(options): intervals[key] = interval else: LOG.warning('Ignoring invalid parameter: "%s"', param) - return (fixed_params, intervals) + return (fixed_params, intervals, equidistant_numpoints) def find_instr_file(instr): @@ -506,7 +558,7 @@ def main(): # Add options usage = ('usage: %prog [-cpnN] Instr [-sndftgahi] ' - 'params={val|min,max|min,guess,max}...') + 'params={val|min,max|min:delta:max|min,guess,max}...') parser = OptionParser(usage, version=mccode_config.configuration['MCCODE_VERSION']) add_mcrun_options(parser) @@ -581,7 +633,7 @@ def main(): mcstas = McStas(options.instr) mcstas.prepare(options) - (fixed_params, intervals) = get_parameters(options) + (fixed_params, intervals, equidistant_numpoints) = get_parameters(options) # Add --seeds as an 'interval', to allow scanning simulation seed if options.seeds: intervals['--seed']=options.seeds.split(',') @@ -609,6 +661,25 @@ def main(): if options.list and options.seeds: raise OptionValueError('--seeds cannot be used with --list') + # The "a:delta:b" syntax (see get_parameters()) already determines its + # own equidistant point count for whichever parameter(s) use it - it + # doesn't mix with -L (a fundamentally different, explicit-list scan + # mode; intervals[key] would be a [min, max] pair from a:delta:b, not + # an explicit list of values, regardless of what else is being + # scanned). An explicit -N is only actually redundant/conflicting when + # EVERY scanned parameter already gets its point count from a:delta:b + # - a scan mixing a:delta:b with a plain "min,max" parameter still + # legitimately needs -N to say how many points THAT one should have + # (see the "mixed" branch below). + if equidistant_numpoints and options.list: + raise OptionValueError( + 'The "a:delta:b" syntax (used for %s) specifies an equidistant scan and cannot be ' + 'combined with -L/--list.' % ', '.join(equidistant_numpoints)) + if equidistant_numpoints and options.numpoints and len(equidistant_numpoints) == len(intervals): + raise OptionValueError( + 'The "a:delta:b" syntax (used for %s) already determines its own point count for every ' + 'scanned parameter, so an explicit -N/--numpoints is redundant here.' % ', '.join(equidistant_numpoints)) + # 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 @@ -682,6 +753,56 @@ def main(): total *= n options.numpoints = total + elif equidistant_numpoints: + # "a:delta:b" syntax: each such parameter already has its own + # point count computed in get_parameters(), independent of -N/-M. + if len(equidistant_numpoints) == len(intervals): + # Every scanned parameter uses a:delta:b. + distinct_n = set(equidistant_numpoints.values()) + if options.multi: + # -M: cartesian product, each dimension keeping its own + # delta-derived point count - identical in spirit to + # -N=a,b,c,... + -M above, just sourced from delta instead. + interval_points = MultiInterval.from_range(equidistant_numpoints, intervals) + total = 1 + for n in equidistant_numpoints.values(): + total *= n + options.numpoints = total + elif len(distinct_n) == 1: + # No -M, but every parameter's delta happens to imply the + # same point count anyway - a perfectly ordinary co-linear + # scan, so there's no need to force the user to add -M. + options.numpoints = distinct_n.pop() + interval_points = LinearInterval.from_range(options.numpoints, intervals) + else: + raise OptionValueError( + 'Parameter(s) %s use "a:delta:b" syntax with different resulting point counts (%s) - ' + 'add -M/--multi to scan them independently (a cartesian product), or use matching ' + 'delta values for a co-linear scan.' % ( + ', '.join(equidistant_numpoints), + ', '.join('%s=%d' % (k, v) for k, v in equidistant_numpoints.items()))) + else: + # Mixed: some parameters use a:delta:b, others a plain min,max + # (no delta) that still needs a point count from somewhere. + missing = [k for k in intervals if k not in equidistant_numpoints] + if not options.multi: + raise OptionValueError( + 'Parameter(s) %s use "a:delta:b" syntax alongside plain interval(s) %s - add ' + '-M/--multi to scan them independently, or use "a:delta:b" for every scanned ' + 'parameter.' % (', '.join(equidistant_numpoints), ', '.join(missing))) + if options.numpoints is None: + raise OptionValueError( + 'Parameter(s) %s need a point count - use "a:delta:b" syntax for them too, or ' + 'supply a plain -N value.' % ', '.join(missing)) + full_numpoints_dict = dict(equidistant_numpoints) + for k in missing: + full_numpoints_dict[k] = options.numpoints + interval_points = MultiInterval.from_range(full_numpoints_dict, intervals) + total = 1 + for n in full_numpoints_dict.values(): + total *= n + options.numpoints = total + else: scan = options.multi or options.numpoints if (options.numpoints is not None and options.numpoints < 2) or (scan and options.numpoints is None):