Warning, /firebird/packages/root2dex/src/edm4hep.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * EDM4hep (ddsim / DD4hep simulation output) -> Firebird DEX.
0003 *
0004 * Mirrors pyrobird's `pyrobird/edm4hep.py` piece for piece.
0005 *
0006 * Collections converted:
0007 * vector<edm4hep::SimTrackerHitData> -> one BoxHit piece per collection
0008 * vector<edm4hep::MCParticleData> -> MC-truth trajectories connecting the
0009 * sim hits of each particle
0010 *
0011 * Sim hits carry no positionError/timeError/edepError, so BoxHit dimensions use
0012 * a fixed configurable box size and the error columns are omitted entirely.
0013 */
0014
0015 import { PIECE_VERSION, type DexEvent, type DexPiece } from './dex';
0016 import { numbers, type ColumnBag, type PodioEventFile } from './podio-file';
0017 import {
0018 mcParticlesBranchNames,
0019 mcParticlesToTrajectories,
0020 type McParticlesOptions,
0021 } from './mc-particles';
0022
0023 export const EDM4HEP_SIM_HIT_TYPE = 'vector<edm4hep::SimTrackerHitData>';
0024
0025 /** Sim hits have no positionError, so boxes get a fixed visualization size [mm]. */
0026 export const DEFAULT_HIT_BOX_SIZE = 2.0;
0027
0028 /** Which collection groups to convert. Defaults to all of them. */
0029 export type Edm4hepCollection = 'tracker_hits' | 'mc_trajectories' | 'mc_particles';
0030 export const EDM4HEP_DEFAULT_COLLECTIONS: Edm4hepCollection[] = [
0031 'tracker_hits',
0032 'mc_trajectories',
0033 'mc_particles',
0034 ];
0035
0036 export interface Edm4hepOptions extends McParticlesOptions {
0037 collections?: Edm4hepCollection[];
0038 /** Box size in mm for sim hits, which carry no position error. */
0039 boxSize?: number;
0040 /** Prepend the MCParticle vertex (with its creation time) as trajectory point 0. */
0041 prependVertex?: boolean;
0042 /** Append the MCParticle endpoint as the last trajectory point. */
0043 appendEndpoint?: boolean;
0044 /** Particles with fewer hits are skipped; their hits stay visible as boxes. */
0045 minHits?: number;
0046 /** Collection name the sim-hit relations point to. */
0047 mcBranch?: string;
0048 /**
0049 * Sim hit collections left out of MC-truth trajectories; they still convert
0050 * as BoxHit pieces. Default: none. Use it for Cherenkov/PID collections:
0051 * they record photon detections attributed to the emitting charged particle
0052 * (optical photons are not stored in MCParticles), and joining them into
0053 * that particle's trajectory draws zigzags along the photosensor planes.
0054 * `pyrobird convert` applies its own default list (DEFAULT_TRAJECTORY_EXCLUDED_COLLECTIONS
0055 * in pyrobird/edm4hep.py); pass the same list to get its output.
0056 */
0057 trajectoryExcludedCollections?: string[];
0058 onWarning?: (message: string) => void;
0059 }
0060
0061 const MC_FIELDS_ALWAYS = [
0062 'PDG',
0063 'charge',
0064 'time',
0065 'momentum.x',
0066 'momentum.y',
0067 'momentum.z',
0068 'vertex.x',
0069 'vertex.y',
0070 'vertex.z',
0071 ];
0072 const MC_FIELDS_ENDPOINT = ['endpoint.x', 'endpoint.y', 'endpoint.z'];
0073
0074 /**
0075 * Finds the hit -> MCParticle relation branch, or null when missing.
0076 * edm4hep >= 0.99 names it '_<Collection>_particle', older versions
0077 * '_<Collection>_MCParticle'.
0078 */
0079 export function findParticleRelationBranch(file: PodioEventFile, name: string): string | null {
0080 for (const suffix of ['particle', 'MCParticle']) {
0081 const candidate = `_${name}_${suffix}`;
0082 if (file.hasBranch(candidate)) return candidate;
0083 }
0084 return null;
0085 }
0086
0087 /** Returns the energy deposit field name: 'eDep' (edm4hep >= 0.99) or 'EDep' (older). */
0088 export function findEdepField(file: PodioEventFile, name: string): string | null {
0089 for (const field of ['eDep', 'EDep']) {
0090 if (file.hasBranch(`${name}.${field}`)) return field;
0091 }
0092 return null;
0093 }
0094
0095 /**
0096 * Every branch an edm4hep entry conversion reads. Collected up front so one
0097 * `readEntry` pass covers the whole event - see PodioEventFile.
0098 */
0099 export function edm4hepBranchNames(file: PodioEventFile, options: Edm4hepOptions = {}): string[] {
0100 const collections = options.collections?.length
0101 ? options.collections
0102 : EDM4HEP_DEFAULT_COLLECTIONS;
0103 const excluded = options.trajectoryExcludedCollections ?? [];
0104 const mcBranch = options.mcBranch ?? 'MCParticles';
0105 const hitBranches = file.collectionsOfType(EDM4HEP_SIM_HIT_TYPE);
0106 const names: string[] = [];
0107
0108 if (collections.includes('tracker_hits')) {
0109 for (const name of hitBranches) {
0110 names.push(`${name}.position.x`, `${name}.position.y`, `${name}.position.z`, `${name}.time`);
0111 const edep = findEdepField(file, name);
0112 if (edep) names.push(`${name}.${edep}`);
0113 }
0114 }
0115
0116 if (collections.includes('mc_trajectories')) {
0117 for (const field of MC_FIELDS_ALWAYS) names.push(`${mcBranch}.${field}`);
0118 if (options.appendEndpoint) {
0119 for (const field of MC_FIELDS_ENDPOINT) names.push(`${mcBranch}.${field}`);
0120 }
0121 for (const name of hitBranches) {
0122 if (excluded.includes(name)) continue;
0123 names.push(`${name}.position.x`, `${name}.position.y`, `${name}.position.z`, `${name}.time`);
0124 const relation = findParticleRelationBranch(file, name);
0125 if (relation) names.push(`${relation}.index`, `${relation}.collectionID`);
0126 }
0127 }
0128
0129 if (collections.includes('mc_particles')) {
0130 names.push(...mcParticlesBranchNames(options));
0131 }
0132
0133 return names;
0134 }
0135
0136 /** Converts one vector<edm4hep::SimTrackerHitData> collection to a BoxHit piece. */
0137 export function simTrackerHitsToBoxHits(
0138 file: PodioEventFile,
0139 bag: ColumnBag,
0140 name: string,
0141 boxSize = DEFAULT_HIT_BOX_SIZE,
0142 onWarning?: (message: string) => void,
0143 ): DexPiece {
0144 const posX = numbers(bag, `${name}.position.x`);
0145 const posY = numbers(bag, `${name}.position.y`);
0146 const posZ = numbers(bag, `${name}.position.z`);
0147 const count = posX.length;
0148
0149 const edepField = findEdepField(file, name);
0150 let edep: number[];
0151 if (edepField) {
0152 edep = numbers(bag, `${name}.${edepField}`);
0153 } else {
0154 onWarning?.(`No eDep/EDep field found in '${name}', writing 0 energy deposit`);
0155 edep = new Array<number>(count).fill(0);
0156 }
0157
0158 const pos: number[] = [];
0159 for (let i = 0; i < count; i++) pos.push(posX[i], posY[i], posZ[i]);
0160
0161 return {
0162 name,
0163 type: 'BoxHit',
0164 version: PIECE_VERSION,
0165 origin: { type: 'edm4hep::SimTrackerHitData', name },
0166 count,
0167 columns: {
0168 // Sim data carries no errors, so the error columns are omitted entirely
0169 pos,
0170 dim: new Array<number>(3 * count).fill(boxSize),
0171 time: numbers(bag, `${name}.time`),
0172 edep,
0173 },
0174 };
0175 }
0176
0177 /**
0178 * Builds MC-truth trajectories connecting SimTrackerHit hits.
0179 *
0180 * Hits from all `hitBranchNames` collections are pooled, grouped by their
0181 * associated MCParticle and sorted by time. Each particle with at least
0182 * `minHits` hits becomes one trajectory in a single PointTrajectory piece.
0183 */
0184 export function simHitsToTrajectories(
0185 file: PodioEventFile,
0186 bag: ColumnBag,
0187 hitBranchNames: string[],
0188 options: Edm4hepOptions & { mcCollectionId?: number | null } = {},
0189 ): DexPiece {
0190 const mcBranch = options.mcBranch ?? 'MCParticles';
0191 const minHits = options.minHits ?? 2;
0192 const prependVertex = options.prependVertex ?? true;
0193 const appendEndpoint = options.appendEndpoint ?? false;
0194 const onWarning = options.onWarning;
0195
0196 const piece: DexPiece = {
0197 name: 'McHitTrajectories',
0198 type: 'PointTrajectory',
0199 version: PIECE_VERSION,
0200 origin: {
0201 type: ['edm4hep::SimTrackerHitData', 'edm4hep::MCParticleData'],
0202 collections: [...hitBranchNames],
0203 },
0204 count: 0,
0205 columns: {},
0206 pointColumns: ['x', 'y', 'z', 't', 'dx', 'dy', 'dz', 'dt'],
0207 points: [],
0208 };
0209
0210 if (!file.hasBranch(mcBranch)) {
0211 onWarning?.(`'${mcBranch}' collection not found, no trajectories are built`);
0212 return piece;
0213 }
0214
0215 const mc = (field: string) => numbers(bag, `${mcBranch}.${field}`);
0216 const mcPdg = mc('PDG');
0217 const mcCharge = mc('charge');
0218 const mcTime = mc('time');
0219 const mcMomX = mc('momentum.x');
0220 const mcMomY = mc('momentum.y');
0221 const mcMomZ = mc('momentum.z');
0222 const mcVtxX = mc('vertex.x');
0223 const mcVtxY = mc('vertex.y');
0224 const mcVtxZ = mc('vertex.z');
0225 const mcEndX = appendEndpoint ? mc('endpoint.x') : [];
0226 const mcEndY = appendEndpoint ? mc('endpoint.y') : [];
0227 const mcEndZ = appendEndpoint ? mc('endpoint.z') : [];
0228
0229 // eicrecon files carry a second MCParticle collection
0230 // (MCParticlesHeadOnFrameNoBeamFX); all known sim-hit relations point to
0231 // 'MCParticles', but sanity-check via collectionID when podio metadata is
0232 // available
0233 const mcCollectionId = options.mcCollectionId ?? null;
0234
0235 // Pool hits from all collections grouped by MCParticle index.
0236 // Each hit is [time, x, y, z].
0237 const hitsByParticle = new Map<number, number[][]>();
0238 for (const name of hitBranchNames) {
0239 const relation = findParticleRelationBranch(file, name);
0240 if (relation === null) {
0241 onWarning?.(
0242 `No MCParticle relation branch found for '${name}', ` +
0243 `its hits are skipped in trajectory building`,
0244 );
0245 continue;
0246 }
0247 const posX = numbers(bag, `${name}.position.x`);
0248 const posY = numbers(bag, `${name}.position.y`);
0249 const posZ = numbers(bag, `${name}.position.z`);
0250 const time = numbers(bag, `${name}.time`);
0251 const mcIndex = numbers(bag, `${relation}.index`);
0252 const collectionIds = numbers(bag, `${relation}.collectionID`);
0253
0254 for (let i = 0; i < posX.length; i++) {
0255 const index = mcIndex[i];
0256 if (index < 0 || index >= mcPdg.length) continue; // unfilled relation
0257 if (mcCollectionId !== null && collectionIds[i] !== mcCollectionId) {
0258 onWarning?.(
0259 `Hit in '${name}' points to an unexpected MCParticle collection ` +
0260 `(collectionID=${collectionIds[i]}), skipping it`,
0261 );
0262 continue;
0263 }
0264 let hits = hitsByParticle.get(index);
0265 if (!hits) {
0266 hits = [];
0267 hitsByParticle.set(index, hits);
0268 }
0269 hits.push([time[i], posX[i], posY[i], posZ[i]]);
0270 }
0271 }
0272
0273 const columnNames = ['pdg', 'charge', 'px', 'py', 'pz', 'p', 'mc_index'] as const;
0274 const columns: Record<string, number[]> = {};
0275 for (const columnName of columnNames) columns[columnName] = [];
0276 const allPoints: number[][][] = [];
0277
0278 for (const index of [...hitsByParticle.keys()].sort((a, b) => a - b)) {
0279 const particleHits = hitsByParticle.get(index)!;
0280 if (particleHits.length < minHits) continue;
0281 particleHits.sort((a, b) => a[0] - b[0]);
0282
0283 // pointColumns => [x, y, z, t, dx, dy, dz, dt]; sim data has no errors
0284 const points: number[][] = [];
0285 if (prependVertex) {
0286 points.push([mcVtxX[index], mcVtxY[index], mcVtxZ[index], mcTime[index], 0, 0, 0, 0]);
0287 }
0288 for (const [hitTime, x, y, z] of particleHits) {
0289 points.push([x, y, z, hitTime, 0, 0, 0, 0]);
0290 }
0291 if (appendEndpoint) {
0292 // edm4hep stores no endpoint time, reuse the last hit time to keep time
0293 // animation monotonic
0294 const lastTime = particleHits[particleHits.length - 1][0];
0295 points.push([mcEndX[index], mcEndY[index], mcEndZ[index], lastTime, 0, 0, 0, 0]);
0296 }
0297
0298 const momentum = Math.sqrt(
0299 mcMomX[index] ** 2 + mcMomY[index] ** 2 + mcMomZ[index] ** 2,
0300 );
0301 columns['pdg'].push(mcPdg[index]);
0302 columns['charge'].push(mcCharge[index]);
0303 columns['px'].push(mcMomX[index]);
0304 columns['py'].push(mcMomY[index]);
0305 columns['pz'].push(mcMomZ[index]);
0306 columns['p'].push(momentum);
0307 columns['mc_index'].push(index);
0308 allPoints.push(points);
0309 }
0310
0311 piece.count = allPoints.length;
0312 piece.columns = allPoints.length ? columns : {};
0313 piece.points = allPoints;
0314 return piece;
0315 }
0316
0317 /** Converts one entry of an edm4hep 'events' tree to a DEX event object. */
0318 export async function edm4hepEntryToDex(
0319 file: PodioEventFile,
0320 entry: number,
0321 options: Edm4hepOptions & { mcCollectionId?: number | null } = {},
0322 ): Promise<DexEvent> {
0323 const collections = options.collections?.length
0324 ? options.collections
0325 : EDM4HEP_DEFAULT_COLLECTIONS;
0326 const excluded = options.trajectoryExcludedCollections ?? [];
0327 const boxSize = options.boxSize ?? DEFAULT_HIT_BOX_SIZE;
0328
0329 const bag = await file.readEntry(edm4hepBranchNames(file, options), entry);
0330 const hitBranches = file.collectionsOfType(EDM4HEP_SIM_HIT_TYPE);
0331 const pieces: DexPiece[] = [];
0332
0333 if (collections.includes('tracker_hits')) {
0334 for (const name of hitBranches) {
0335 const piece = simTrackerHitsToBoxHits(file, bag, name, boxSize, options.onWarning);
0336 // Collections empty in this event are skipped
0337 if (piece.count > 0) pieces.push(piece);
0338 }
0339 }
0340
0341 if (collections.includes('mc_trajectories')) {
0342 const trajectoryBranches = hitBranches.filter(name => !excluded.includes(name));
0343 const piece = simHitsToTrajectories(file, bag, trajectoryBranches, options);
0344 if (piece.count > 0) pieces.push(piece);
0345 }
0346
0347 if (collections.includes('mc_particles')) {
0348 const piece = mcParticlesToTrajectories(file, bag, options);
0349 if (piece.count > 0) pieces.push(piece);
0350 }
0351
0352 return { id: entry, pieces };
0353 }