diff --git a/public/examples/ex8.json b/public/examples/ex8.json index 76ef05c35..569f2dcca 100644 --- a/public/examples/ex8.json +++ b/public/examples/ex8.json @@ -404,28 +404,6 @@ ], "detectorUuid": "116f105e-c987-4602-9913-8dd0ef2affaa", "trace": false - }, - { - "name": "Pr", - "type": "Output", - "uuid": "224449ff-fe6e-4fc9-822d-d0d7d45126f4", - "quantities": [ - { - "name": "fluxdiff", - "type": "Quantity", - "uuid": "5709cc76-2610-4cb6-b1cf-bd58d2f2c587", - "filter": "f96f7542-143f-4c2c-b7ca-8a121ea2f631", - "keyword": "KineticEnergySpectrum", - "histogramNBins": 100, - "histogramMin": 0, - "histogramMax": 200, - "histogramUnit": "MeV", - "histogramXScale": "none", - "histogramXBinScheme": "linear" - } - ], - "detectorUuid": "590c80b4-bb94-4c44-8a06-2eb58dcb1f37", - "trace": false } ], "filters": [ diff --git a/src/services/Geant4LocalWorkerSimulationService.ts b/src/services/Geant4LocalWorkerSimulationService.ts index 4f5ef220b..1228b5ce4 100644 --- a/src/services/Geant4LocalWorkerSimulationService.ts +++ b/src/services/Geant4LocalWorkerSimulationService.ts @@ -248,7 +248,10 @@ export default class Geant4LocalWorkerSimulationService implements SimulationSer // TODO: find a way to terminate & destroy the worker after fetching the files to free up memory // TODO: keeping in mind, that this method can be called multiple times, simultaneously - const parser = new Geant4ResultsFileParser(this.numPrimaries); + const parser = new Geant4ResultsFileParser( + this.numPrimaries, + this.inputFiles[jobId]['run.mac'] + ); const parsedContents = fileContents .map(file => (file ? parser.parseResultFile(file) : undefined)) diff --git a/src/services/Geant4ResultsFileParser.ts b/src/services/Geant4ResultsFileParser.ts index 4701a8479..b8b845fcb 100644 --- a/src/services/Geant4ResultsFileParser.ts +++ b/src/services/Geant4ResultsFileParser.ts @@ -8,17 +8,185 @@ interface ResultFileMetadata { scorerName: string; } +interface XYZ { + x: number; + y: number; + z: number; +} + +interface CYL { + z: number; + r: number; +} + +interface ScorerMetadata { + type: T; + translation: XYZ; + binsAlongAxis: T extends 'box' ? XYZ : CYL; + size: T extends 'box' ? XYZ : CYL; + start: T extends 'box' ? XYZ : CYL; + end: T extends 'box' ? XYZ : CYL; +} + +/** + * Default values for box detector + */ +function emptyBox(): ScorerMetadata<'box'> { + return { + type: 'box', + translation: { x: 0, y: 0, z: 0 }, + binsAlongAxis: { x: 1, y: 1, z: 1 }, + size: { x: 1, y: 1, z: 1 }, + start: { x: -1, y: -1, z: -1 }, + end: { x: 1, y: 1, z: 1 } + }; +} + +/** + * Default values for cylinder detector + */ +function emptyCylinder(): ScorerMetadata<'cylinder'> { + return { + type: 'cylinder', + translation: { x: 0, y: 0, z: 0 }, + binsAlongAxis: { z: 1, r: 1 }, + size: { z: 1, r: 1 }, + start: { z: -1, r: 0 }, + end: { z: 1, r: 1 } + }; +} + export class Geant4ResultsFileParser { numPrimaries: number; + scorersMetadata: { [key: string]: ScorerMetadata<'box'> | ScorerMetadata<'cylinder'> }; - constructor(numPrimaries: number) { + constructor(numPrimaries: number, macroFile: string) { this.numPrimaries = numPrimaries; + this.scorersMetadata = {}; + this.parseMacroFile(macroFile); + } + + /** + * Parses given macro file to find all information about scorers in the simulation. + * This is needed to calculate the axis ranges in cm (Geant4 provides only bin numbers). + * We can't get this information from project JSON, because YAPTIDE allows running directly from + * macro files that are pasted in - without project data. + */ + private parseMacroFile(macroFile: string) { + const lines = macroFile.split('\n'); + let meshName: string | undefined = undefined; + let createCmd: string; + + // iterate over macro commands top to bottom and store values for each scorer + // The structure of the declarations is as follows: + // /score/create/boxMesh name + // /some/command + // /some/command + // /some/command + // /score/close + // Lucky for us, every command is scoped (/score/create starts, /score/close ends) + // So we don't need to worry about mismatching configuration options with meshes + // We can just read the lines as they go + + for (const line of lines) { + // Command: + // /score/create/{boxMesh, cylinderMesh} + if (line.startsWith('/score/create')) { + [createCmd, meshName] = line.split(' '); + const meshType = createCmd.split('/').at(-1)!.slice(0, -4) as 'box' | 'cylinder'; + this.scorersMetadata[meshName] = meshType === 'box' ? emptyBox() : emptyCylinder(); + } + + // Command: + // /score/mesh/translate/xyz cm + // defaults to 0, 0, 0 + if (line.startsWith('/score/mesh/translate/xyz') && meshName) { + const [, x, y, z] = line.split(' '); + this.scorersMetadata[meshName].translation = { + x: parseFloat(x), + y: parseFloat(y), + z: parseFloat(z) + }; + } + + // Command: + // /score/mesh/boxSize cm + // uses half-widths! + // defaults to 1, 1, 1 + if (line.startsWith('/score/mesh/boxSize') && meshName) { + const [, x, y, z] = line.split(' '); + this.scorersMetadata[meshName].size = { + x: parseFloat(x), + y: parseFloat(y), + z: parseFloat(z) + }; + } + + // Command: + // /score/mesh/cylinderSize cm + // uses half-widths for depth! + // defaults to 1, 1 + if (line.startsWith('/score/mesh/cylinderSize') && meshName) { + const [, r, z] = line.split(' '); + this.scorersMetadata[meshName].size = { z: parseFloat(z), r: parseFloat(r) }; + } + + // Command: + // /score/mesh/nBin + // defaults to 1, 1, 1 + if ( + line.startsWith('/score/mesh/nBin') && + meshName && + this.scorersMetadata[meshName].type === 'box' + ) { + const [, x, y, z] = line.split(' '); + this.scorersMetadata[meshName].binsAlongAxis = { + x: parseInt(x), + y: parseInt(y), + z: parseInt(z) + }; + } + + // Command: + // /score/mesh/nBin + // defaults to 1, 1 + if ( + line.startsWith('/score/mesh/nBin') && + meshName && + this.scorersMetadata[meshName].type === 'cylinder' + ) { + const [, r, z] = line.split(' '); + this.scorersMetadata[meshName].binsAlongAxis = { + r: parseInt(r), + z: parseInt(z) + }; + } + } + + for (let metadata of Object.values(this.scorersMetadata)) { + if (metadata.type === 'cylinder') { + metadata.start = { z: metadata.translation.z - metadata.size.z, r: 0 }; + metadata.end = { z: metadata.translation.z + metadata.size.z, r: metadata.size.r }; + } else if (metadata.type === 'box') { + metadata.start = { + x: metadata.translation.x - metadata.size.x, + y: metadata.translation.y - metadata.size.y, + z: metadata.translation.z - metadata.size.z + }; + + metadata.end = { + x: metadata.translation.x + metadata.size.x, + y: metadata.translation.y + metadata.size.y, + z: metadata.translation.z + metadata.size.z + }; + } + } } public parseResultFile( content: string ): { metadata: ResultFileMetadata; results: Page1D | Page2D } | undefined { - if (content == '') { + if (content === '') { return undefined; } @@ -69,8 +237,12 @@ export class Geant4ResultsFileParser { const meshName = header[0].split(' ').at(-1)!; const scorerName = header[1].split(' ').at(-1)!; - const xDataColumn = dimensionMask.findIndex(n => n); - const xDataName = header[2].split(',')[xDataColumn]; + columns = this.getAxisValuesFromBins(meshName, dimensionMask, columns); + + const columnNames = Geant4ResultsFileParser.getColumnNames(header); + + const dataColumn = dimensionMask.findIndex(n => n); + const dataName = columnNames[dataColumn]; return { metadata: { @@ -82,13 +254,13 @@ export class Geant4ResultsFileParser { name: scorerName, dimensions: 1, axisDim1: { - name: xDataName, - unit: '', - values: columns[xDataColumn].map(v => parseFloat(v)) + name: dataName, + unit: 'cm', + values: columns[dataColumn].map(v => parseFloat(v)) }, data: { name: scorerName, - unit: this.getUnit(header[2], true), + unit: Geant4ResultsFileParser.getUnit(header[2], true), values: columns[3].map(v => parseFloat(v) / this.numPrimaries) } } @@ -103,38 +275,54 @@ export class Geant4ResultsFileParser { const meshName = header[0].split(' ').at(-1)!; const scorerName = header[1].split(' ').at(-1)!; - // there are 2 columns, and they store all combinations of bin numbers - // 1 1 - // 1 2 - // 1 3 - // 2 1 - // 2 2 - // 2 3 - // we want only unique, ascending values: (y) 1 2 / (x) 1 2 3 - - // x column comes after y to match what JsRootGraph2D expects when accessing data as consecutive 1d array - // because of that, x = findLastIndex, y = findIndex - const xDataColumn = dimensionMask.findLastIndex(n => n); - const xDataName = header[2].split(',')[xDataColumn]; - - // store only unique, ascending values - const xDataValues = [parseFloat(columns[xDataColumn][0])]; - columns[xDataColumn] - .map(v => parseFloat(v)) - .forEach(v => { - if (v > xDataValues.at(-1)!) xDataValues.push(v); - }); + let columnNames = Geant4ResultsFileParser.getColumnNames(header); + + // column a comes after b to match what JsRootGraph2D expects when accessing data as consecutive 1d array + // because of that, a = findLastIndex, b = findIndex + let aDataColumn = dimensionMask.findLastIndex(n => n); + let bDataColumn = dimensionMask.findIndex(n => n); + let aDataName = columnNames[aDataColumn]; + let bDataName = columnNames[bDataColumn]; - const yDataColumn = dimensionMask.findIndex(n => n); - const yDataName = header[2].split(',')[yDataColumn]; + let columnsWithAxisValues = this.getAxisValuesFromBins(meshName, dimensionMask, columns); - // store only unique, ascending values - const yDataValues = [parseFloat(columns[yDataColumn][0])]; - columns[yDataColumn] + // For the plots to look similar to other simulators, we need to swap the axes and the values associated + // changing + // 0 0 0 1 1 1 + // 0 1 2 0 1 2 + // into + // 0 0 1 1 2 2 + // 0 1 0 1 0 1 + const aBins = parseInt(columns[aDataColumn].at(-1)!) + 1; + const bBins = parseInt(columns[bDataColumn].at(-1)!) + 1; + columnsWithAxisValues = Geant4ResultsFileParser.swapColumns( + columnsWithAxisValues, + bDataColumn, + aDataColumn, + bBins, + aBins + ); + [aDataName, bDataName] = [bDataName, aDataName]; + + let aDataValuesAll = columnsWithAxisValues[aDataColumn] + .map(v => parseFloat(v)) + .toSorted((a, b) => a - b); + let aDataValues = [aDataValuesAll[0]]; + aDataValuesAll.forEach(v => { + if (v > aDataValues.at(-1)!) { + aDataValues.push(v); + } + }); + + let bDataValuesAll = columnsWithAxisValues[bDataColumn] .map(v => parseFloat(v)) - .forEach(v => { - if (v > yDataValues.at(-1)!) yDataValues.push(v); - }); + .toSorted((a, b) => a - b); + let bDataValues = [bDataValuesAll[0]]; + bDataValuesAll.forEach(v => { + if (v > bDataValues.at(-1)!) { + bDataValues.push(v); + } + }); return { metadata: { @@ -146,25 +334,168 @@ export class Geant4ResultsFileParser { name: scorerName, dimensions: 2, axisDim1: { - name: xDataName, - unit: '', - values: xDataValues + name: aDataName, + unit: 'cm', + values: aDataValues }, axisDim2: { - name: yDataName, - unit: '', - values: yDataValues + name: bDataName, + unit: 'cm', + values: bDataValues }, data: { name: scorerName, - unit: this.getUnit(header[2], true), - values: columns[3].map(v => parseFloat(v) / this.numPrimaries) + unit: Geant4ResultsFileParser.getUnit(header[2], true), + values: columnsWithAxisValues[3].map(v => parseFloat(v) / this.numPrimaries) } } }; } - private getUnit(header: string, perPrimary: boolean) { + /** + * Replaces bin values with actual axes scale that is equal to detector dimensions. + * The dimensions come from the data parsed by this.parseMacroFile() that needs to be run first. + */ + private getAxisValuesFromBins( + meshName: string, + dimensionsMask: boolean[], + binsColumns: string[][] + ): string[][] { + binsColumns = Array.from(binsColumns, row => Array.from(row)); // deepcopy + + if (!this.scorersMetadata.hasOwnProperty(meshName)) { + return Array.from(binsColumns, row => Array.from(row)); + } + + let numBins = [1, 1, 1]; + let startValues = [0, 0, 0]; + let endValues = [0, 0, 0]; + + const metadata = this.scorersMetadata[meshName]; + + if (metadata.type === 'box') { + // columns: + // iX, iY, iZ, total(value) [percm2], total(val^2), entry + // First 3 store bin numbers + + if (dimensionsMask[0]) { + numBins[0] = metadata.binsAlongAxis.x; + startValues[0] = metadata.start.x; + endValues[0] = metadata.end.x; + } + + if (dimensionsMask[1]) { + numBins[1] = metadata.binsAlongAxis.y; + startValues[1] = metadata.start.y; + endValues[1] = metadata.end.y; + } + + if (dimensionsMask[2]) { + numBins[2] = metadata.binsAlongAxis.z; + startValues[2] = metadata.start.z; + endValues[2] = metadata.end.z; + } + } + + if (metadata.type === 'cylinder') { + // columns: + // iZ, iPHI, iR, total(value) [percm2], total(val^2), entry + // we care only for iZ and iR + + if (dimensionsMask[0]) { + numBins[0] = metadata.binsAlongAxis.z; + startValues[0] = metadata.start.z; + endValues[0] = metadata.end.z; + } + + if (dimensionsMask[2]) { + numBins[2] = metadata.binsAlongAxis.r; + startValues[2] = metadata.start.r; + endValues[2] = metadata.end.r; + } + } + + // For each plot dimensions (>1 bin), replace bin number + // with world coordinate for position in the detector + // + // 4 bins, o -> calculated world coordinate for the given bin: + // |---o---|---o---|---o---|---o---| + let span: number, offsetFraction: number; + + for (const i in dimensionsMask) { + if (!dimensionsMask[i]) { + continue; + } + + span = endValues[i] - startValues[i]; + + for (let j = 0; j < binsColumns[i].length; j++) { + offsetFraction = (parseFloat(binsColumns[i][j]) - 0.5) / numBins[i]; + binsColumns[i][j] = `${startValues[i] + offsetFraction * span}`; + } + } + + return binsColumns; + } + + private static getColumnNames(header: string[]): string[] { + let names = header[2].substring(2).split(', '); + + for (const i in names) { + if (names[i].startsWith('i')) { + names[i] = `Position (${names[i].substring(1)})`; + } + } + + return names; + } + + static swapColumns( + array2d: string[][], + a: number, + b: number, + aBins: number, + bBins: number + ): string[][] { + if (aBins === 1 || bBins === 1) { + return this.swapIndices( + Array.from(array2d, row => Array.from(row)), + a, + b + ); + } + + let src: number, dst: number, row: string[]; + let newArray2d = Array.from({ length: array2d.length }, (): string[] => + Array.from({ length: array2d[0].length }) + ); + + for (let ib = 0; ib < bBins; ib++) { + for (let ia = 0; ia < aBins; ia++) { + src = ia * bBins + ib; + dst = ib * aBins + ia; + + row = array2d.map(row => row[src]); + row = Geant4ResultsFileParser.swapIndices(row, a, b); + + for (let i in row) { + newArray2d[i][dst] = row[i]; + } + } + } + + return newArray2d; + } + + static swapIndices(array: T[], a: number, b: number): T[] { + const tmp: T = array[a]; + array[a] = array[b]; + array[b] = tmp; + + return array; + } + + private static getUnit(header: string, perPrimary: boolean) { const valueColumnHeader = header.split(',').at(3) ?? ''; const unit = Array.from(valueColumnHeader.matchAll(VALUE_HEADER_UNIT_REGEX)).at(0)?.at(1) ?? '?'; diff --git a/src/services/__tests__/Geant4ResultsFileParser.test.ts b/src/services/__tests__/Geant4ResultsFileParser.test.ts new file mode 100644 index 000000000..d7f23718e --- /dev/null +++ b/src/services/__tests__/Geant4ResultsFileParser.test.ts @@ -0,0 +1,81 @@ +import { Geant4ResultsFileParser } from '../Geant4ResultsFileParser'; + +describe('Geant4ResultsFileParser', () => { + test('should parse cylinder scorer', async () => { + const macro = + '/score/create/cylinderMesh TestCyl\n' + + '/score/mesh/translate/xyz 0.5 1 1.5 cm\n' + + '/score/mesh/cylinderSize 2 10 cm\n' + + '/score/mesh/nBin 100 200\n' + + '/score/quantity energyDeposit eDep\n' + + '/score/close\n'; + + const parser = new Geant4ResultsFileParser(1000, macro); + + expect(parser.scorersMetadata).toHaveProperty('TestCyl'); + expect(parser.scorersMetadata['TestCyl'].type).toEqual('cylinder'); + expect(parser.scorersMetadata['TestCyl'].binsAlongAxis).toMatchObject({ r: 100, z: 200 }); + expect(parser.scorersMetadata['TestCyl'].translation).toMatchObject({ + x: 0.5, + y: 1, + z: 1.5 + }); + expect(parser.scorersMetadata['TestCyl'].size).toMatchObject({ r: 2, z: 10 }); + expect(parser.scorersMetadata['TestCyl'].start).toMatchObject({ r: 0, z: -8.5 }); + expect(parser.scorersMetadata['TestCyl'].end).toMatchObject({ r: 2, z: 11.5 }); + }); + + test('should parse box scorer', async () => { + const macro = + '/score/create/boxMesh TestBox\n' + + '/score/mesh/translate/xyz 0.5 1 1.5 cm\n' + + '/score/mesh/boxSize 5 10 15 cm\n' + + '/score/mesh/nBin 50 100 200\n' + + '/score/quantity energyDeposit eDep\n' + + '/score/close\n'; + + const parser = new Geant4ResultsFileParser(1000, macro); + + expect(parser.scorersMetadata).toHaveProperty('TestBox'); + expect(parser.scorersMetadata['TestBox'].type).toEqual('box'); + expect(parser.scorersMetadata['TestBox'].binsAlongAxis).toMatchObject({ + x: 50, + y: 100, + z: 200 + }); + + expect(parser.scorersMetadata['TestBox'].translation).toMatchObject({ + x: 0.5, + y: 1, + z: 1.5 + }); + expect(parser.scorersMetadata['TestBox'].size).toMatchObject({ x: 5, y: 10, z: 15 }); + expect(parser.scorersMetadata['TestBox'].start).toMatchObject({ x: -4.5, y: -9, z: -13.5 }); + expect(parser.scorersMetadata['TestBox'].end).toMatchObject({ x: 5.5, y: 11, z: 16.5 }); + }); + + test('should invert columns', async () => { + let array = [ + ['0', '0', '0', '0', '1', '1', '1', '1', '2', '2', '2', '2'], // bins + ['0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0'], + ['0', '1', '2', '3', '0', '1', '2', '3', '0', '1', '2', '3'], // bins + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] // values + ]; + + array = Geant4ResultsFileParser.swapColumns(array, 0, 2, 3, 4); + expect(array[3]).toEqual(['0', '4', '8', '1', '5', '9', '2', '6', '10', '3', '7', '11']); + }); + + test('should invert columns when one does not change', async () => { + let array = [ + ['0', '0', '0', '0', '1', '1', '1', '1', '2', '2', '2', '2'], // bins (not swapped) + ['0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0'], + ['0', '1', '2', '3', '0', '1', '2', '3', '0', '1', '2', '3'], // bins + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] // values + ]; + + array = Geant4ResultsFileParser.swapColumns(array, 1, 2, 1, 4); + expect(array[1]).toEqual(['0', '1', '2', '3', '0', '1', '2', '3', '0', '1', '2', '3']); + expect(array[3]).toEqual(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']); + }); +});