From 5b8b15ea02a959e367831e031112588967f7fda0 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Thu, 9 Jul 2026 22:04:07 +0200 Subject: [PATCH 01/20] Expand template for possible DIFF view link --- tools/Python/mctest/main.template | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/Python/mctest/main.template b/tools/Python/mctest/main.template index 85e554c46..e31fdd170 100644 --- a/tools/Python/mctest/main.template +++ b/tools/Python/mctest/main.template @@ -50,11 +50,17 @@ a:visited { color: lightgrey; background-color: transparent; text-decoration: no C:{{ c.1 }}/R:{{ c.2 }}
I={{ c.3 }} ({{ c.4 }})

{{ c.6 }}
PLOTS: [Interactive HTML] [PDF overview] + {%- if c.8 %} +
DIFF: [vs ref] + {%- endif %} {%- elif c.0 == 2 %} C:{{ c.1 }}/R:{{ c.2 }}
I={{ c.3 }} ({{ c.4 }})

{{ c.6 }}
PLOTS: [Interactive HTML] [PDF overview] + {%- if c.8 %} +
DIFF: [vs ref] + {%- endif %} {%- elif c.0 == 3 %} C:{{ c.1 }} From 6556cfa33a6b6d7bd27696676a1ebe06a6972fc5 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Thu, 9 Jul 2026 22:10:54 +0200 Subject: [PATCH 02/20] Let mcviewtest generate "diff" links for comparison to the "reference" column --- tools/Python/mctest/mcviewtest.py | 71 ++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 5 deletions(-) mode change 100755 => 100644 tools/Python/mctest/mcviewtest.py diff --git a/tools/Python/mctest/mcviewtest.py b/tools/Python/mctest/mcviewtest.py old mode 100755 new mode 100644 index c219325ec..e65793e4d --- a/tools/Python/mctest/mcviewtest.py +++ b/tools/Python/mctest/mcviewtest.py @@ -31,7 +31,7 @@ def get_oldest_dir(directory_name): files.sort(key=lambda x:x[0]) return files[0][1] -def run_normal_mode(testdir, reflabel): +def run_normal_mode(testdir, reflabel, nodiff=False, diffmax=300): ''' load test data and print to html label ''' def get_col_header(label, meta): @@ -53,7 +53,55 @@ def get_header_lst(meta): lst.append(meta["date"]) return lst - def get_cell_tuple(cellobj, refval=None): + def get_data_url(cellobj): + ''' Reconstructs the relative "label/instrname/testnb/" data directory + for a cellobj, the same way get_cell_tuple() does for its own + cell - used here to also locate the *reference* column's data + directory when generating a diff link. ''' + label = cellobj["localfile"].split("/") + if len(label) == 1: + label = cellobj["localfile"].split("\\") + label = label[len(label) - 3] + return label + "/" + cellobj["instrname"] + "/" + str(cellobj["testnb"]) + "/" + + def generate_diff_link(refcellobj, url, label): + ''' Generates (or reuses a cached) mcplotdiff-html comparison between + this cell's own test-output directory and the reference column's + corresponding directory, returning a relative URL to the diff + overview's index.html, or None if a diff isn't available/possible + (missing/incomplete data on either side, disabled via --nodiff, + or the plotter itself failing). ''' + if nodiff: + return None + if refcellobj is None or refcellobj.get("testval") is None: + # no valid reference data to diff against + return None + + ref_url = get_data_url(refcellobj) + test_abs = join(testdir, url) + ref_abs = join(testdir, ref_url) + if not (os.path.isfile(join(test_abs, "mccode.sim")) and os.path.isfile(join(ref_abs, "mccode.sim"))): + # NeXus-only output (mccode.h5) or otherwise nothing mcplotdiff-html can compare + return None + + outdir_rel = join(url, "diff_vs_%s" % reflabel) + outdir_abs = join(testdir, outdir_rel) + index_abs = join(outdir_abs, "index.html") + + if not os.path.isfile(index_abs): + diffplotter = mccode_config.configuration["MCPLOT"].split('-')[0] + "diff-html" + cmd = '%s "%s" "%s" --nobrowse -A "%s" -B "%s" --output "%s"' % ( + diffplotter, test_abs, ref_abs, label, reflabel, outdir_abs) + try: + utils.run_subtool_noread(cmd, cwd=testdir, timeout=diffmax) + except Exception as e: + print("generate_diff_link: %s" % str(e)) + + if os.path.isfile(index_abs): + return (outdir_rel + "/index.html").replace(os.sep, '/').replace("//", "/") + return None + + def get_cell_tuple(cellobj, refval=None, refcellobj=None): ''' set up and format cell data ''' state = None compiletime = None @@ -141,7 +189,9 @@ def get_cell_tuple(cellobj, refval=None): else: refp = "%2.f" % refp + "%" - return (state, compiletime, runtime, testval, refp, url, display, displayurl) + diffurl = generate_diff_link(refcellobj, url, label) + + return (state, compiletime, runtime, testval, refp, url, display, displayurl, diffurl) def get_empty_cell_tuple(tag=None): ''' return a "state_four" black cell, optionally with a tag, this could be "no ref" or "no test" etc. ''' @@ -188,7 +238,13 @@ def iterate_obj_to_populate_rows(iterobj, otherobjs, rows, ncols, use_iterobj_re targetval = o.get("targetval", None) if use_iterobj_refvalue: targetval = iterobj[key]["targetval"] - row.append(get_cell_tuple(o, targetval)) + # diff cells always compare against the true reference + # column (refobj), regardless of which object is + # currently driving iteration (iterobj may be a + # fallback "lead" column rather than refobj itself, in + # the untested multi-column case below) + refcellobj = refobj.get(key, None) + row.append(get_cell_tuple(o, targetval, refcellobj=refcellobj)) # delete "used" cell keys if del_used_from_overobjs: @@ -304,7 +360,10 @@ def main(args): print("No testdir defined, will use current dir") print("--> Using testdir=%s\n" % os.getcwd()) testdir=os.getcwd() - run_normal_mode(testdir, reflabel) + diffmax = 300 + if args.diffmax: + diffmax = int(args.diffmax[0]) + run_normal_mode(testdir, reflabel, nodiff=args.nodiff, diffmax=diffmax) if not args.nobrowse: subprocess.Popen('%s %s' % (mccode_config.configuration['BROWSER'], os.path.join(testdir,os.path.basename(testdir) +'_output.html')), shell=True) @@ -318,6 +377,8 @@ def main(args): parser.add_argument('--testroot', nargs="?", help='test root folder for test result management') parser.add_argument('--verbose', action='store_true', help='output excessive information for debug purposes') parser.add_argument('--nobrowse', action='store_true', help='Do not spawn browser on exit') + parser.add_argument('--nodiff', action='store_true', help='Do not generate mcplotdiff-html comparison cells against the reference column') + parser.add_argument('--diffmax', nargs=1, help='Maximum time (s) allowed per mcplotdiff-html comparison (default 300s)') args = parser.parse_args() main(args) From d8256757737696a2eadfbca83acc663f57c2c11e Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Fri, 10 Jul 2026 09:16:56 +0200 Subject: [PATCH 03/20] Add a
in the cell to separate DIFF from the rest --- tools/Python/mctest/main.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Python/mctest/main.template b/tools/Python/mctest/main.template index e31fdd170..9fb7cb22c 100644 --- a/tools/Python/mctest/main.template +++ b/tools/Python/mctest/main.template @@ -51,7 +51,7 @@ a:visited { color: lightgrey; background-color: transparent; text-decoration: no
{{ c.6 }}
PLOTS: [Interactive HTML] [PDF overview] {%- if c.8 %} -
DIFF: [vs ref] +

DIFF: [vs ref] {%- endif %} {%- elif c.0 == 2 %} From 061fad4dc7234bb35b6577a5f1b8910ba03e9bba Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Fri, 10 Jul 2026 09:33:42 +0200 Subject: [PATCH 04/20] Attempt to make the diff-pages more readable --- tools/Python/mcplot/html/mcplotdiff.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/Python/mcplot/html/mcplotdiff.py b/tools/Python/mcplot/html/mcplotdiff.py index 724097e78..ec56f4b5a 100644 --- a/tools/Python/mcplot/html/mcplotdiff.py +++ b/tools/Python/mcplot/html/mcplotdiff.py @@ -318,7 +318,8 @@ def write_index(outdir, entries, label_a, label_b): outfile.write(" #sizecontrol input[type=range] { vertical-align: middle; margin: 0 8px; }\n") outfile.write("\n") outfile.write("\n") - outfile.write(f"

Difference plots: {label_a} − {label_b}

\n") + outfile.write("

Difference plots:

\n") + outfile.write(f"
  1. {label_a} vs.
  2. {label_b}
" outfile.write(f"

diff.monitor = ({label_a}).monitor − ({label_b}).monitor

\n") outfile.write("
\n") outfile.write(" \n") @@ -349,13 +350,13 @@ def write_index(outdir, entries, label_a, label_b): if a_lin or a_log or b_lin or b_log: outfile.write("|\n") if a_lin: - outfile.write(f"[ {label_a} ]\n") + outfile.write(f"[ A ]\n") if a_log: - outfile.write(f"[ {label_a} (log) ]\n") + outfile.write(f"[ A (log) ]\n") if b_lin: - outfile.write(f"[ {label_b} ]\n") + outfile.write(f"[ B ]\n") if b_log: - outfile.write(f"[ {label_b} (log) ]\n") + outfile.write(f"[ B (log) ]\n") outfile.write("
\n") outfile.write("\n") From ee085eb4c9dfa180c4a945c53308e18946ce8ff0 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Fri, 10 Jul 2026 09:41:07 +0200 Subject: [PATCH 05/20] Close by ) statement --- tools/Python/mcplot/html/mcplotdiff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Python/mcplot/html/mcplotdiff.py b/tools/Python/mcplot/html/mcplotdiff.py index ec56f4b5a..b821d10c8 100644 --- a/tools/Python/mcplot/html/mcplotdiff.py +++ b/tools/Python/mcplot/html/mcplotdiff.py @@ -319,7 +319,7 @@ def write_index(outdir, entries, label_a, label_b): outfile.write("\n") outfile.write("\n") outfile.write("

Difference plots:

\n") - outfile.write(f"
  1. {label_a} vs.
  2. {label_b}
" + outfile.write(f"
  1. {label_a} vs.
  2. {label_b}
") outfile.write(f"

diff.monitor = ({label_a}).monitor − ({label_b}).monitor

\n") outfile.write("
\n") outfile.write(" \n") From 81431880fcd5bdc30585f3cfda22377e0687d816 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Fri, 10 Jul 2026 09:44:58 +0200 Subject: [PATCH 06/20] Try separating links with
--- tools/Python/mcplot/html/mcplotdiff.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/Python/mcplot/html/mcplotdiff.py b/tools/Python/mcplot/html/mcplotdiff.py index b821d10c8..85a623a6a 100644 --- a/tools/Python/mcplot/html/mcplotdiff.py +++ b/tools/Python/mcplot/html/mcplotdiff.py @@ -348,13 +348,13 @@ def write_index(outdir, entries, label_a, label_b): b_lin = _relhref(entry.get('b_lin'), outdir) b_log = _relhref(entry.get('b_log'), outdir) if a_lin or a_log or b_lin or b_log: - outfile.write("|\n") + outfile.write("\n") if a_lin: - outfile.write(f"[ A ]\n") + outfile.write(f"
[ A ]\n") if a_log: outfile.write(f"[ A (log) ]\n") if b_lin: - outfile.write(f"[ B ]\n") + outfile.write(f"
[ B ]\n") if b_log: outfile.write(f"[ B (log) ]\n") From d95a4b660a8ded722ce59cf2d4dda019dc746429 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Fri, 10 Jul 2026 09:46:09 +0200 Subject: [PATCH 07/20]
->
--- tools/Python/mcplot/html/mcplotdiff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/Python/mcplot/html/mcplotdiff.py b/tools/Python/mcplot/html/mcplotdiff.py index 85a623a6a..025f3899d 100644 --- a/tools/Python/mcplot/html/mcplotdiff.py +++ b/tools/Python/mcplot/html/mcplotdiff.py @@ -350,11 +350,11 @@ def write_index(outdir, entries, label_a, label_b): if a_lin or a_log or b_lin or b_log: outfile.write("\n") if a_lin: - outfile.write(f"
[ A ]\n") + outfile.write(f"
[ A ]\n") if a_log: outfile.write(f"[ A (log) ]\n") if b_lin: - outfile.write(f"
[ B ]\n") + outfile.write(f"
[ B ]\n") if b_log: outfile.write(f"[ B (log) ]\n") From 5ac3e2c959c0fe464f3ab5cfdeee2f44799e1ed2 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Fri, 10 Jul 2026 09:59:33 +0200 Subject: [PATCH 08/20] Acceptable but not great: Links below diff mons --- tools/Python/mcplot/html/mcplotdiff.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tools/Python/mcplot/html/mcplotdiff.py b/tools/Python/mcplot/html/mcplotdiff.py index 025f3899d..c6e43222c 100644 --- a/tools/Python/mcplot/html/mcplotdiff.py +++ b/tools/Python/mcplot/html/mcplotdiff.py @@ -336,11 +336,12 @@ def write_index(outdir, entries, label_a, label_b): outfile.write(f"\n") outfile.write("
\n") outfile.write("\n") outfile.write("\n") outfile.write("\n") From 53557760061c8f74550f34fc3e5819a803fdc64e Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Fri, 10 Jul 2026 11:17:19 +0200 Subject: [PATCH 09/20] Make clear that no datafile is available in 'diff mode' --- tools/Python/mcplot/html/mcplot.py | 3 ++- tools/Python/mcplot/html/mcplotdiff.py | 1 + tools/Python/mcplot/html/template_1d.html | 2 +- tools/Python/mcplot/html/template_2d.html | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/Python/mcplot/html/mcplot.py b/tools/Python/mcplot/html/mcplot.py index a66694762..29fd40a3f 100644 --- a/tools/Python/mcplot/html/mcplot.py +++ b/tools/Python/mcplot/html/mcplot.py @@ -40,7 +40,8 @@ def get_html(template_name, params, simfile): text = open(os.path.join(os.path.dirname(__file__),template_name)).read() text = text.replace("@PARAMS@", params) text = text.replace("@DATAFILE@", simfile) - + text = text.replace("@DATALINK@", "Click for ascii data") + logscalestr = "true" if logscale==True else "false" text = text.replace("@LOGSCALE@", logscalestr) text = text.replace("@LIBPATH@", libpath) diff --git a/tools/Python/mcplot/html/mcplotdiff.py b/tools/Python/mcplot/html/mcplotdiff.py index c6e43222c..1dd6b379c 100644 --- a/tools/Python/mcplot/html/mcplotdiff.py +++ b/tools/Python/mcplot/html/mcplotdiff.py @@ -88,6 +88,7 @@ def get_html(template_name, params, simfile): text = open(os.path.join(os.path.dirname(__file__), template_name)).read() text = text.replace("@PARAMS@", params) text = text.replace("@DATAFILE@", simfile) + text = text.replace("@DATALINK@", "Diff mode (no physical datafile)") # Nothing to link to in diff-mode logscalestr = "true" if logscale == True else "false" text = text.replace("@LOGSCALE@", logscalestr) diff --git a/tools/Python/mcplot/html/template_1d.html b/tools/Python/mcplot/html/template_1d.html index e4356acbf..ece1e1cb6 100644 --- a/tools/Python/mcplot/html/template_1d.html +++ b/tools/Python/mcplot/html/template_1d.html @@ -13,6 +13,6 @@ plt.rgstrMouseRClickPlot( () => { console.log("right click"); } );

-Click for ascii data +@DATALINK@ diff --git a/tools/Python/mcplot/html/template_2d.html b/tools/Python/mcplot/html/template_2d.html index 217fcaf6f..fdd0a7fbf 100644 --- a/tools/Python/mcplot/html/template_2d.html +++ b/tools/Python/mcplot/html/template_2d.html @@ -13,6 +13,6 @@ plt.rgstrMouseRClickPlot( () => { console.log("right click"); } );

-Click for ascii data +@DATALINK@ From 755d394d92bd4df44c825e2381492e03f62cd030 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 12 Jul 2026 13:06:44 +0200 Subject: [PATCH 10/20] Add logging output for the diff processing --- tools/Python/mctest/mcviewtest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/Python/mctest/mcviewtest.py b/tools/Python/mctest/mcviewtest.py index e65793e4d..377e881f7 100644 --- a/tools/Python/mctest/mcviewtest.py +++ b/tools/Python/mctest/mcviewtest.py @@ -93,7 +93,9 @@ def generate_diff_link(refcellobj, url, label): cmd = '%s "%s" "%s" --nobrowse -A "%s" -B "%s" --output "%s"' % ( diffplotter, test_abs, ref_abs, label, reflabel, outdir_abs) try: + logging.info('%s processing: \n%s vs \n%s' % (diffplotter, test_abs, ref_abs)) utils.run_subtool_noread(cmd, cwd=testdir, timeout=diffmax) + logging.info('done.\n\n') except Exception as e: print("generate_diff_link: %s" % str(e)) From 8d06e3af1c42093fc72598763457837fe60d7260 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 12 Jul 2026 13:35:27 +0200 Subject: [PATCH 11/20] Parallelize html-diff creation and add default to only "make diffs when output is actually different" --- tools/Python/mctest/mcviewtest.py | 110 +++++++++++++++++++++++------- 1 file changed, 84 insertions(+), 26 deletions(-) diff --git a/tools/Python/mctest/mcviewtest.py b/tools/Python/mctest/mcviewtest.py index 377e881f7..b1201734c 100644 --- a/tools/Python/mctest/mcviewtest.py +++ b/tools/Python/mctest/mcviewtest.py @@ -9,6 +9,7 @@ from os import walk import subprocess import shutil +import concurrent.futures import jinja2 ERROR_PERCENT_THRESSHOLD_ACCEPT = 20 @@ -31,9 +32,13 @@ def get_oldest_dir(directory_name): files.sort(key=lambda x:x[0]) return files[0][1] -def run_normal_mode(testdir, reflabel, nodiff=False, diffmax=300): +def run_normal_mode(testdir, reflabel, nodiff=False, diffmax=300, diffall=False, diffworkers=4): ''' load test data and print to html label ''' + # jobs collected during row-building, run in parallel afterwards (see + # plan_diff_link() below for why this is a two-phase process) + pending_diff_jobs = [] + def get_col_header(label, meta): try: return "
".join((label + " - " + meta.get("ncount", ""), meta.get("hostname", ""), "CPU: " + meta.get("cpu_type", ""), "GPU: " + meta.get("gpu_type", ""), meta.get("date", ""))) @@ -64,13 +69,24 @@ def get_data_url(cellobj): label = label[len(label) - 3] return label + "/" + cellobj["instrname"] + "/" + str(cellobj["testnb"]) + "/" - def generate_diff_link(refcellobj, url, label): - ''' Generates (or reuses a cached) mcplotdiff-html comparison between - this cell's own test-output directory and the reference column's - corresponding directory, returning a relative URL to the diff - overview's index.html, or None if a diff isn't available/possible - (missing/incomplete data on either side, disabled via --nodiff, - or the plotter itself failing). ''' + def plan_diff_link(refcellobj, url, label, row, col_idx): + ''' Decides whether a diff against the reference column is + applicable, and if so either: + - returns the link immediately, if a cached diff already + exists on disk, or + - registers a pending job to be run later (see the parallel + execution pass after all rows have been built, below), and + optimistically returns the link the job is expected to + produce. If the job later fails, the corresponding cell in + `row` is patched to drop the link (see the execution pass). + + This is deliberately NOT where the mcplotdiff-html subprocess is + actually run: with ~300+ rows x multiple columns, running these + one at a time while building the table serializes what is an + embarrassingly parallel, purely I/O+CPU bound batch of + independent jobs (disjoint output directories, no shared + state). Collecting them here and running them via a bounded + thread pool afterwards is a straightforward, safe win. ''' if nodiff: return None if refcellobj is None or refcellobj.get("testval") is None: @@ -87,23 +103,25 @@ def generate_diff_link(refcellobj, url, label): outdir_rel = join(url, "diff_vs_%s" % reflabel) outdir_abs = join(testdir, outdir_rel) index_abs = join(outdir_abs, "index.html") - - if not os.path.isfile(index_abs): - diffplotter = mccode_config.configuration["MCPLOT"].split('-')[0] + "diff-html" - cmd = '%s "%s" "%s" --nobrowse -A "%s" -B "%s" --output "%s"' % ( - diffplotter, test_abs, ref_abs, label, reflabel, outdir_abs) - try: - logging.info('%s processing: \n%s vs \n%s' % (diffplotter, test_abs, ref_abs)) - utils.run_subtool_noread(cmd, cwd=testdir, timeout=diffmax) - logging.info('done.\n\n') - except Exception as e: - print("generate_diff_link: %s" % str(e)) + link = (outdir_rel + "/index.html").replace(os.sep, '/').replace('//', '/') if os.path.isfile(index_abs): - return (outdir_rel + "/index.html").replace(os.sep, '/').replace("//", "/") - return None - - def get_cell_tuple(cellobj, refval=None, refcellobj=None): + # already cached from a previous run, nothing to do + return link + + diffplotter = mccode_config.configuration["MCPLOT"].split('-')[0] + "diff-html" + cmd = '%s "%s" "%s" --nobrowse -A "%s" -B "%s" --output "%s"' % ( + diffplotter, test_abs, ref_abs, label, reflabel, outdir_abs) + + pending_diff_jobs.append({ + 'cmd': cmd, + 'index_abs': index_abs, + 'row': row, + 'col_idx': col_idx, + }) + return link + + def get_cell_tuple(cellobj, refval=None, refcellobj=None, row=None, col_idx=None): ''' set up and format cell data ''' state = None compiletime = None @@ -191,7 +209,9 @@ def get_cell_tuple(cellobj, refval=None, refcellobj=None): else: refp = "%2.f" % refp + "%" - diffurl = generate_diff_link(refcellobj, url, label) + diffurl = None + if diffall or state == 2: + diffurl = plan_diff_link(refcellobj, url, label, row, col_idx) return (state, compiletime, runtime, testval, refp, url, display, displayurl, diffurl) @@ -246,7 +266,8 @@ def iterate_obj_to_populate_rows(iterobj, otherobjs, rows, ncols, use_iterobj_re # fallback "lead" column rather than refobj itself, in # the untested multi-column case below) refcellobj = refobj.get(key, None) - row.append(get_cell_tuple(o, targetval, refcellobj=refcellobj)) + col_idx = len(row) + row.append(get_cell_tuple(o, targetval, refcellobj=refcellobj, row=row, col_idx=col_idx)) # delete "used" cell keys if del_used_from_overobjs: @@ -302,6 +323,37 @@ def iterate_obj_to_populate_rows(iterobj, otherobjs, rows, ncols, use_iterobj_re leadcol = testobjs.pop(0) iterate_obj_to_populate_rows(leadcol, testobjs, rows, ncols=numcols, use_iterobj_refvalue=False) + # Run all collected mcplotdiff-html jobs in parallel now that every row + # has been built (see plan_diff_link() above). Each job's cell already + # holds the *expected* link optimistically; if the job fails or times + # out, patch that specific cell back to drop the link rather than + # leaving a dead one in the rendered report. + if pending_diff_jobs: + logging.info("Running %d mcplotdiff-html comparison(s) (up to %d in parallel)..." + % (len(pending_diff_jobs), diffworkers)) + + def _run_diff_job(job): + try: + utils.run_subtool_noread(job['cmd'], cwd=testdir, timeout=diffmax) + except Exception as e: + logging.info("diff job failed: %s" % str(e)) + return job + + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=diffworkers) as executor: + futures = [executor.submit(_run_diff_job, job) for job in pending_diff_jobs] + for future in concurrent.futures.as_completed(futures): + job = future.result() + if not os.path.isfile(job['index_abs']): + # job failed/timed out: drop the optimistically-set link + row = job['row'] + col_idx = job['col_idx'] + cell = row[col_idx] + row[col_idx] = cell[:8] + (None,) + done += 1 + if done % 20 == 0 or done == len(pending_diff_jobs): + logging.info(" ...%d/%d diffs done" % (done, len(pending_diff_jobs))) + text = open(join(dirname(__file__), "main.template")).read() html = jinja2.Template(text).render(hrow=hrow, rows=rows, header=get_header_lst(refmeta)) @@ -365,7 +417,11 @@ def main(args): diffmax = 300 if args.diffmax: diffmax = int(args.diffmax[0]) - run_normal_mode(testdir, reflabel, nodiff=args.nodiff, diffmax=diffmax) + diffworkers = 4 + if args.diffworkers: + diffworkers = int(args.diffworkers[0]) + run_normal_mode(testdir, reflabel, nodiff=args.nodiff, diffmax=diffmax, + diffall=args.diffall, diffworkers=diffworkers) if not args.nobrowse: subprocess.Popen('%s %s' % (mccode_config.configuration['BROWSER'], os.path.join(testdir,os.path.basename(testdir) +'_output.html')), shell=True) @@ -380,7 +436,9 @@ def main(args): parser.add_argument('--verbose', action='store_true', help='output excessive information for debug purposes') parser.add_argument('--nobrowse', action='store_true', help='Do not spawn browser on exit') parser.add_argument('--nodiff', action='store_true', help='Do not generate mcplotdiff-html comparison cells against the reference column') + parser.add_argument('--diffall', action='store_true', help='Also generate diff cells for rows that already match the reference within tolerance (default: only for discrepancies)') parser.add_argument('--diffmax', nargs=1, help='Maximum time (s) allowed per mcplotdiff-html comparison (default 300s)') + parser.add_argument('--diffworkers', nargs=1, help='Number of mcplotdiff-html comparisons to run in parallel (default 4)') args = parser.parse_args() main(args) From 424b23d59e899ddb981f80f88e213e163ebad11c Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 12 Jul 2026 13:36:57 +0200 Subject: [PATCH 12/20] Parallelize 2D-colormap lookup --- tools/Python/mcplot/html/mcplotdiff.py | 57 ++++++++++++++++---------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/tools/Python/mcplot/html/mcplotdiff.py b/tools/Python/mcplot/html/mcplotdiff.py index 1dd6b379c..0ac5b1b1c 100644 --- a/tools/Python/mcplot/html/mcplotdiff.py +++ b/tools/Python/mcplot/html/mcplotdiff.py @@ -80,6 +80,28 @@ def lookup(cm, x): return cm[idx] +def lookup_vec(cm, x): + """ Vectorized equivalent of lookup(): x is an ndarray of values + (nominally in [0, 1]), returns an array of shape x.shape + (4,) + by indexing into the colour map cm for every element at once. + + This replaces what used to be a per-pixel Python-level call to + lookup() inside a nested "for i / for j" loop in + get_params_str_2D_diff() - for a modestly sized 2D monitor (e.g. + 100x100 = 10000 pixels), that's 10000 individual Python function + calls per image, x2 images (linear + log) x2 (data + colourbar + rendering is cheap by comparison, but the main image dominates), + which adds up badly across hundreds of monitors. Doing the same + clamp/round/index operations as whole-array numpy operations here + is the same math, just executed in C rather than the Python + interpreter loop. """ + x = np.nan_to_num(x, nan=0.5) + x = np.clip(x, 0.0, 1.0) + idx = np.round((len(cm) - 1) * x).astype(int) + idx = np.clip(idx, 0, len(cm) - 1) + return cm[idx] + + # --------------------------------------------------------------------------- # html / json generation (mirrors mcplot.py's get_html/get_json_*) # --------------------------------------------------------------------------- @@ -169,19 +191,15 @@ def get_params_str_2D_diff(data): signed (symlog-style) transform: sign(v) * log10(1 + |v|/eps), so that small differences remain visible while preserving sign. """ vals = np.array(data.zvals, dtype=float) - dims = np.shape(vals) cm = get_cm_diverging() # --- linear diverging image --- maxabs = float(np.max(np.abs(vals))) if vals.size else 0.0 - img = np.zeros((dims[0], dims[1], 4)) - for i in range(dims[0]): - for j in range(dims[1]): - if maxabs > 0: - x = (vals[i, j] / maxabs + 1.0) / 2.0 - else: - x = 0.5 - img[i, j, :] = lookup(cm, x) + if maxabs > 0: + x = (vals / maxabs + 1.0) / 2.0 + else: + x = np.full(vals.shape, 0.5) + img = lookup_vec(cm, x) encoded_2d_data = _encode_png(img) # --- signed-log ("symlog") diverging image --- @@ -193,22 +211,19 @@ def get_params_str_2D_diff(data): slog = vals maxslog = 0.0 - img_log = np.zeros((dims[0], dims[1], 4)) - for i in range(dims[0]): - for j in range(dims[1]): - if maxslog > 0: - x = (slog[i, j] / maxslog + 1.0) / 2.0 - else: - x = 0.5 - img_log[i, j, :] = lookup(cm, x) + if maxslog > 0: + xlog = (slog / maxslog + 1.0) / 2.0 + else: + xlog = np.full(vals.shape, 0.5) + img_log = lookup_vec(cm, xlog) encoded_2d_data_log = _encode_png(img_log) # --- colour bars --- def make_colorbar(cm): - img = np.zeros((256, 1, 4)) - for i in range(256): - img[255 - i, 0] = lookup(cm, i / 255) - return _encode_png(img) + # equivalent to the old "for i in range(256): img[255-i,0] = lookup(cm, i/255)": + # colors[i] = lookup(cm, i/255); reversing gives img[r] = colors[255-r] + colors = lookup_vec(cm, np.arange(256) / 255.0) + return _encode_png(colors[::-1].reshape(256, 1, 4)) encoded_cb = make_colorbar(cm) encoded_cb_log = make_colorbar(cm) From bef4adabbb98b032d39df19670ad743351b535fd Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 12 Jul 2026 13:39:09 +0200 Subject: [PATCH 13/20] Add


also in this type of cell --- tools/Python/mctest/main.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Python/mctest/main.template b/tools/Python/mctest/main.template index 9fb7cb22c..e22bfaa22 100644 --- a/tools/Python/mctest/main.template +++ b/tools/Python/mctest/main.template @@ -59,7 +59,7 @@ a:visited { color: lightgrey; background-color: transparent; text-decoration: no
{{ c.6 }}
PLOTS: [Interactive HTML] [PDF overview] {%- if c.8 %} -
DIFF: [vs ref] +

DIFF: [vs ref] {%- endif %} {%- elif c.0 == 3 %} From 31f0c312ad44f92cbccd6e8b048773c29793764b Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 12 Jul 2026 13:46:57 +0200 Subject: [PATCH 14/20] Parallelize to full machine by default --- tools/Python/mctest/mcviewtest.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tools/Python/mctest/mcviewtest.py b/tools/Python/mctest/mcviewtest.py index b1201734c..5a6d8e3ba 100644 --- a/tools/Python/mctest/mcviewtest.py +++ b/tools/Python/mctest/mcviewtest.py @@ -32,9 +32,24 @@ def get_oldest_dir(directory_name): files.sort(key=lambda x:x[0]) return files[0][1] -def run_normal_mode(testdir, reflabel, nodiff=False, diffmax=300, diffall=False, diffworkers=4): +def get_default_diffworkers(): + ''' Number of mcplotdiff-html comparisons to run in parallel by default: + the number of processors available to this process. Prefers + os.sched_getaffinity(0) (Linux only) over os.cpu_count(), since the + former respects cgroup/taskset CPU restrictions (e.g. inside a + container or batch job allocation) that the latter ignores. ''' + try: + return len(os.sched_getaffinity(0)) + except AttributeError: + # os.sched_getaffinity doesn't exist on macOS/Windows + return os.cpu_count() or 4 + +def run_normal_mode(testdir, reflabel, nodiff=False, diffmax=300, diffall=True, diffworkers=None): ''' load test data and print to html label ''' + if diffworkers is None: + diffworkers = get_default_diffworkers() + # jobs collected during row-building, run in parallel afterwards (see # plan_diff_link() below for why this is a two-phase process) pending_diff_jobs = [] @@ -417,11 +432,12 @@ def main(args): diffmax = 300 if args.diffmax: diffmax = int(args.diffmax[0]) - diffworkers = 4 + diffworkers = get_default_diffworkers() if args.diffworkers: diffworkers = int(args.diffworkers[0]) + diffall = not args.diff_errors_only run_normal_mode(testdir, reflabel, nodiff=args.nodiff, diffmax=diffmax, - diffall=args.diffall, diffworkers=diffworkers) + diffall=diffall, diffworkers=diffworkers) if not args.nobrowse: subprocess.Popen('%s %s' % (mccode_config.configuration['BROWSER'], os.path.join(testdir,os.path.basename(testdir) +'_output.html')), shell=True) @@ -436,9 +452,9 @@ def main(args): parser.add_argument('--verbose', action='store_true', help='output excessive information for debug purposes') parser.add_argument('--nobrowse', action='store_true', help='Do not spawn browser on exit') parser.add_argument('--nodiff', action='store_true', help='Do not generate mcplotdiff-html comparison cells against the reference column') - parser.add_argument('--diffall', action='store_true', help='Also generate diff cells for rows that already match the reference within tolerance (default: only for discrepancies)') + parser.add_argument('--diff-errors-only', dest='diff_errors_only', action='store_true', help='Only generate diff cells for rows that show a discrepancy against the reference (default: diff every row with valid data)') parser.add_argument('--diffmax', nargs=1, help='Maximum time (s) allowed per mcplotdiff-html comparison (default 300s)') - parser.add_argument('--diffworkers', nargs=1, help='Number of mcplotdiff-html comparisons to run in parallel (default 4)') + parser.add_argument('--diffworkers', nargs=1, help='Number of mcplotdiff-html comparisons to run in parallel (default: number of available processors)') args = parser.parse_args() main(args) From 61bc5868abe83546482f24d4afd3bbf4697084eb Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 12 Jul 2026 13:52:38 +0200 Subject: [PATCH 15/20] Speed up execution of mcplot-matplotlib (especially for big 2D matrices) --- tools/Python/mcplot/matplotlib/plotfuncs.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tools/Python/mcplot/matplotlib/plotfuncs.py b/tools/Python/mcplot/matplotlib/plotfuncs.py index 5b70bb220..53ab8bb45 100644 --- a/tools/Python/mcplot/matplotlib/plotfuncs.py +++ b/tools/Python/mcplot/matplotlib/plotfuncs.py @@ -211,7 +211,16 @@ def plot_single_data(node, i, n, log): #ax = Axes3D(pylab.gcf()) #pylab.contour(x,y,zvals) #ax.plot_surface(x,y,zvals) - pylab.pcolor(x,y,zvals) + # pcolormesh (QuadMesh) instead of pcolor (PolyCollection): pcolor + # builds one vector polygon per grid cell, which is dramatically + # slower to construct and render for vector output formats + # (mctest.py generates a PDF overview per test via --format=pdf), + # and produces much larger files. pcolormesh is a same-signature + # drop-in given our regularly-gridded x/y (from linspace), and + # rasterized=True has vector backends embed it as a single bitmap + # rather than thousands of individual polygons; PNG/interactive + # output is raster either way and unaffected. + pylab.pcolormesh(x, y, zvals, shading='auto', rasterized=True) pylab.colorbar() pylab.xlabel(data.xlabel, fontsize=fontsize, fontweight='bold') From 5d4dc160933af771876c59123ea5712924d3c299 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 12 Jul 2026 13:59:06 +0200 Subject: [PATCH 16/20] Update .md tool docs --- tools/Python/mctest/mcviewtest.md.in | 43 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/Python/mctest/mcviewtest.md.in b/tools/Python/mctest/mcviewtest.md.in index c9fe61781..6b911bf6b 100644 --- a/tools/Python/mctest/mcviewtest.md.in +++ b/tools/Python/mctest/mcviewtest.md.in @@ -9,13 +9,26 @@ the @MCCODE_PREFIX@test toool, testing the @MCCODE_NAME@ installation # SYNOPSIS -**@MCCODE_PREFIX@viewtest** [-h] [--reflabel [REFLABEL]] [--testroot [TESTROOT]] [--verbose] [--nobrowse] [testdir] +**@MCCODE_PREFIX@viewtest** [-h] [--reflabel [REFLABEL]] [--testroot [TESTROOT]] [--verbose] [--nobrowse] [--nodiff] [--diff-errors-only] [--diffmax DIFFMAX] [--diffworkers DIFFWORKERS] [testdir] # DESCRIPTION The tool **@MCCODE_PREFIX@viewtest** is a program visualise testesults from the current @MCCODE_NAME@ installation. +When more than one test run is present (a reference run and one or more +other runs, e.g. different platforms, GPU vs. CPU, or different @MCCODE_NAME@ +versions/branches), taking the first/reference test run's results as the +'reference' column, **@MCCODE_PREFIX@viewtest** also generates a +**@MCCODE_PREFIX@plotdiff-html** comparison between each other column's +monitor output and the reference's, and adds a `DIFF: [vs ref]` link to the +test report next to each such row. These comparisons are cached on disk (in +a `diff_vs_` subfolder next to the test's own output), so re-running +**@MCCODE_PREFIX@viewtest** over the same test data does not regenerate +existing comparisons, and they are generated in parallel (see +`--diffworkers` below) to keep this reasonably fast even for large test +suites (a few hundred instruments). + # OPTIONS **-h, --help** @@ -35,6 +48,26 @@ from the current @MCCODE_NAME@ installation. **--nobrowse** Do not spawn browser on exit +**--nodiff** + Do not generate **@MCCODE_PREFIX@plotdiff-html** comparison cells + against the reference column at all + +**--diff-errors-only** + Only generate diff cells for rows that show a discrepancy against + the reference (i.e. skip rows that already match the reference within + tolerance). Default is to diff every row that has valid data on both + sides, since these comparisons are cached and run in parallel; use this + switch to further cut down the number of comparisons generated, e.g. on + a very large test suite + +**--diffmax DIFFMAX** + Maximum time (s) allowed per **@MCCODE_PREFIX@plotdiff-html** + comparison (default 300s) + +**--diffworkers DIFFWORKERS** + Number of **@MCCODE_PREFIX@plotdiff-html** comparisons to run in + parallel (default: number of processors available to this process) + **testdir** Indicate location of result directories (PWD is used if unset) @@ -50,10 +83,16 @@ http://www.@FLAVOR@.org `@MCCODE_PREFIX@viewtest` +View test results, but skip generating diff cells against the reference column entirely (fastest, no comparisons at all) +: `@MCCODE_PREFIX@viewtest --nodiff` + +View test results, only diffing rows that show a discrepancy, using 8 parallel workers +: `@MCCODE_PREFIX@viewtest --diff-errors-only --diffworkers 8` + # AUTHORS @MCCODE_NAME@ Team (@FLAVOR@.org) # SEE ALSO -@FLAVOR@(1), @MCCODE_PREFIX@test(1), @MCCODE_PREFIX@doc(1), @MCCODE_PREFIX@plot(1), @MCCODE_PREFIX@run(1), @MCCODE_PREFIX@gui(1), @MCCODE_PREFIX@display(1) +@FLAVOR@(1), @MCCODE_PREFIX@test(1), @MCCODE_PREFIX@doc(1), @MCCODE_PREFIX@plot(1), @MCCODE_PREFIX@plotdiff-html(1), @MCCODE_PREFIX@run(1), @MCCODE_PREFIX@gui(1), @MCCODE_PREFIX@display(1) From b479a612789bcb3b864cb8a4e28e3341ccb669a0 Mon Sep 17 00:00:00 2001 From: Peter Willendrup Date: Sun, 12 Jul 2026 14:20:31 +0200 Subject: [PATCH 17/20] Add pool-parallelisation in mcplot-html --- tools/Python/mcplot/html/mcplot.py | 66 +++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/tools/Python/mcplot/html/mcplot.py b/tools/Python/mcplot/html/mcplot.py index 29fd40a3f..0f453a705 100644 --- a/tools/Python/mcplot/html/mcplot.py +++ b/tools/Python/mcplot/html/mcplot.py @@ -10,6 +10,7 @@ import base64 import json import subprocess +import concurrent.futures sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..')) @@ -23,6 +24,18 @@ WIDTH = 1024 HEIGHT = 768 +def get_default_workers(): + ''' Number of per-monitor plot jobs to run in parallel by default: the + number of processors available to this process. Prefers + os.sched_getaffinity(0) (Linux only) over os.cpu_count(), since the + former respects cgroup/taskset CPU restrictions (e.g. inside a + container or batch job allocation) that the latter ignores. ''' + try: + return len(os.sched_getaffinity(0)) + except AttributeError: + # os.sched_getaffinity doesn't exist on macOS/Windows + return os.cpu_count() or 4 + def file_base_name(file_name): if '.' in file_name: separator_index = file_name.index('.') @@ -35,14 +48,14 @@ def path_base_name(path): file_name = os.path.basename(path) return file_base_name(file_name) -def get_html(template_name, params, simfile): +def get_html(template_name, params, simfile, use_logscale): ''' ''' text = open(os.path.join(os.path.dirname(__file__),template_name)).read() text = text.replace("@PARAMS@", params) text = text.replace("@DATAFILE@", simfile) text = text.replace("@DATALINK@", "Click for ascii data") - logscalestr = "true" if logscale==True else "false" + logscalestr = "true" if use_logscale else "false" text = text.replace("@LOGSCALE@", logscalestr) text = text.replace("@LIBPATH@", libpath) return text @@ -221,27 +234,42 @@ def browse(html_filepath): def plotfunc(node, filename=None): ''' plot a plotnode to a html file as an svg-plot using plotfuncs.js and d3.v4.min.js ''' - global logscale if isinstance(filename, list): filename = filename[0] # get data and set file path if type(node) is PNSingle: data = node.getdata_idx(0) - return plotfunc_single(data, filename) + return plotfunc_single(data, filename, use_logscale=logscale) elif type(node) is PNMultiple: - data = node.getdata_lst() - data_lst = data - - f = [] - f_log = [] - count = 0 + data_lst = node.getdata_lst() + + # Each monitor's linear and log-scale pages are fully independent + # (own numpy arrays, own PIL image, own output file - no shared + # matplotlib-style global figure/axes state the way the + # mcplot-matplotlib tool has), so unlike that tool this is safe to + # parallelize directly: build the full list of (monitor, use_log) + # jobs up front and run them through a bounded thread pool, instead + # of the previous sequential loop that flipped a shared global + # `logscale` flag between calls (unsafe to parallelize as-is, since + # concurrent threads would race on that flag). + jobs = [] for dat in data_lst: - logscale = False - f.append(plotfunc_single(dat)) - logscale = True - f_log.append(plotfunc_single(dat)) - count += 1 + jobs.append((dat, False)) + jobs.append((dat, True)) + + def _run_job(job): + dat, use_log = job + return plotfunc_single(dat, None, use_logscale=use_log) + + max_workers = get_default_workers() + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + # executor.map preserves input order regardless of completion + # order, so results[0::2]/[1::2] line up with data_lst as before + results = list(executor.map(_run_job, jobs)) + + f = results[0::2] + f_log = results[1::2] # now create an overview HTML (index.html with