nds-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +186 -0
  2. package/bin/nds-mcp.js +33 -0
  3. package/dist/constants.d.ts +14 -0
  4. package/dist/constants.d.ts.map +1 -0
  5. package/dist/constants.js +13 -0
  6. package/dist/constants.js.map +1 -0
  7. package/dist/db/chargeRadii.d.ts +22 -0
  8. package/dist/db/chargeRadii.d.ts.map +1 -0
  9. package/dist/db/chargeRadii.js +75 -0
  10. package/dist/db/chargeRadii.js.map +1 -0
  11. package/dist/db/decayFeedings.d.ts +31 -0
  12. package/dist/db/decayFeedings.d.ts.map +1 -0
  13. package/dist/db/decayFeedings.js +41 -0
  14. package/dist/db/decayFeedings.js.map +1 -0
  15. package/dist/db/ensureDb.d.ts +20 -0
  16. package/dist/db/ensureDb.d.ts.map +1 -0
  17. package/dist/db/ensureDb.js +148 -0
  18. package/dist/db/ensureDb.js.map +1 -0
  19. package/dist/db/gammas.d.ts +39 -0
  20. package/dist/db/gammas.d.ts.map +1 -0
  21. package/dist/db/gammas.js +53 -0
  22. package/dist/db/gammas.js.map +1 -0
  23. package/dist/db/levels.d.ts +68 -0
  24. package/dist/db/levels.d.ts.map +1 -0
  25. package/dist/db/levels.js +121 -0
  26. package/dist/db/levels.js.map +1 -0
  27. package/dist/db/masses.d.ts +16 -0
  28. package/dist/db/masses.d.ts.map +1 -0
  29. package/dist/db/masses.js +22 -0
  30. package/dist/db/masses.js.map +1 -0
  31. package/dist/db/ndsDb.d.ts +10 -0
  32. package/dist/db/ndsDb.d.ts.map +1 -0
  33. package/dist/db/ndsDb.js +61 -0
  34. package/dist/db/ndsDb.js.map +1 -0
  35. package/dist/db/nubase.d.ts +21 -0
  36. package/dist/db/nubase.d.ts.map +1 -0
  37. package/dist/db/nubase.js +61 -0
  38. package/dist/db/nubase.js.map +1 -0
  39. package/dist/db/reactions.d.ts +11 -0
  40. package/dist/db/reactions.d.ts.map +1 -0
  41. package/dist/db/reactions.js +54 -0
  42. package/dist/db/reactions.js.map +1 -0
  43. package/dist/db/references.d.ts +11 -0
  44. package/dist/db/references.d.ts.map +1 -0
  45. package/dist/db/references.js +24 -0
  46. package/dist/db/references.js.map +1 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +55 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/ingest/buildDb.d.ts +36 -0
  52. package/dist/ingest/buildDb.d.ts.map +1 -0
  53. package/dist/ingest/buildDb.js +933 -0
  54. package/dist/ingest/buildDb.js.map +1 -0
  55. package/dist/ingest/parseAme.d.ts +79 -0
  56. package/dist/ingest/parseAme.d.ts.map +1 -0
  57. package/dist/ingest/parseAme.js +186 -0
  58. package/dist/ingest/parseAme.js.map +1 -0
  59. package/dist/ingest/parseEnsdf.d.ts +210 -0
  60. package/dist/ingest/parseEnsdf.d.ts.map +1 -0
  61. package/dist/ingest/parseEnsdf.js +469 -0
  62. package/dist/ingest/parseEnsdf.js.map +1 -0
  63. package/dist/ingest/parseLaserRadii.d.ts +33 -0
  64. package/dist/ingest/parseLaserRadii.d.ts.map +1 -0
  65. package/dist/ingest/parseLaserRadii.js +210 -0
  66. package/dist/ingest/parseLaserRadii.js.map +1 -0
  67. package/dist/ingest/parseNubase.d.ts +40 -0
  68. package/dist/ingest/parseNubase.d.ts.map +1 -0
  69. package/dist/ingest/parseNubase.js +146 -0
  70. package/dist/ingest/parseNubase.js.map +1 -0
  71. package/dist/ingest/parseRadii.d.ts +17 -0
  72. package/dist/ingest/parseRadii.d.ts.map +1 -0
  73. package/dist/ingest/parseRadii.js +42 -0
  74. package/dist/ingest/parseRadii.js.map +1 -0
  75. package/dist/ingest/parseTunl.d.ts +48 -0
  76. package/dist/ingest/parseTunl.d.ts.map +1 -0
  77. package/dist/ingest/parseTunl.js +773 -0
  78. package/dist/ingest/parseTunl.js.map +1 -0
  79. package/dist/shared/errors.d.ts +20 -0
  80. package/dist/shared/errors.d.ts.map +1 -0
  81. package/dist/shared/errors.js +45 -0
  82. package/dist/shared/errors.js.map +1 -0
  83. package/dist/shared/index.d.ts +4 -0
  84. package/dist/shared/index.d.ts.map +1 -0
  85. package/dist/shared/index.js +3 -0
  86. package/dist/shared/index.js.map +1 -0
  87. package/dist/shared/sqlite3Cli.d.ts +3 -0
  88. package/dist/shared/sqlite3Cli.d.ts.map +1 -0
  89. package/dist/shared/sqlite3Cli.js +106 -0
  90. package/dist/shared/sqlite3Cli.js.map +1 -0
  91. package/dist/tooling.d.ts +2 -0
  92. package/dist/tooling.d.ts.map +1 -0
  93. package/dist/tooling.js +2 -0
  94. package/dist/tooling.js.map +1 -0
  95. package/dist/tools/dispatcher.d.ts +11 -0
  96. package/dist/tools/dispatcher.d.ts.map +1 -0
  97. package/dist/tools/dispatcher.js +64 -0
  98. package/dist/tools/dispatcher.js.map +1 -0
  99. package/dist/tools/index.d.ts +3 -0
  100. package/dist/tools/index.d.ts.map +1 -0
  101. package/dist/tools/index.js +3 -0
  102. package/dist/tools/index.js.map +1 -0
  103. package/dist/tools/mcpSchema.d.ts +3 -0
  104. package/dist/tools/mcpSchema.d.ts.map +1 -0
  105. package/dist/tools/mcpSchema.js +21 -0
  106. package/dist/tools/mcpSchema.js.map +1 -0
  107. package/dist/tools/registry.d.ts +22 -0
  108. package/dist/tools/registry.d.ts.map +1 -0
  109. package/dist/tools/registry.js +309 -0
  110. package/dist/tools/registry.js.map +1 -0
  111. package/dist/utils/stdioHygiene.d.ts +7 -0
  112. package/dist/utils/stdioHygiene.d.ts.map +1 -0
  113. package/dist/utils/stdioHygiene.js +16 -0
  114. package/dist/utils/stdioHygiene.js.map +1 -0
  115. package/package.json +64 -0
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Parser for TUNL (Triangle Universities Nuclear Laboratory) energy level tables.
3
+ *
4
+ * Input: pdftotext -layout output from TUNL EL PDF files.
5
+ * Handles both even-A and odd-A formats, including:
6
+ * - Inline Jπ (8Be 2004 style)
7
+ * - Parity on separate line above OR below energy (12C 2017, 14N 1991)
8
+ * - Fractional Jπ for half-integer spins (5Li, 7Li, 11B, 15N)
9
+ * - Lifetime (τm) and width (Γ) with unit conversion
10
+ *
11
+ * Evaluation years: 1991, 1992, 1993, 1995, 1998, 2002, 2004, 2012, 2017.
12
+ */
13
+ // ── Element → Z mapping (light nuclei only, A ≤ 20) ──────────────────────────
14
+ const ELEMENT_Z = {
15
+ n: 0, H: 1, He: 2, Li: 3, Be: 4, B: 5, C: 6, N: 7, O: 8, F: 9,
16
+ Ne: 10, Na: 11, Mg: 12,
17
+ };
18
+ function elementToZ(el) {
19
+ const z = ELEMENT_Z[el];
20
+ if (z !== undefined)
21
+ return z;
22
+ const tc = el.charAt(0).toUpperCase() + el.slice(1).toLowerCase();
23
+ const z2 = ELEMENT_Z[tc];
24
+ if (z2 !== undefined)
25
+ return z2;
26
+ throw new Error(`Unknown element for TUNL: ${el}`);
27
+ }
28
+ // ── Constants ─────────────────────────────────────────────────────────────────
29
+ const HBAR_KEV_S = 6.582119569e-19; // ℏ in keV·s
30
+ const TIME_UNITS = {
31
+ s: 1, ms: 1e-3, us: 1e-6, 'μs': 1e-6, ns: 1e-9,
32
+ ps: 1e-12, fs: 1e-15, fsec: 1e-15, as: 1e-18,
33
+ };
34
+ const ENERGY_UNITS = {
35
+ keV: 1, MeV: 1000, eV: 0.001, meV: 1e-6,
36
+ };
37
+ // ── Header parsing ────────────────────────────────────────────────────────────
38
+ const TABLE_HEADER_RE = /Table\s+([\d.]+)\s+from\s+\((\d{4}\w+)\):\s*Energy\s+levels?\s+of\s+(\d+)\s+(\w+)/i;
39
+ function detectWidthUnit(text) {
40
+ const headerLines = text.split('\n').slice(0, 15);
41
+ for (let i = 0; i < headerLines.length; i++) {
42
+ const line = headerLines[i];
43
+ // Inline: "Γcm (MeV)" or "Γcm b (MeV)"
44
+ if (/Γcm\s*\w?\s*\(MeV\)/i.test(line))
45
+ return 'MeV';
46
+ // Inline: "Γcm (keV)" → explicit keV
47
+ if (/Γcm\s*\w?\s*\(keV\)/i.test(line))
48
+ return 'keV';
49
+ // Split-line: "Γcm" on this line, "(MeV)" on next line at similar column
50
+ if (/Γcm/.test(line) && i + 1 < headerLines.length) {
51
+ const gammaPos = line.indexOf('Γcm');
52
+ const nextLine = headerLines[i + 1];
53
+ const mevRe = /\(MeV\)/g;
54
+ let m;
55
+ while ((m = mevRe.exec(nextLine)) !== null) {
56
+ if (Math.abs(m.index - gammaPos) < 12)
57
+ return 'MeV';
58
+ }
59
+ }
60
+ }
61
+ return 'keV';
62
+ }
63
+ function parseTableHeader(text) {
64
+ const m = text.match(TABLE_HEADER_RE);
65
+ if (!m)
66
+ return null;
67
+ const sourceTable = `Table ${m[1]}`;
68
+ const evaluation = m[2];
69
+ const A = parseInt(m[3], 10);
70
+ const element = m[4];
71
+ const Z = elementToZ(element);
72
+ const defaultWidthUnit = detectWidthUnit(text);
73
+ return { sourceTable, evaluation, A, element, Z, defaultWidthUnit };
74
+ }
75
+ function stripFootnotes(s) {
76
+ // Remove trailing single-letter footnote markers: "1513 ± 15 i" → "1513 ± 15"
77
+ return s.replace(/\s+[a-z]$/g, '').replace(/\s+[a-z],/g, ',').trim();
78
+ }
79
+ export function parseWidth(raw, defaultUnit) {
80
+ const s = stripFootnotes(raw.trim());
81
+ if (!s || s === '-' || s === '–') {
82
+ return { width_keV: null, width_unc_keV: null, width_raw: null, width_relation: null, half_life: null };
83
+ }
84
+ if (/^stable$/i.test(s)) {
85
+ return { width_keV: null, width_unc_keV: null, width_raw: null, width_relation: null, half_life: 'stable' };
86
+ }
87
+ if (/^broad$/i.test(s)) {
88
+ return { width_keV: null, width_unc_keV: null, width_raw: s, width_relation: 'broad', half_life: null };
89
+ }
90
+ let cleaned = s;
91
+ // Strip leading Γ= or τm=
92
+ const isLifetimeExplicit = /^τm?\s*=/i.test(cleaned);
93
+ cleaned = cleaned.replace(/^(?:Γ|τm?)\s*=\s*/i, '').trim();
94
+ // Strip leading |g| = ... lines (magnetic moment, not width)
95
+ if (/^\|g\|/.test(cleaned)) {
96
+ return { width_keV: null, width_unc_keV: null, width_raw: null, width_relation: null, half_life: null };
97
+ }
98
+ // Detect time unit → it's a lifetime
99
+ if (isLifetimeExplicit || /\b(fs|fsec|ps|ns|[μu]s|ms)\b/.test(cleaned)) {
100
+ const lt = parseLifetime(cleaned);
101
+ return { ...lt, width_raw: s, width_relation: lt.width_keV !== null ? '=' : null };
102
+ }
103
+ // Upper limit: < value or ≤ value
104
+ const ltMatch = cleaned.match(/^([<≤])\s*([\d.]+)\s*(?:[×x]\s*10\s*[−\-]\s*(\d+))?\s*(\w+)?/);
105
+ if (ltMatch) {
106
+ const relation = ltMatch[1] === '≤' ? '≤' : '<';
107
+ let val = parseFloat(ltMatch[2]);
108
+ if (ltMatch[3])
109
+ val *= Math.pow(10, -parseInt(ltMatch[3], 10));
110
+ const unit = ltMatch[4] && ENERGY_UNITS[ltMatch[4]] ? ltMatch[4] : defaultUnit;
111
+ const factor = ENERGY_UNITS[unit];
112
+ return { width_keV: val * factor, width_unc_keV: null, width_raw: s, width_relation: relation, half_life: null };
113
+ }
114
+ // Detect leading ≈ or ~
115
+ const isApprox = /^[≈~]/.test(cleaned);
116
+ cleaned = cleaned.replace(/^[≈~]\s*/, '');
117
+ // Scientific notation: (value ± unc) × 10−exp [unit]
118
+ const sciMatch = cleaned.match(/\(?([\d.]+)\s*(?:±\s*([\d.]+))?\)?\s*[×x]\s*10\s*[−\-]\s*(\d+)\s*(\w+)?/);
119
+ if (sciMatch) {
120
+ const val = parseFloat(sciMatch[1]);
121
+ const unc = sciMatch[2] ? parseFloat(sciMatch[2]) : null;
122
+ const exp = parseInt(sciMatch[3], 10);
123
+ const unit = sciMatch[4] && ENERGY_UNITS[sciMatch[4]] ? sciMatch[4] : defaultUnit;
124
+ const factor = ENERGY_UNITS[unit];
125
+ const multiplier = Math.pow(10, -exp) * factor;
126
+ return {
127
+ width_keV: val * multiplier,
128
+ width_unc_keV: unc !== null ? unc * multiplier : null,
129
+ width_raw: s,
130
+ width_relation: isApprox ? '≈' : '=',
131
+ half_life: null,
132
+ };
133
+ }
134
+ // Γcalc = value (calculated width, still in default unit)
135
+ const calcMatch = cleaned.match(/Γcalc\s*=\s*([\d.]+)/);
136
+ if (calcMatch) {
137
+ const val = parseFloat(calcMatch[1]);
138
+ const factor = ENERGY_UNITS[defaultUnit];
139
+ return { width_keV: val * factor, width_unc_keV: null, width_raw: s, width_relation: 'calc', half_life: null };
140
+ }
141
+ // Plain value ± unc with optional unit
142
+ const valMatch = cleaned.match(/^([\d.]+)\s*(?:±\s*([\d.]+))?\s*(meV|eV|keV|MeV)?/);
143
+ if (valMatch) {
144
+ const val = parseFloat(valMatch[1]);
145
+ const unc = valMatch[2] ? parseFloat(valMatch[2]) : null;
146
+ const unit = valMatch[3] && ENERGY_UNITS[valMatch[3]] ? valMatch[3] : defaultUnit;
147
+ const factor = ENERGY_UNITS[unit];
148
+ return {
149
+ width_keV: val * factor,
150
+ width_unc_keV: unc !== null ? unc * factor : null,
151
+ width_raw: s,
152
+ width_relation: isApprox ? '≈' : '=',
153
+ half_life: null,
154
+ };
155
+ }
156
+ return { width_keV: null, width_unc_keV: null, width_raw: s, width_relation: null, half_life: null };
157
+ }
158
+ function parseLifetime(s) {
159
+ // Handle asymmetric uncertainties: "12+11\n-6 fs" → just take value
160
+ const m = s.match(/([\d.]+)\s*(?:±\s*([\d.]+))?\s*(fs|fsec|ps|ns|[μu]s|ms|s)\b/);
161
+ if (!m)
162
+ return { width_keV: null, width_unc_keV: null, half_life: s };
163
+ const val = parseFloat(m[1]);
164
+ const unc = m[2] ? parseFloat(m[2]) : null;
165
+ const unitKey = m[3];
166
+ const unitFactor = TIME_UNITS[unitKey] ?? TIME_UNITS[unitKey.replace('μ', 'u')];
167
+ if (!unitFactor)
168
+ return { width_keV: null, width_unc_keV: null, half_life: s };
169
+ const tau_s = val * unitFactor;
170
+ if (tau_s === 0)
171
+ return { width_keV: null, width_unc_keV: null, half_life: s };
172
+ const width_keV = HBAR_KEV_S / tau_s;
173
+ let width_unc_keV = null;
174
+ if (unc !== null) {
175
+ const tau_unc_s = unc * unitFactor;
176
+ width_unc_keV = width_keV * (tau_unc_s / tau_s);
177
+ }
178
+ return { width_keV, width_unc_keV, half_life: null };
179
+ }
180
+ export function parseEnergy(raw) {
181
+ let s = stripFootnotes(raw.trim()).replace(/[()]/g, '').trim();
182
+ if (!s)
183
+ return null;
184
+ if (/^g\.?s\.?$/i.test(s) || s === '0') {
185
+ return { energy_keV: 0, energy_unc_keV: null, energy_raw: raw.trim() };
186
+ }
187
+ // Remove leading ≈
188
+ s = s.replace(/^[≈~]\s*/, '');
189
+ const m = s.match(/^([\d.]+)\s*(?:±\s*([\d.]+))?/);
190
+ if (!m)
191
+ return null;
192
+ const energy_MeV = parseFloat(m[1]);
193
+ const energy_keV = energy_MeV * 1000;
194
+ // TUNL convention: "Ex (MeV ± keV)" → uncertainty is in keV
195
+ const energy_unc_keV = m[2] ? parseFloat(m[2]) : null;
196
+ return { energy_keV, energy_unc_keV, energy_raw: raw.trim() };
197
+ }
198
+ // ── Decay modes parsing ───────────────────────────────────────────────────────
199
+ function parseDecayModes(raw) {
200
+ if (!raw?.trim())
201
+ return null;
202
+ const s = stripFootnotes(raw.trim());
203
+ if (!s || s === '-' || s === '–')
204
+ return null;
205
+ if (/^stable$/i.test(s))
206
+ return 'stable';
207
+ // Extract known decay tokens.
208
+ // Greek letters (γ, α, π) are non-ASCII and \b doesn't work on them in non-Unicode mode.
209
+ // Use a combined regex: no \b around Greek, \b around ASCII tokens.
210
+ const tokens = [];
211
+ const re = /(γ|α|π|\b3\s*He\b|\b6\s*Li\b|\b2α|\b2p\b|\b2n\b|\bp\b|\bn\b|\bd\b|\bt\b)/gi;
212
+ let match;
213
+ while ((match = re.exec(s)) !== null) {
214
+ const tok = match[1].replace(/\s+/g, '');
215
+ if (!tokens.includes(tok))
216
+ tokens.push(tok);
217
+ }
218
+ return tokens.length > 0 ? tokens.join(', ') : null;
219
+ }
220
+ // ── Data line extraction ──────────────────────────────────────────────────────
221
+ function extractDataLines(text) {
222
+ const lines = text.split('\n');
223
+ const dataLines = [];
224
+ let inFootnotes = false;
225
+ for (const line of lines) {
226
+ const trimmed = line.trim();
227
+ if (!trimmed)
228
+ continue;
229
+ // Form-feed characters
230
+ if (trimmed === '\f' || trimmed.startsWith('\f')) {
231
+ inFootnotes = false;
232
+ continue;
233
+ }
234
+ // New table header resets footnote mode
235
+ if (TABLE_HEADER_RE.test(trimmed)) {
236
+ inFootnotes = false;
237
+ continue;
238
+ }
239
+ if (/\(continued\)/i.test(trimmed))
240
+ continue;
241
+ // Column headers
242
+ if (/^Ex[\s(]/i.test(trimmed))
243
+ continue;
244
+ if (/^\(MeV/i.test(trimmed))
245
+ continue;
246
+ if (/^J\s*π/i.test(trimmed))
247
+ continue;
248
+ // Footnotes: single lowercase letter followed by explanation text
249
+ if (/^[a-z]\s{1,4}[A-Z]/.test(trimmed) ||
250
+ /^[a-z]\s{1,4}(?:See|From|For|The|This|Γ|Newly|Situated|These|Primarily|With|Wide|Revisions|Mainly|Support|At|In|Possible|Adopted|Weighted|I\s)/i.test(trimmed)) {
251
+ inFootnotes = true;
252
+ continue;
253
+ }
254
+ if (inFootnotes)
255
+ continue;
256
+ // Isolated page numbers
257
+ if (/^\d{1,2}$/.test(trimmed))
258
+ continue;
259
+ // Reaction continuation lines: digits, commas, spaces, parens (must have at least one comma)
260
+ if (/^[\d,\s()]+$/.test(trimmed) && trimmed.includes(','))
261
+ continue;
262
+ // "used in analysis" column subheader
263
+ if (/^\(used in analysis\)/i.test(trimmed))
264
+ continue;
265
+ dataLines.push(line);
266
+ }
267
+ return dataLines;
268
+ }
269
+ /**
270
+ * Split a line into columns by gaps of 3+ spaces.
271
+ * Each "word" (contiguous non-space) is found, then adjacent words
272
+ * separated by < 3 spaces are merged into the same column.
273
+ */
274
+ function splitColumns(line) {
275
+ const segments = [];
276
+ const re = /(\S+)/g;
277
+ let m;
278
+ let lastEnd = 0;
279
+ while ((m = re.exec(line)) !== null) {
280
+ const gap = m.index - lastEnd;
281
+ if (segments.length === 0 || gap >= 3) {
282
+ segments.push({ text: m[1], startCol: m.index });
283
+ }
284
+ else {
285
+ const prev = segments[segments.length - 1];
286
+ prev.text = line.substring(prev.startCol, m.index + m[1].length).trimEnd();
287
+ }
288
+ lastEnd = m.index + m[0].length;
289
+ }
290
+ return segments;
291
+ }
292
+ /**
293
+ * Check if first column in line looks like an energy value:
294
+ * g.s., 0, numeric values, with optional parens, ≈, uncertainty, footnote letters.
295
+ */
296
+ function looksLikeEnergy(text) {
297
+ if (/^g\.?s\.?$/i.test(text))
298
+ return true;
299
+ if (text === '0')
300
+ return true;
301
+ // Strip parens and leading ≈
302
+ const cleaned = text.replace(/[()]/g, '').replace(/^[≈~]\s*/, '').trim();
303
+ if (!cleaned)
304
+ return false;
305
+ if (!/^\d/.test(cleaned))
306
+ return false;
307
+ // Numeric energy with optional uncertainty: "3.03 ± 10", "≈ 37", "18.91"
308
+ if (/^\d[\d.]*\s*(?:±\s*[\d.]+)?$/.test(cleaned))
309
+ return true;
310
+ return false;
311
+ }
312
+ function isEnergyLine(line) {
313
+ const cols = splitColumns(line);
314
+ if (cols.length === 0)
315
+ return null;
316
+ const first = cols[0];
317
+ // Energy value must start within first 20 characters (rejects continuation lines)
318
+ if (first.startCol > 20)
319
+ return null;
320
+ // Strip footnote markers from energy text
321
+ const text = stripFootnotes(first.text).trim();
322
+ if (!looksLikeEnergy(text))
323
+ return null;
324
+ // Reject if next column starts with energy unit (this is a width value, not Ex)
325
+ if (cols.length > 1 && /^(?:eV|keV|MeV|meV)\b/i.test(cols[1].text.trim()))
326
+ return null;
327
+ // Reject bare page/reaction numbers with no other content
328
+ if (/^\d{1,2}$/.test(text) && cols.length <= 1)
329
+ return null;
330
+ return { energyStr: text, restCols: cols.slice(1) };
331
+ }
332
+ // ── Jπ above/below detection ──────────────────────────────────────────────────
333
+ /**
334
+ * Check if line is a Jπ info line (typically above/below an energy line):
335
+ * - Odd-A: "3−", "3− 1", "(3−)", with optional leading footnote markers
336
+ * - Even-A parity: "+", "−"
337
+ */
338
+ function isJpiInfoLine(line) {
339
+ const trimmed = line.trim();
340
+ if (!trimmed)
341
+ return null;
342
+ // Must have significant leading whitespace (not a data entry)
343
+ const leadingSpace = line.match(/^(\s*)/)?.[1]?.length ?? 0;
344
+ if (leadingSpace < 5)
345
+ return null;
346
+ // Strip leading footnote markers: "b 1+" → "1+"
347
+ let cleaned = trimmed.replace(/^[a-z]+[\s,]*/, '').trim();
348
+ if (!cleaned)
349
+ cleaned = trimmed;
350
+ // Strip trailing footnote markers: "3− 1 a" → "3− 1"
351
+ cleaned = cleaned.replace(/\s+[a-z]$/g, '').trim();
352
+ // Parity-only: just "+" or "−"
353
+ if (/^[+\-−]$/.test(cleaned))
354
+ return cleaned;
355
+ // Jπ numerator for odd-A: "3−", "3− 1", "(3−)", "3+ 1", etc.
356
+ if (/^\(?\s*\d+\s*[+\-−]\s*\)?\s*\d*\s*$/.test(cleaned))
357
+ return cleaned;
358
+ // Multiple Jπ options: "5+ 7+", "5+, 4−, 6−, 7+"
359
+ if (/^\(?\s*\d+\s*[+\-−][\s,\d+\-−]*\)?\s*$/.test(cleaned))
360
+ return cleaned;
361
+ // "T = 3/2" style on its own line
362
+ if (/^T\s*=/.test(cleaned))
363
+ return cleaned;
364
+ return null;
365
+ }
366
+ /**
367
+ * Check if a line is a parity-only line (just "+" or "−" with leading whitespace).
368
+ */
369
+ function isParityOnlyLine(line) {
370
+ const trimmed = line.trim();
371
+ if (/^[+\-−]$/.test(trimmed)) {
372
+ const leadingSpace = line.match(/^(\s*)/)?.[1]?.length ?? 0;
373
+ if (leadingSpace >= 5)
374
+ return trimmed;
375
+ }
376
+ return null;
377
+ }
378
+ /**
379
+ * Expand pdftotext concatenated fractions for odd-A half-integer values.
380
+ * In PDF, stacked fractions like 3/2 are rendered as "32" or "23" by pdftotext.
381
+ * Only expands 2-digit numbers where one digit is 2 and the other is odd.
382
+ */
383
+ function expandConcatFraction(s) {
384
+ return s.replace(/(?<![/\d])(\d)(\d)(?![/\d])/g, (match, d1, d2) => {
385
+ const n1 = parseInt(d1, 10);
386
+ const n2 = parseInt(d2, 10);
387
+ // numerator/2 pattern: e.g., "32" → "3/2"
388
+ if (n2 === 2 && n1 % 2 === 1)
389
+ return `${n1}/2`;
390
+ // reversed pattern: e.g., "23" → "3/2"
391
+ if (n1 === 2 && n2 % 2 === 1)
392
+ return `${n2}/2`;
393
+ return match;
394
+ });
395
+ }
396
+ function parseJpiT(raw, jpiAbove, isOddA) {
397
+ const s = stripFootnotes(raw.trim()).replace(/−/g, '-');
398
+ // Check if jpiAbove is parity-only ("+", "−", "-")
399
+ const aboveTrimmed = jpiAbove?.trim().replace(/−/g, '-') ?? null;
400
+ const isParityOnlyAbove = aboveTrimmed !== null && /^[+\-]$/.test(aboveTrimmed);
401
+ // Odd-A with proper Jπ numerator above (not parity-only)
402
+ if (isOddA && jpiAbove && !isParityOnlyAbove) {
403
+ const above = aboveTrimmed;
404
+ // Parse above: "3-", "3- 1", "3+", "(3-)", "( 3- )", "1- 1"
405
+ const aboveMatch = above.match(/\(?\s*(\d+)\s*([+\-])?\s*\)?\s*(?:(\d+))?/);
406
+ if (!aboveMatch)
407
+ return { spin_parity: null, isospin: null };
408
+ const jNum = aboveMatch[1];
409
+ const parity = aboveMatch[2] || '';
410
+ const tNum = aboveMatch[3] ?? null;
411
+ // Parse current line: "2", "2 ; 2", "2 ;2", "; 2"
412
+ const denomMatch = s.match(/^;?\s*(\d+)(?:\s*;?\s*(\d+))?/);
413
+ const jDenom = denomMatch?.[1] ?? '2';
414
+ const jp = jDenom === '1'
415
+ ? `${jNum}${parity}`
416
+ : `${jNum}/${jDenom}${parity}`;
417
+ // T: look for T denominator
418
+ let isospin = null;
419
+ if (tNum) {
420
+ const tDenomMatch = s.match(/;\s*(\d+)/);
421
+ if (tDenomMatch) {
422
+ isospin = tDenomMatch[1] === '1' ? tNum : `${tNum}/${tDenomMatch[1]}`;
423
+ }
424
+ }
425
+ return { spin_parity: jp, isospin };
426
+ }
427
+ // Even-A, inline format, or odd-A with parity-only/no above line
428
+ // Extract T (after semicolon)
429
+ let isospin = null;
430
+ const tMatch = s.match(/;\s*T?\s*=?\s*([\d(/)+\-\s]+?)(?:\s{2,}|$)/);
431
+ if (tMatch) {
432
+ let tVal = tMatch[1].trim().replace(/\s+/g, '');
433
+ // Remove trailing footnote markers from T
434
+ tVal = tVal.replace(/[a-z]$/, '');
435
+ if (isOddA)
436
+ tVal = expandConcatFraction(tVal);
437
+ isospin = tVal || null;
438
+ }
439
+ // Also check for standalone "T = N" patterns
440
+ const tStandalone = s.match(/T\s*=\s*(\d+(?:\/\d+)?)/);
441
+ if (tStandalone && !isospin) {
442
+ isospin = tStandalone[1];
443
+ }
444
+ // Extract Jπ (before semicolon if present)
445
+ const jpiPart = s.split(';')[0].trim();
446
+ if (!jpiPart)
447
+ return { spin_parity: null, isospin };
448
+ // If jpiPart is just a T assignment (e.g., "T = 1", "(T = 3/2)"), Jπ is unknown
449
+ if (/^\(?T\s*=/i.test(jpiPart))
450
+ return { spin_parity: null, isospin };
451
+ let jp = jpiPart;
452
+ // Remove outer parens for simple cases
453
+ if (jp.startsWith('(') && jp.endsWith(')') && !jp.includes(',')) {
454
+ jp = jp.slice(1, -1).trim();
455
+ }
456
+ // For odd-A inline: expand pdftotext concatenated fractions
457
+ // e.g., "(32)-" → "(3/2)-", "52" → "5/2"
458
+ if (isOddA) {
459
+ jp = expandConcatFraction(jp);
460
+ }
461
+ // Apply parity from above line (even-A parity-separate or odd-A parity-only)
462
+ if (aboveTrimmed && /^[+\-]$/.test(aboveTrimmed) && !/[+\-]/.test(jp)) {
463
+ jp = jp.replace(/\s+/g, '') + aboveTrimmed;
464
+ }
465
+ else if (jpiAbove && !isOddA) {
466
+ const parityAbove = aboveTrimmed;
467
+ if (/^[+\-]$/.test(parityAbove) && !/[+\-]/.test(jp)) {
468
+ jp = jp.replace(/\s+/g, '') + parityAbove;
469
+ }
470
+ }
471
+ return { spin_parity: jp.replace(/\s+/g, '') || null, isospin };
472
+ }
473
+ // ── Column content classification ─────────────────────────────────────────────
474
+ function looksLikeJpi(s) {
475
+ const t = s.trim().replace(/−/g, '-');
476
+ if (!t)
477
+ return false;
478
+ // Contains parity symbols (+/-)
479
+ if (/[+\-]/.test(t) && !/±/.test(t))
480
+ return true;
481
+ // Contains semicolon (Jπ;T separator)
482
+ if (t.includes(';'))
483
+ return true;
484
+ // "T = N" style or "(T = N)"
485
+ if (/^\(?T\s*=/i.test(t))
486
+ return true;
487
+ // Small integer 0-9 for spin (like "2" for J denominator)
488
+ if (/^\(?\d{1,2}\)?$/.test(t))
489
+ return true;
490
+ // Multi-option: "(5+, 4-, 6-, 7+)"
491
+ if (/\([^)]*[+\-][^)]*\)/.test(t))
492
+ return true;
493
+ // Contains ≥ (like "≥ 3/2") — Jπ constraint
494
+ if (/≥\s*\d/.test(t))
495
+ return true;
496
+ return false;
497
+ }
498
+ function looksLikeDecay(s) {
499
+ const t = s.trim();
500
+ if (/^stable$/i.test(t))
501
+ return true;
502
+ // Contains known decay particle symbols and mostly those + commas/parens/spaces
503
+ if (/[γα]/.test(t))
504
+ return true;
505
+ // "p, n", "n, α", "p", "γ, p, α" etc — short strings of decay tokens
506
+ if (/^[\s,()]*(?:p|n|d|t|π|3\s*He|2[αpn]|6\s*Li)[\s,()pndtγα3He2]*$/i.test(t) && t.length < 40)
507
+ return true;
508
+ return false;
509
+ }
510
+ function looksLikeWidth(s) {
511
+ const t = s.trim();
512
+ if (t === '-' || t === '–')
513
+ return true;
514
+ if (/^stable$/i.test(t))
515
+ return true;
516
+ if (/^broad$/i.test(t))
517
+ return true;
518
+ if (/(?:eV|keV|MeV|meV|fs|fsec|ps|ns)\b/i.test(t))
519
+ return true;
520
+ if (/^[<≤≈~]/.test(t))
521
+ return true;
522
+ if (/^(?:Γ|τ)/i.test(t))
523
+ return true;
524
+ if (/^\|g\|/.test(t))
525
+ return true;
526
+ // Numeric value with optional ± (could be width in default units)
527
+ if (/^\d[\d.]*\s*(?:±\s*[\d.]+)?$/.test(stripFootnotes(t)))
528
+ return true;
529
+ // Scientific notation
530
+ if (/×\s*10/.test(t))
531
+ return true;
532
+ return false;
533
+ }
534
+ function looksLikeReactions(s) {
535
+ // Comma-separated numbers, possibly with parens
536
+ return /^[\d,\s()]+$/.test(s.trim()) && /\d/.test(s);
537
+ }
538
+ // ── Unified parser ────────────────────────────────────────────────────────────
539
+ function parseEntries(dataLines, meta) {
540
+ const rows = [];
541
+ const isOddA = meta.A % 2 === 1;
542
+ for (let i = 0; i < dataLines.length; i++) {
543
+ const line = dataLines[i];
544
+ const energyResult = isEnergyLine(line);
545
+ if (!energyResult)
546
+ continue;
547
+ const energy = parseEnergy(energyResult.energyStr);
548
+ if (!energy)
549
+ continue;
550
+ // Check line above for Jπ info
551
+ let jpiAbove = null;
552
+ if (i > 0) {
553
+ jpiAbove = isJpiInfoLine(dataLines[i - 1]);
554
+ }
555
+ // Assign columns from the energy line
556
+ const restCols = energyResult.restCols;
557
+ let jpiCol = '';
558
+ let widthCol = '';
559
+ let decayCol = '';
560
+ // Classify remaining columns by content
561
+ assignColumns(restCols, (j, w, d) => { jpiCol = j; widthCol = w; decayCol = d; });
562
+ // For even-A: check for "parity below" pattern
563
+ // If Jπ has no parity character, look at next data line for parity
564
+ if (!isOddA && jpiCol && !/[+\-−]/.test(jpiCol)) {
565
+ if (i + 1 < dataLines.length) {
566
+ const parity = isParityOnlyLine(dataLines[i + 1]);
567
+ if (parity) {
568
+ jpiAbove = parity; // pass as "above" — parseJpiT treats it the same
569
+ // If we had no width/decay on the energy line, look 2 lines ahead
570
+ if (!widthCol && !decayCol && i + 2 < dataLines.length) {
571
+ const belowLine = dataLines[i + 2];
572
+ if (!isEnergyLine(belowLine) && !isJpiInfoLine(belowLine)) {
573
+ const belowCols = splitColumns(belowLine);
574
+ // These are width + decay columns
575
+ for (const col of belowCols) {
576
+ const t = col.text.trim();
577
+ if (looksLikeReactions(t))
578
+ continue;
579
+ if (!widthCol && looksLikeWidth(t)) {
580
+ widthCol = t;
581
+ continue;
582
+ }
583
+ if (!decayCol && looksLikeDecay(t)) {
584
+ decayCol = t;
585
+ continue;
586
+ }
587
+ if (!widthCol) {
588
+ widthCol = t;
589
+ continue;
590
+ }
591
+ if (!decayCol) {
592
+ decayCol = t;
593
+ }
594
+ }
595
+ }
596
+ }
597
+ }
598
+ }
599
+ }
600
+ // For odd-A: check next line for ";T_denom + width + decay"
601
+ if (isOddA && i + 1 < dataLines.length) {
602
+ const nextLine = dataLines[i + 1];
603
+ if (!isJpiInfoLine(nextLine) && !isEnergyLine(nextLine)) {
604
+ const nextCols = splitColumns(nextLine);
605
+ let startIdx = 0;
606
+ // Check if first column is ";T_denom"
607
+ if (nextCols.length > 0 && /^;\s*\d+/.test(nextCols[0].text)) {
608
+ jpiCol = jpiCol + ' ' + nextCols[0].text;
609
+ startIdx = 1;
610
+ }
611
+ // Fill in missing width/decay from next line
612
+ for (let ci = startIdx; ci < nextCols.length; ci++) {
613
+ const t = nextCols[ci].text.trim();
614
+ if (looksLikeReactions(t))
615
+ continue;
616
+ if (!widthCol && looksLikeWidth(t)) {
617
+ widthCol = t;
618
+ continue;
619
+ }
620
+ if (!decayCol && looksLikeDecay(t)) {
621
+ decayCol = t;
622
+ continue;
623
+ }
624
+ if (!widthCol) {
625
+ widthCol = t;
626
+ continue;
627
+ }
628
+ if (!decayCol) {
629
+ decayCol = t;
630
+ }
631
+ }
632
+ }
633
+ }
634
+ // For even-A with no width/decay on energy line and no parity-below,
635
+ // still check next line for width/decay
636
+ if (!isOddA && !widthCol && !decayCol && i + 1 < dataLines.length) {
637
+ const nextLine = dataLines[i + 1];
638
+ if (!isEnergyLine(nextLine) && !isJpiInfoLine(nextLine) && !isParityOnlyLine(nextLine)) {
639
+ // Check if it could be a width/decay continuation
640
+ const leadSpace = nextLine.match(/^(\s*)/)?.[1]?.length ?? 0;
641
+ if (leadSpace >= 25) {
642
+ const nextCols = splitColumns(nextLine);
643
+ for (const col of nextCols) {
644
+ const t = col.text.trim();
645
+ if (looksLikeReactions(t))
646
+ continue;
647
+ if (!widthCol && looksLikeWidth(t)) {
648
+ widthCol = t;
649
+ continue;
650
+ }
651
+ if (!decayCol && looksLikeDecay(t)) {
652
+ decayCol = t;
653
+ continue;
654
+ }
655
+ if (!widthCol) {
656
+ widthCol = t;
657
+ continue;
658
+ }
659
+ if (!decayCol) {
660
+ decayCol = t;
661
+ }
662
+ }
663
+ }
664
+ }
665
+ }
666
+ // Parse Jπ;T
667
+ const jpiT = parseJpiT(jpiCol, jpiAbove, isOddA);
668
+ // Parse width
669
+ const width = parseWidth(widthCol, meta.defaultWidthUnit);
670
+ // Parse decay modes
671
+ let halfLife = width.half_life;
672
+ let decayModes = parseDecayModes(decayCol);
673
+ if (/stable/i.test(decayCol))
674
+ halfLife = 'stable';
675
+ if (width.half_life === 'stable' && !decayModes)
676
+ decayModes = 'stable';
677
+ // Check if width column had "stable" text (as in "stable" being in the Γ column)
678
+ if (/stable/i.test(widthCol) && !decayModes)
679
+ decayModes = 'stable';
680
+ rows.push({
681
+ Z: meta.Z,
682
+ A: meta.A,
683
+ element: meta.element,
684
+ energy_keV: energy.energy_keV,
685
+ energy_unc_keV: energy.energy_unc_keV,
686
+ energy_raw: energy.energy_raw,
687
+ spin_parity: jpiT.spin_parity,
688
+ isospin: jpiT.isospin,
689
+ width_keV: width.width_keV,
690
+ width_unc_keV: width.width_unc_keV,
691
+ width_raw: width.width_raw,
692
+ width_relation: width.width_relation,
693
+ half_life: halfLife,
694
+ decay_modes: decayModes,
695
+ evaluation: meta.evaluation,
696
+ table_label: meta.sourceTable,
697
+ });
698
+ }
699
+ return rows;
700
+ }
701
+ /**
702
+ * Assign restCols to jpi, width, decay slots using content classification.
703
+ * Columns appear in order: Jπ;T, Γ, Decay, Reactions.
704
+ * Some may be absent, causing later columns to shift left.
705
+ * The first column is Jπ;T ONLY if it looks like a Jπ value.
706
+ */
707
+ function assignColumns(cols, assign) {
708
+ let jpi = '';
709
+ let width = '';
710
+ let decay = '';
711
+ if (cols.length === 0) {
712
+ assign('', '', '');
713
+ return;
714
+ }
715
+ let startIdx = 0;
716
+ const firstText = cols[0].text.trim();
717
+ // First column is Jπ;T only if it actually looks like one
718
+ if (looksLikeJpi(firstText)) {
719
+ jpi = cols[0].text;
720
+ startIdx = 1;
721
+ }
722
+ // Process remaining columns
723
+ for (let i = startIdx; i < cols.length; i++) {
724
+ const text = cols[i].text.trim();
725
+ if (looksLikeReactions(text))
726
+ break; // everything after is reactions
727
+ if (!width && (looksLikeWidth(text) || (!jpi && !looksLikeDecay(text)))) {
728
+ width = text;
729
+ continue;
730
+ }
731
+ if (!decay && looksLikeDecay(text)) {
732
+ decay = text;
733
+ continue;
734
+ }
735
+ // Ambiguous — assign to first empty slot
736
+ if (!width) {
737
+ width = text;
738
+ continue;
739
+ }
740
+ if (!decay) {
741
+ decay = text;
742
+ continue;
743
+ }
744
+ }
745
+ assign(jpi, width, decay);
746
+ }
747
+ // ── Main entry point ──────────────────────────────────────────────────────────
748
+ export function parseTunlLevels(text) {
749
+ const meta = parseTableHeader(text);
750
+ if (!meta) {
751
+ console.error('TUNL: could not parse table header');
752
+ return [];
753
+ }
754
+ const dataLines = extractDataLines(text);
755
+ if (dataLines.length === 0)
756
+ return [];
757
+ return parseEntries(dataLines, meta);
758
+ }
759
+ export function parseTunlLevelsWithMeta(text, Z, A, element, evaluation, sourceTable) {
760
+ const meta = {
761
+ sourceTable,
762
+ evaluation,
763
+ A,
764
+ element,
765
+ Z,
766
+ defaultWidthUnit: detectWidthUnit(text),
767
+ };
768
+ const dataLines = extractDataLines(text);
769
+ if (dataLines.length === 0)
770
+ return [];
771
+ return parseEntries(dataLines, meta);
772
+ }
773
+ //# sourceMappingURL=parseTunl.js.map