Warning, /firebird/packages/firebird-ng/workers/root-file/src/lib/root-file.protocol.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * The ROOT file worker's message protocol and its request handling, kept apart
0003 * from the worker entry point so that the handling runs in unit tests without
0004 * jsroot.
0005 *
0006 * Files are opened under HANDLES: each client (the display's loads, the data
0007 * selector's picker) works on its own handle id, so opening a file for one
0008 * never replaces the file another one is converting.
0009 */
0010
0011 import type { ContentEntry, FileContentProbe } from '@dexvis/firebird-core/loaders';
0012 import type { DexDocument, PodioModel } from '@dexvis/root2dex';
0013 import { DEFAULT_MAX_ENTRIES, parseEntryRanges, selectEntries } from '@dexvis/root2dex/entries';
0014
0015 /** What to open: a file the user picked/dropped, or a URL. */
0016 export type RootFileSource =
0017 | { kind: 'file'; file: File }
0018 | { kind: 'url'; url: string };
0019
0020 export interface RootFileProbeRequest {
0021 type: 'probe';
0022 requestId: string;
0023 source: RootFileSource;
0024 }
0025
0026 export interface RootFileOpenRequest {
0027 type: 'open';
0028 requestId: string;
0029 /** The handle the file opens under; replaces what that handle had open. */
0030 handle: number;
0031 source: RootFileSource;
0032 }
0033
0034 /** What a conversion converts, besides the entries. */
0035 export interface RootConvertOptions {
0036 /**
0037 * Collection groups to convert (names from the open response's
0038 * `collectionGroups`). Absent or empty means all groups.
0039 */
0040 collections?: string[];
0041 /**
0042 * EDM4hep sim hit collections kept out of MC-truth trajectories; they still
0043 * convert as hits. Absent or empty excludes nothing.
0044 */
0045 trajectoryExcludedCollections?: string[];
0046 }
0047
0048 export interface RootFileConvertRequest extends RootConvertOptions {
0049 type: 'convert';
0050 requestId: string;
0051 /** The handle whose open file is converted. */
0052 handle: number;
0053 /**
0054 * Entry numbers as typed by the user: '1', '0,2,4-5'. Entries outside the
0055 * file are dropped with one warning in `RootFileConverted.warnings`; a
0056 * selection with no entry in the file, or more than DEFAULT_MAX_ENTRIES
0057 * requested entries, is rejected before anything is expanded (the pyrobird
0058 * convert endpoint applies the same rules).
0059 */
0060 entries: string;
0061 }
0062
0063 export interface RootFileCloseRequest {
0064 type: 'close';
0065 requestId: string;
0066 /** The handle to release; other handles keep their files. */
0067 handle: number;
0068 }
0069
0070 export type RootFileRequest =
0071 | RootFileProbeRequest
0072 | RootFileOpenRequest
0073 | RootFileConvertRequest
0074 | RootFileCloseRequest;
0075
0076 export interface RootFileProbed {
0077 type: 'probed';
0078 requestId: string;
0079 probe: FileContentProbe;
0080 }
0081
0082 export interface RootFileOpened {
0083 type: 'opened';
0084 requestId: string;
0085 sourceName: string;
0086 model: PodioModel;
0087 entryCount: number;
0088 /** The collection groups this model's conversion knows (checkbox choices). */
0089 collectionGroups: string[];
0090 }
0091
0092 export interface RootFileConverted {
0093 type: 'converted';
0094 requestId: string;
0095 /** The converted entry numbers: the request clamped to the file. */
0096 entries: number[];
0097 dex: DexDocument;
0098 /** The entry selection warning, then the converter warnings, in order. */
0099 warnings: string[];
0100 /** [load-timing] How long the conversion took in the worker. */
0101 convertMs: number;
0102 /**
0103 * [load-timing] Wall-clock (Date.now(), shared with the main thread) right
0104 * before postMessage — the receiver's Date.now() minus this is the
0105 * serialize + queue + deserialize cost of shipping the document.
0106 */
0107 postedAtMs: number;
0108 }
0109
0110 export interface RootFileClosed {
0111 type: 'closed';
0112 requestId: string;
0113 }
0114
0115 export interface RootFileError {
0116 type: 'error';
0117 requestId: string;
0118 error: string;
0119 }
0120
0121 export type RootFileResponse =
0122 | RootFileProbed
0123 | RootFileOpened
0124 | RootFileConverted
0125 | RootFileClosed
0126 | RootFileError;
0127
0128 /** An open file as the session needs it: what Root2DexConverter offers. */
0129 export interface OpenedConverter {
0130 readonly sourceName: string;
0131 readonly model: PodioModel;
0132 readonly entryCount: number;
0133 convert(
0134 entries: number[],
0135 options: RootConvertOptions & { onWarning?: (message: string) => void },
0136 ): Promise<DexDocument>;
0137 }
0138
0139 /** What the session reads files with; the worker passes jsroot and root2dex. */
0140 export interface RootFileBackend {
0141 /** Lists a file's top-level keys with their class names. */
0142 listKeys(source: File | string): Promise<ContentEntry[]>;
0143 /** Opens a podio file for conversion. */
0144 open(source: File | string): Promise<OpenedConverter>;
0145 /** The collection groups a model's conversion knows. */
0146 collectionGroups(model: PodioModel): string[];
0147 }
0148
0149 /** Answers RootFileRequests: one session per worker. */
0150 export class RootFileSession {
0151 /** Open files by handle. */
0152 private readonly converters = new Map<number, OpenedConverter>();
0153 /** The latest open request per handle: an older open that finishes later is not kept. */
0154 private readonly latestOpen = new Map<number, string>();
0155
0156 constructor(
0157 private readonly backend: RootFileBackend,
0158 private readonly post: (message: RootFileResponse) => void,
0159 ) {}
0160
0161 /** Handles one request; the answer, or an error, goes out through `post`. */
0162 async handle(request: RootFileRequest): Promise<void> {
0163 try {
0164 switch (request.type) {
0165 case 'probe':
0166 return await this.probe(request);
0167 case 'open':
0168 return await this.open(request);
0169 case 'convert':
0170 return await this.convert(request);
0171 case 'close':
0172 this.converters.delete(request.handle);
0173 this.latestOpen.delete(request.handle);
0174 this.post({ type: 'closed', requestId: request.requestId });
0175 return;
0176 }
0177 } catch (error) {
0178 this.post({
0179 type: 'error',
0180 requestId: request.requestId,
0181 error: error instanceof Error ? error.message : String(error),
0182 });
0183 }
0184 }
0185
0186 /**
0187 * Lists the file's top-level keys. No interpretation: a detector geometry
0188 * comes back as `{name: 'Default', className: 'TGeoManager'}` and event data
0189 * as `{name: 'events', className: 'TTree'}`, and the caller decides what
0190 * that means.
0191 */
0192 private async probe(request: RootFileProbeRequest): Promise<void> {
0193 const entries = await this.backend.listKeys(sourceOf(request.source));
0194 const probe: FileContentProbe = { name: nameOf(request.source), entries };
0195 this.post({ type: 'probed', requestId: request.requestId, probe });
0196 }
0197
0198 private async open(request: RootFileOpenRequest): Promise<void> {
0199 this.latestOpen.set(request.handle, request.requestId);
0200 const converter = await this.backend.open(sourceOf(request.source));
0201 if (this.latestOpen.get(request.handle) === request.requestId) {
0202 this.converters.set(request.handle, converter);
0203 }
0204 this.post({
0205 type: 'opened',
0206 requestId: request.requestId,
0207 sourceName: converter.sourceName,
0208 model: converter.model,
0209 entryCount: converter.entryCount,
0210 collectionGroups: this.backend.collectionGroups(converter.model),
0211 });
0212 }
0213
0214 private async convert(request: RootFileConvertRequest): Promise<void> {
0215 const converter = this.converters.get(request.handle);
0216 if (!converter) throw new Error('No ROOT file is open');
0217 // The size is checked and the ranges clamped before the selection is expanded
0218 const { entries, warning } = selectEntries(parseEntryRanges(request.entries), converter.entryCount, DEFAULT_MAX_ENTRIES);
0219 const warnings: string[] = warning ? [warning] : [];
0220 const convertStart = performance.now();
0221 const dex = await converter.convert(entries, {
0222 collections: request.collections?.length ? request.collections : undefined,
0223 trajectoryExcludedCollections: request.trajectoryExcludedCollections,
0224 onWarning: message => warnings.push(message),
0225 });
0226 const convertMs = performance.now() - convertStart;
0227 this.post({
0228 type: 'converted',
0229 requestId: request.requestId,
0230 entries,
0231 dex,
0232 warnings,
0233 convertMs,
0234 postedAtMs: Date.now(),
0235 });
0236 }
0237 }
0238
0239 function sourceOf(source: RootFileSource): File | string {
0240 return source.kind === 'file' ? source.file : source.url;
0241 }
0242
0243 function nameOf(source: RootFileSource): string {
0244 return source.kind === 'file' ? source.file.name : source.url;
0245 }