diff --git a/TrimSP.html b/TrimSP.html index 3bd7cc3..8229998 100644 --- a/TrimSP.html +++ b/TrimSP.html @@ -97,7 +97,7 @@
| Scan parameter + | Scan: | ++ Threads: + + | ||
|
diff --git a/TrimSPelec.js b/TrimSPelec.js
index c524aa5..506403e 100644
--- a/TrimSPelec.js
+++ b/TrimSPelec.js
@@ -4,7 +4,8 @@
const Plotly = require('plotly.js-dist');
const ipcRenderer = require('electron').ipcRenderer;
const fs = require('fs');
-const exec = require('child_process').execSync;
+const { execSync, execFile } = require('child_process');
+const os = require('os');
const path = require('path');
function getFiles(dir, filelist){
@@ -20,8 +21,27 @@ function getFiles(dir, filelist){
return fileList;
}
-function execute(command) {
- exec(command, {stdio: 'inherit'});
+// Run one TrimSP binary asynchronously in a given working directory so the
+// scan loop can keep several local jobs in flight at once.
+function executeFileAsync(binary, args, workdir) {
+ return new Promise((resolve, reject) => {
+ execFile(binary, args, {cwd: workdir}, (err) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve();
+ });
+ });
+}
+
+// Use the available CPU parallelism as the default worker-pool size for local
+// scan jobs. The caller still caps this against the number of scan points.
+function getParallelJobCount() {
+ if (typeof os.availableParallelism === 'function') {
+ return Math.max(1, os.availableParallelism());
+ }
+ return Math.max(1, os.cpus().length);
}
function checkDir(directory) {
@@ -145,7 +165,7 @@ function resolveConfigWorkPath(filename, content) {
return null;
}
if (configuredWorkPath.startsWith('./')) {
- return path.resolve(path.dirname(filename), configuredWorkPath);
+ return path.resolve(configuredWorkPath);
}
if (path.isAbsolute(configuredWorkPath)) {
return configuredWorkPath;
diff --git a/TrimSPlib.js b/TrimSPlib.js
index 0a6e2d8..2873190 100644
--- a/TrimSPlib.js
+++ b/TrimSPlib.js
@@ -1,5 +1,9 @@
// This file contains function that are generic Javascript for TrimSP
+const electronExecSync = (typeof module !== 'undefined' && module.exports)
+ ? require('child_process').execSync
+ : null;
+
// This is an object that contains all GUI ids and values
var All = new Object();
@@ -3351,6 +3355,19 @@ function adjust_scans()
var scanSeq = document.getElementById("scanSeq").checked;
var sender = document.getElementById("scanSeq");
var scanType = document.getElementById("scanType").value;
+ var scanThreads = document.getElementById("scanThreads");
+
+ // Default to half the available local worker slots, rounded down, while
+ // keeping at least one process. Web mode keeps the default at 1 because
+ // the CGI backend still executes scan points sequentially.
+ if (scanThreads && (scanThreads.value === "" || Number(scanThreads.value) < 1)) {
+ let availableThreads = 1;
+ if (!amIWeb() && typeof getParallelJobCount === 'function') {
+ availableThreads = getParallelJobCount();
+ }
+ scanThreads.value = Math.max(1, Math.floor(availableThreads / 2));
+ }
+
// consider only if scans checkbox is checked
if (scanSeq) {
// Which type of scan
@@ -3453,7 +3470,7 @@ function prep_cfg(toggle) {
valtmp = 1*(document.getElementById(strtmp).checked);
All[strtmp]=valtmp;
ScanSec = ScanSec + strtmp +"="+ valtmp+"\n";
- let parScan = ["comboScan","scanType","scanFrom","scanStep","scanTo","scanList","scanListdz"];
+ let parScan = ["comboScan","scanType","scanThreads","scanFrom","scanStep","scanTo","scanList","scanListdz"];
for (key of parScan) {
All[key]= document.getElementById(key).value;
ScanSec = ScanSec + key + "=" + All[key] + "\n";
@@ -3629,6 +3646,77 @@ function yieldToRenderer() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
+// Format the live profile legend from the scan parameter currently being run.
+function formatScanLegend(scanName, scanValue) {
+ if (scanName === "E") {
+ return scanName + '=' + Number(scanValue) / 1000 + 'keV';
+ }
+ return scanName + '=' + scanValue;
+}
+
+// Merge finished per-run *.seq files into one in-memory table ordered by the
+// original scan index, not by worker completion order.
+function buildCombinedSequenceLines(results) {
+ let combinedSeqLines = [];
+ const completedResults = results.filter(Boolean).sort((a, b) => a.index - b.index);
+
+ for (const result of completedResults) {
+ if (combinedSeqLines.length === 0) {
+ combinedSeqLines = result.seqLines.slice();
+ } else {
+ combinedSeqLines = combinedSeqLines.concat(result.seqLines.slice(1));
+ }
+ }
+
+ return combinedSeqLines;
+}
+
+// Rebuild the live profile and fraction plots from all completed scan jobs.
+// This keeps the display responsive while preserving deterministic ordering.
+function renderCompletedScanResults(results, allValues) {
+ const completedResults = results.filter(Boolean).sort((a, b) => a.index - b.index);
+ if (completedResults.length === 0) {
+ return [];
+ }
+
+ Plotly.purge("plotRge");
+ for (const result of completedResults) {
+ Plot_xy("plotRge", result.depth, result.nmuons,
+ ['Depth (nm)', 'Stopped muons (%/nm)', result.legend]);
+ }
+
+ const combinedSeqLines = buildCombinedSequenceLines(results);
+ const [combinedCols, combinedData] = parseDatContent(combinedSeqLines.join('\n'));
+ const seqInfo = getSequenceColumnInfo(combinedCols, allValues["numLayer"]);
+ const energy = combinedData[seqInfo.indexByName['Energy']];
+ const ntot = combinedData[seqInfo.indexByName['ntot']];
+ const plotSeries = [
+ {
+ index: seqInfo.indexByName['backsc'],
+ label: combinedCols[seqInfo.indexByName['backsc']],
+ },
+ ...seqInfo.layerIndices.map((index, offset) => {
+ let LComp = "L" + (offset + 1) + "Comp";
+ return {
+ index: index,
+ label: allValues[LComp] || seqInfo.layerHeaders[offset],
+ };
+ }),
+ ];
+
+ Plotly.purge("plotFrac");
+ for (const series of plotSeries) {
+ let idata = combinedData[series.index];
+ for (let i = 0; i < idata.length; i++) {
+ idata[i] = 100 * idata[i] / ntot[i];
+ }
+ Plot_xy("plotFrac", energy, combinedData[series.index],
+ ['E (keV)', 'Fraction of muons (%)', series.label]);
+ }
+
+ return combinedSeqLines;
+}
+
async function startSequence() {
let cmd = '';
let trimBin = All['trimPath']+"/trimspNL";
@@ -3693,43 +3781,46 @@ async function startSequence() {
SValues.push(parseInt(All["valEnergy"]));
}
- // Now start the actual simulation
- //setInterval(() => {
- let iScan=0;
- for (var SValue of SValues) {
- // Update value in GUI
- document.getElementById(ScanAttrib).value = SValue;
- All[ScanAttrib]=SValue;
- if ( All["SdzFlag"] == 1) {
- if (All["comboScan"]=="Energy") {
- document.getElementById(ScanAttrib).value = SdzValues[iScan];
- } else if (All["comboScan"]=="") {
- document.getElementById(ScanAttrib).value = SdzValues[iScan];
- }
- }
- // Update GUI progress bar
- Progress=Math.round(Progress+90/SValues.length);
- document.getElementById("pBar").style.width = Progress + "%";
- document.getElementById("pBar").innerHTML = Progress + "%";
- updateStatusBar({ run: `Running ${iScan + 1}/${SValues.length}: ${ScanName}=${SValue}` });
- await yieldToRenderer();
-
- // Single run: Start
- let inputFileContent=CreateInpFile();
- if (inputFileContent=="ERROR") {return(0);}
- let FILENAME=All["fileNamePrefix"]+"_"+ScanName+SValue;
- writeAsciiFile(All["workPath"]+"/"+FILENAME+".inp",inputFileContent);
- if (!webOrApp) {
- // Prepare command and execute
- cmd = "cd " + All["workPath"];
- cmd += ";" + trimBin + ' ' + FILENAME;
- execute(cmd);
- }
- // Single run: End
+ // Freeze the initial GUI state and expand the scan into independent jobs.
+ // Each job gets its own input file name and a deterministic seed offset so
+ // the results are reproducible even when runs finish out of order.
+ const scanValues = SValues.slice();
+ const initialAll = Object.assign({}, All);
+ const initialSeed = parseInt(initialAll["ranSeed"], 10);
+ let jobs = [];
- // Read stopping profiels
- let [cols,data]= readDatFile(All["workPath"]+"/"+FILENAME+".rge");
- // convert depth to nm and normalize stopping profile
+ for (let iScan = 0; iScan < scanValues.length; iScan++) {
+ let SValue = scanValues[iScan];
+ let jobAll = Object.assign({}, initialAll);
+ jobAll[ScanAttrib] = SValue;
+ if (!Number.isNaN(initialSeed)) {
+ jobAll["ranSeed"] = String(initialSeed + iScan);
+ }
+ All = jobAll;
+ let inputFileContent = CreateInpFile();
+ if (inputFileContent=="ERROR") {
+ All = initialAll;
+ return(0);
+ }
+ let FILENAME = jobAll["fileNamePrefix"] + "_" + ScanName + SValue;
+ jobs.push({
+ index: iScan,
+ scanValue: SValue,
+ filename: FILENAME,
+ inputFileContent: inputFileContent,
+ legend: formatScanLegend(ScanName, SValue),
+ });
+ }
+ All = initialAll;
+
+ // Results are stored by original scan index. The plots and final merged
+ // sequence file are always rebuilt from this ordered array.
+ let results = new Array(jobs.length);
+
+ // Read back the outputs of one completed run and convert them into the
+ // normalized data structures used by the live plots.
+ async function collectRunResult(job) {
+ let [cols,data]= readDatFile(All["workPath"]+"/"+job.filename+".rge");
let depth=data[0];
let nmuons=data[1];
let dz = (depth[1]-depth[0])/10;
@@ -3738,58 +3829,94 @@ async function startSequence() {
depth[i]=depth[i]/10;
nmuons[i]=nmuons[i]/norm;
}
- Plot_xy("plotRge",depth,nmuons,['Depth (nm)','Stopped muons (%/nm)',ScanName+'='+Number(SValue)/1000+'keV']);
- // Read the sequence data
- let [cfort,cdata]= readDatFile(All["workPath"]+"/fort.33");
- let seqInfo = getSequenceColumnInfo(cfort, All["numLayer"]);
- let energy = cdata[seqInfo.indexByName['Energy']];
- let ntot = cdata[seqInfo.indexByName['ntot']];
- let plotSeries = [
- {
- index: seqInfo.indexByName['backsc'],
- label: cfort[seqInfo.indexByName['backsc']],
- },
- ...seqInfo.layerIndices.map((index, offset) => {
- let LComp = "L" + (offset + 1) + "Comp";
- return {
- index: index,
- label: All[LComp] || seqInfo.layerHeaders[offset],
- };
- }),
- ];
- // Now loop over all
- Plotly.purge("plotFrac")
- for (let series of plotSeries) {
- // Normalize
- let idata = cdata[series.index];
- for (let i=0; i | ||||