seqeyes-python 0.3.1__tar.gz → 0.3.3__tar.gz
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.
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/PKG-INFO +1 -1
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/pyproject.toml +1 -1
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/src/seqeyes/__init__.py +1 -1
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/src/seqeyes/resources/pulseq-bundle.js +369 -134
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/src/seqeyes/resources/viewer.html +56 -1
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/src/seqeyes/resources/webview-bundle.js +59 -2
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/.gitignore +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/README.md +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/examples/demo.ipynb +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/examples/demo.py +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/src/seqeyes/_plot.py +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/src/seqeyes/_renderer.py +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/tests/conftest.py +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/tests/debug_full.html +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/tests/debug_minimal.html +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/tests/debug_viewer.html +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/tests/debug_viewer.py +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/tests/integration_test.py +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/tests/test_plot.py +0 -0
- {seqeyes_python-0.3.1 → seqeyes_python-0.3.3}/tests/test_renderer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: seqeyes-python
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq.
|
|
5
5
|
Project-URL: Homepage, https://github.com/bughht/seqeyes_plugin
|
|
6
6
|
Project-URL: Repository, https://github.com/bughht/seqeyes_plugin
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "seqeyes-python"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -24,7 +24,11 @@ var Pulseq = (() => {
|
|
|
24
24
|
var pulseq_browser_exports = {};
|
|
25
25
|
__export(pulseq_browser_exports, {
|
|
26
26
|
INTERACTIVE_COMPUTE_LIMITS: () => INTERACTIVE_COMPUTE_LIMITS,
|
|
27
|
+
MAX_RF_RESPONSE_BANDS: () => MAX_RF_RESPONSE_BANDS,
|
|
28
|
+
MAX_RF_RESPONSE_FFT_POINTS: () => MAX_RF_RESPONSE_FFT_POINTS,
|
|
29
|
+
MAX_RF_RESPONSE_SAMPLES: () => MAX_RF_RESPONSE_SAMPLES,
|
|
27
30
|
PACKAGE_VERSION: () => PACKAGE_VERSION,
|
|
31
|
+
analyzeRfResponse: () => analyzeRfResponse,
|
|
28
32
|
audioBudgetRefusal: () => audioBudgetRefusal,
|
|
29
33
|
calculateKspace: () => calculateKspace,
|
|
30
34
|
calculateM1: () => calculateM1,
|
|
@@ -47,6 +51,7 @@ var Pulseq = (() => {
|
|
|
47
51
|
estimateDerivedCost: () => estimateDerivedCost,
|
|
48
52
|
estimateKspaceCost: () => estimateKspaceCost,
|
|
49
53
|
estimateKspacePeakMemoryBytes: () => estimateKspacePeakMemoryBytes,
|
|
54
|
+
estimateRfCarrierAreaDeg: () => estimateRfCarrierAreaDeg,
|
|
50
55
|
estimateSequenceKspaceCost: () => estimateSequenceKspaceCost,
|
|
51
56
|
estimateSpectrogramCost: () => estimateSpectrogramCost,
|
|
52
57
|
exportKspaceArtifacts: () => exportKspaceArtifacts,
|
|
@@ -81,7 +86,7 @@ var Pulseq = (() => {
|
|
|
81
86
|
});
|
|
82
87
|
|
|
83
88
|
// package.json
|
|
84
|
-
var version = "0.3.
|
|
89
|
+
var version = "0.3.3";
|
|
85
90
|
|
|
86
91
|
// src/pulseq/decompressor.ts
|
|
87
92
|
function decompressShape(compressed, numSamples) {
|
|
@@ -1522,6 +1527,334 @@ var Pulseq = (() => {
|
|
|
1522
1527
|
return DEFAULT_B0_T;
|
|
1523
1528
|
}
|
|
1524
1529
|
|
|
1530
|
+
// src/pulseq/fft.ts
|
|
1531
|
+
var twiddleCache = /* @__PURE__ */ new Map();
|
|
1532
|
+
var hannCache = /* @__PURE__ */ new Map();
|
|
1533
|
+
function isPowerOfTwo(n) {
|
|
1534
|
+
return n > 0 && (n & n - 1) === 0;
|
|
1535
|
+
}
|
|
1536
|
+
function nextPowerOfTwo(n) {
|
|
1537
|
+
if (n <= 1) return 1;
|
|
1538
|
+
let p = 1;
|
|
1539
|
+
while (p < n) p *= 2;
|
|
1540
|
+
return p;
|
|
1541
|
+
}
|
|
1542
|
+
function previousPowerOfTwo(n) {
|
|
1543
|
+
if (n <= 1) return 1;
|
|
1544
|
+
let p = 1;
|
|
1545
|
+
while (p * 2 <= n) p *= 2;
|
|
1546
|
+
return p;
|
|
1547
|
+
}
|
|
1548
|
+
function getTwiddles(n) {
|
|
1549
|
+
const cached = twiddleCache.get(n);
|
|
1550
|
+
if (cached) return cached;
|
|
1551
|
+
if (!isPowerOfTwo(n)) throw new Error(`FFT size must be a power of two, got ${n}`);
|
|
1552
|
+
const cos = new Float64Array(n / 2);
|
|
1553
|
+
const sin = new Float64Array(n / 2);
|
|
1554
|
+
for (let i = 0; i < n / 2; i++) {
|
|
1555
|
+
const angle = -2 * Math.PI * i / n;
|
|
1556
|
+
cos[i] = Math.cos(angle);
|
|
1557
|
+
sin[i] = Math.sin(angle);
|
|
1558
|
+
}
|
|
1559
|
+
const bits = Math.round(Math.log2(n));
|
|
1560
|
+
const reverse = new Uint32Array(n);
|
|
1561
|
+
for (let i = 0; i < n; i++) {
|
|
1562
|
+
let r = 0;
|
|
1563
|
+
for (let b = 0; b < bits; b++) if (i & 1 << b) r |= 1 << bits - 1 - b;
|
|
1564
|
+
reverse[i] = r;
|
|
1565
|
+
}
|
|
1566
|
+
const table = { cos, sin, reverse };
|
|
1567
|
+
twiddleCache.set(n, table);
|
|
1568
|
+
return table;
|
|
1569
|
+
}
|
|
1570
|
+
function fftInPlace(re, im, n) {
|
|
1571
|
+
const { cos, sin, reverse } = getTwiddles(n);
|
|
1572
|
+
for (let i = 0; i < n; i++) {
|
|
1573
|
+
const j = reverse[i];
|
|
1574
|
+
if (j > i) {
|
|
1575
|
+
let tmp = re[i];
|
|
1576
|
+
re[i] = re[j];
|
|
1577
|
+
re[j] = tmp;
|
|
1578
|
+
tmp = im[i];
|
|
1579
|
+
im[i] = im[j];
|
|
1580
|
+
im[j] = tmp;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
for (let size = 2; size <= n; size *= 2) {
|
|
1584
|
+
const half = size / 2;
|
|
1585
|
+
const step = n / size;
|
|
1586
|
+
for (let start = 0; start < n; start += size) {
|
|
1587
|
+
for (let k = 0; k < half; k++) {
|
|
1588
|
+
const twiddleIndex = k * step;
|
|
1589
|
+
const wr = cos[twiddleIndex];
|
|
1590
|
+
const wi = sin[twiddleIndex];
|
|
1591
|
+
const a = start + k;
|
|
1592
|
+
const b = a + half;
|
|
1593
|
+
const xr = re[b] * wr - im[b] * wi;
|
|
1594
|
+
const xi = re[b] * wi + im[b] * wr;
|
|
1595
|
+
re[b] = re[a] - xr;
|
|
1596
|
+
im[b] = im[a] - xi;
|
|
1597
|
+
re[a] += xr;
|
|
1598
|
+
im[a] += xi;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
function realFFTPairMagnitude(a, b, n, scratchRe, scratchIm, outA, outB) {
|
|
1604
|
+
for (let i = 0; i < n; i++) {
|
|
1605
|
+
scratchRe[i] = a[i];
|
|
1606
|
+
scratchIm[i] = b[i];
|
|
1607
|
+
}
|
|
1608
|
+
fftInPlace(scratchRe, scratchIm, n);
|
|
1609
|
+
const half = n >> 1;
|
|
1610
|
+
for (let k = 0; k <= half; k++) {
|
|
1611
|
+
const j = (n - k) % n;
|
|
1612
|
+
const zr = scratchRe[k], zi = scratchIm[k];
|
|
1613
|
+
const cr = scratchRe[j], ci = -scratchIm[j];
|
|
1614
|
+
const ar = 0.5 * (zr + cr);
|
|
1615
|
+
const ai = 0.5 * (zi + ci);
|
|
1616
|
+
const br = 0.5 * (zi - ci);
|
|
1617
|
+
const bi = -0.5 * (zr - cr);
|
|
1618
|
+
outA[k] = Math.sqrt(ar * ar + ai * ai);
|
|
1619
|
+
outB[k] = Math.sqrt(br * br + bi * bi);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function realFFTMagnitude(a, n, scratchRe, scratchIm, out) {
|
|
1623
|
+
for (let i = 0; i < n; i++) {
|
|
1624
|
+
scratchRe[i] = a[i];
|
|
1625
|
+
scratchIm[i] = 0;
|
|
1626
|
+
}
|
|
1627
|
+
fftInPlace(scratchRe, scratchIm, n);
|
|
1628
|
+
const half = n >> 1;
|
|
1629
|
+
for (let k = 0; k <= half; k++) {
|
|
1630
|
+
const re = scratchRe[k], im = scratchIm[k];
|
|
1631
|
+
out[k] = Math.sqrt(re * re + im * im);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
function hannWindow(n) {
|
|
1635
|
+
const cached = hannCache.get(n);
|
|
1636
|
+
if (cached) return cached;
|
|
1637
|
+
const w = new Float64Array(n);
|
|
1638
|
+
for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * (i + 1) / n));
|
|
1639
|
+
hannCache.set(n, w);
|
|
1640
|
+
return w;
|
|
1641
|
+
}
|
|
1642
|
+
function windowCoherentGain(w) {
|
|
1643
|
+
let sum = 0;
|
|
1644
|
+
for (let i = 0; i < w.length; i++) sum += w[i];
|
|
1645
|
+
return sum;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/pulseq/rfResponse.ts
|
|
1649
|
+
var MAX_RF_RESPONSE_FFT_POINTS = 131072;
|
|
1650
|
+
var MAX_RF_RESPONSE_SAMPLES = 131072;
|
|
1651
|
+
var MAX_RF_RESPONSE_BANDS = 8;
|
|
1652
|
+
var MIN_FFT_POINTS = 64;
|
|
1653
|
+
var ZERO_PAD_FACTOR = 4;
|
|
1654
|
+
var DOMINANT_BAND_FRACTION = 0.5;
|
|
1655
|
+
var MIN_DOMINANT_AREA_DEG = 0.01;
|
|
1656
|
+
var DEG_PER_CYCLE = 360;
|
|
1657
|
+
var TAU = 2 * Math.PI;
|
|
1658
|
+
function analyzeRfResponse(rf, seq, classifiedUse = rf.use) {
|
|
1659
|
+
if (rfSampleCount(rf, seq) > MAX_RF_RESPONSE_SAMPLES) {
|
|
1660
|
+
return {
|
|
1661
|
+
carrierAreaDeg: estimateRfCarrierAreaDeg(rf, seq),
|
|
1662
|
+
bands: [],
|
|
1663
|
+
spectrumAnalyzed: false,
|
|
1664
|
+
limited: true
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
const samples = buildComplexRfSamples(rf, seq);
|
|
1668
|
+
const carrierAreaDeg = frequencyResolvedAreaDeg(samples, 0);
|
|
1669
|
+
const normalizedUse = classifiedUse.toLowerCase();
|
|
1670
|
+
const inversion = normalizedUse === "i" || normalizedUse === "inversion";
|
|
1671
|
+
let offsets;
|
|
1672
|
+
let spectrumAnalyzed = false;
|
|
1673
|
+
let limited = false;
|
|
1674
|
+
if (inversion) {
|
|
1675
|
+
offsets = [0];
|
|
1676
|
+
} else if (samples.uniform && samples.real.length <= MAX_RF_RESPONSE_FFT_POINTS) {
|
|
1677
|
+
offsets = dominantBandOffsets(samples);
|
|
1678
|
+
spectrumAnalyzed = true;
|
|
1679
|
+
} else {
|
|
1680
|
+
offsets = [0];
|
|
1681
|
+
limited = true;
|
|
1682
|
+
}
|
|
1683
|
+
const bands = offsets.map((frequencyOffsetHz) => {
|
|
1684
|
+
const spectralAreaDeg = frequencyResolvedAreaDeg(samples, frequencyOffsetHz);
|
|
1685
|
+
const spinor = propagateSpinor(samples, frequencyOffsetHz);
|
|
1686
|
+
return {
|
|
1687
|
+
frequencyOffsetHz,
|
|
1688
|
+
spectralAreaDeg,
|
|
1689
|
+
polarFlipDeg: spinor.polarFlipDeg,
|
|
1690
|
+
mz: spinor.mz
|
|
1691
|
+
};
|
|
1692
|
+
});
|
|
1693
|
+
return { carrierAreaDeg, bands, spectrumAnalyzed, limited };
|
|
1694
|
+
}
|
|
1695
|
+
function estimateRfCarrierAreaDeg(rf, seq) {
|
|
1696
|
+
const magnitude = seq.shapes.get(rf.magShapeId);
|
|
1697
|
+
if (!magnitude || magnitude.numSamples < 1) return 0;
|
|
1698
|
+
const phase = seq.shapes.get(rf.phaseShapeId);
|
|
1699
|
+
const time = rf.timeShapeId > 0 ? seq.shapes.get(rf.timeShapeId) : void 0;
|
|
1700
|
+
const count = rfSampleCount(rf, seq);
|
|
1701
|
+
const raster = seq.rasterTimes.rfRaster;
|
|
1702
|
+
let realArea = 0;
|
|
1703
|
+
let imaginaryArea = 0;
|
|
1704
|
+
for (let index = 0; index < count; index++) {
|
|
1705
|
+
const sampleTime = time ? time.samples[index] * raster : (index + 0.5) * raster;
|
|
1706
|
+
const nextTime = time && index + 1 < count ? time.samples[index + 1] * raster : sampleTime + raster;
|
|
1707
|
+
const width = nextTime - sampleTime;
|
|
1708
|
+
const amplitude = rf.amplitude * magnitude.samples[index];
|
|
1709
|
+
const phaseRad = TAU * (phase?.samples[index] ?? 0);
|
|
1710
|
+
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(amplitude) || !Number.isFinite(phaseRad)) continue;
|
|
1711
|
+
realArea += amplitude * Math.cos(phaseRad) * width;
|
|
1712
|
+
imaginaryArea += amplitude * Math.sin(phaseRad) * width;
|
|
1713
|
+
}
|
|
1714
|
+
return DEG_PER_CYCLE * Math.hypot(realArea, imaginaryArea);
|
|
1715
|
+
}
|
|
1716
|
+
function rfSampleCount(rf, seq) {
|
|
1717
|
+
const magnitude = seq.shapes.get(rf.magShapeId);
|
|
1718
|
+
if (!magnitude) return 0;
|
|
1719
|
+
const phase = seq.shapes.get(rf.phaseShapeId);
|
|
1720
|
+
const time = rf.timeShapeId > 0 ? seq.shapes.get(rf.timeShapeId) : void 0;
|
|
1721
|
+
return Math.min(
|
|
1722
|
+
magnitude.numSamples,
|
|
1723
|
+
phase?.numSamples ?? magnitude.numSamples,
|
|
1724
|
+
time?.numSamples ?? magnitude.numSamples
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
function buildComplexRfSamples(rf, seq) {
|
|
1728
|
+
const magnitude = seq.shapes.get(rf.magShapeId);
|
|
1729
|
+
if (!magnitude || magnitude.numSamples < 1) return emptySamples(seq.rasterTimes.rfRaster);
|
|
1730
|
+
const phase = seq.shapes.get(rf.phaseShapeId);
|
|
1731
|
+
const time = rf.timeShapeId > 0 ? seq.shapes.get(rf.timeShapeId) : void 0;
|
|
1732
|
+
const count = Math.min(
|
|
1733
|
+
magnitude.numSamples,
|
|
1734
|
+
phase?.numSamples ?? magnitude.numSamples,
|
|
1735
|
+
time?.numSamples ?? magnitude.numSamples
|
|
1736
|
+
);
|
|
1737
|
+
if (count < 1) return emptySamples(seq.rasterTimes.rfRaster);
|
|
1738
|
+
const raster = seq.rasterTimes.rfRaster;
|
|
1739
|
+
const real = new Float64Array(count);
|
|
1740
|
+
const imaginary = new Float64Array(count);
|
|
1741
|
+
const times = new Float64Array(count);
|
|
1742
|
+
const widths = new Float64Array(count);
|
|
1743
|
+
let uniform = !time;
|
|
1744
|
+
for (let index = 0; index < count; index++) {
|
|
1745
|
+
const sampleTime = time ? time.samples[index] * raster : (index + 0.5) * raster;
|
|
1746
|
+
const nextTime = time && index + 1 < count ? time.samples[index + 1] * raster : sampleTime + raster;
|
|
1747
|
+
const width = nextTime - sampleTime;
|
|
1748
|
+
const amplitude = rf.amplitude * magnitude.samples[index];
|
|
1749
|
+
const phaseRad = TAU * (phase?.samples[index] ?? 0);
|
|
1750
|
+
times[index] = Number.isFinite(sampleTime) ? sampleTime : 0;
|
|
1751
|
+
widths[index] = Number.isFinite(width) && width > 0 ? width : 0;
|
|
1752
|
+
real[index] = Number.isFinite(amplitude) && Number.isFinite(phaseRad) ? amplitude * Math.cos(phaseRad) : 0;
|
|
1753
|
+
imaginary[index] = Number.isFinite(amplitude) && Number.isFinite(phaseRad) ? amplitude * Math.sin(phaseRad) : 0;
|
|
1754
|
+
if (Math.abs(widths[index] - raster) > Math.max(1e-12, raster * 1e-6)) uniform = false;
|
|
1755
|
+
}
|
|
1756
|
+
return { real, imaginary, times, widths, uniform, dwell: raster };
|
|
1757
|
+
}
|
|
1758
|
+
function emptySamples(raster) {
|
|
1759
|
+
return {
|
|
1760
|
+
real: new Float64Array(0),
|
|
1761
|
+
imaginary: new Float64Array(0),
|
|
1762
|
+
times: new Float64Array(0),
|
|
1763
|
+
widths: new Float64Array(0),
|
|
1764
|
+
uniform: true,
|
|
1765
|
+
dwell: raster
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
function frequencyResolvedAreaDeg(samples, frequencyOffsetHz) {
|
|
1769
|
+
let realArea = 0;
|
|
1770
|
+
let imaginaryArea = 0;
|
|
1771
|
+
for (let index = 0; index < samples.real.length; index++) {
|
|
1772
|
+
const angle = -TAU * frequencyOffsetHz * samples.times[index];
|
|
1773
|
+
const cosine = Math.cos(angle);
|
|
1774
|
+
const sine = Math.sin(angle);
|
|
1775
|
+
const width = samples.widths[index];
|
|
1776
|
+
realArea += (samples.real[index] * cosine - samples.imaginary[index] * sine) * width;
|
|
1777
|
+
imaginaryArea += (samples.real[index] * sine + samples.imaginary[index] * cosine) * width;
|
|
1778
|
+
}
|
|
1779
|
+
return DEG_PER_CYCLE * Math.hypot(realArea, imaginaryArea);
|
|
1780
|
+
}
|
|
1781
|
+
function dominantBandOffsets(samples) {
|
|
1782
|
+
const sampleCount = samples.real.length;
|
|
1783
|
+
if (sampleCount === 0 || !Number.isFinite(samples.dwell) || samples.dwell <= 0) return [0];
|
|
1784
|
+
const paddedTarget = sampleCount <= Math.floor(MAX_RF_RESPONSE_FFT_POINTS / ZERO_PAD_FACTOR) ? sampleCount * ZERO_PAD_FACTOR : sampleCount;
|
|
1785
|
+
const fftPoints = nextPowerOfTwo(Math.max(MIN_FFT_POINTS, paddedTarget));
|
|
1786
|
+
if (fftPoints > MAX_RF_RESPONSE_FFT_POINTS) return [0];
|
|
1787
|
+
const real = new Float64Array(fftPoints);
|
|
1788
|
+
const imaginary = new Float64Array(fftPoints);
|
|
1789
|
+
real.set(samples.real);
|
|
1790
|
+
imaginary.set(samples.imaginary);
|
|
1791
|
+
fftInPlace(real, imaginary, fftPoints);
|
|
1792
|
+
let peakAreaDeg = 0;
|
|
1793
|
+
for (let bin = 0; bin < fftPoints; bin++) {
|
|
1794
|
+
peakAreaDeg = Math.max(
|
|
1795
|
+
peakAreaDeg,
|
|
1796
|
+
DEG_PER_CYCLE * samples.dwell * Math.hypot(real[bin], imaginary[bin])
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
if (!Number.isFinite(peakAreaDeg) || peakAreaDeg < MIN_DOMINANT_AREA_DEG) return [0];
|
|
1800
|
+
const threshold = Math.max(MIN_DOMINANT_AREA_DEG, peakAreaDeg * DOMINANT_BAND_FRACTION);
|
|
1801
|
+
const clusters = [];
|
|
1802
|
+
let active = null;
|
|
1803
|
+
const half = fftPoints / 2;
|
|
1804
|
+
const frequencyStep = 1 / (fftPoints * samples.dwell);
|
|
1805
|
+
for (let signedBin = -half; signedBin < half; signedBin++) {
|
|
1806
|
+
const bin = signedBin < 0 ? signedBin + fftPoints : signedBin;
|
|
1807
|
+
const areaDeg = DEG_PER_CYCLE * samples.dwell * Math.hypot(real[bin], imaginary[bin]);
|
|
1808
|
+
if (areaDeg >= threshold) {
|
|
1809
|
+
const weight = areaDeg * areaDeg;
|
|
1810
|
+
if (!active) active = { frequencySum: 0, weightSum: 0, peakAreaDeg: 0 };
|
|
1811
|
+
active.frequencySum += signedBin * frequencyStep * weight;
|
|
1812
|
+
active.weightSum += weight;
|
|
1813
|
+
active.peakAreaDeg = Math.max(active.peakAreaDeg, areaDeg);
|
|
1814
|
+
} else if (active) {
|
|
1815
|
+
clusters.push(active);
|
|
1816
|
+
active = null;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
if (active) clusters.push(active);
|
|
1820
|
+
const offsets = clusters.filter((cluster) => cluster.weightSum > 0).sort((left, right) => right.peakAreaDeg - left.peakAreaDeg).slice(0, MAX_RF_RESPONSE_BANDS).map((cluster) => cluster.frequencySum / cluster.weightSum).sort((left, right) => left - right);
|
|
1821
|
+
return offsets.length > 0 ? offsets : [0];
|
|
1822
|
+
}
|
|
1823
|
+
function propagateSpinor(samples, frequencyOffsetHz) {
|
|
1824
|
+
let stateAReal = 1;
|
|
1825
|
+
let stateAImaginary = 0;
|
|
1826
|
+
let stateBReal = 0;
|
|
1827
|
+
let stateBImaginary = 0;
|
|
1828
|
+
for (let index = 0; index < samples.real.length; index++) {
|
|
1829
|
+
const bx = samples.real[index];
|
|
1830
|
+
const by = samples.imaginary[index];
|
|
1831
|
+
const norm = Math.hypot(bx, by, frequencyOffsetHz);
|
|
1832
|
+
const width = samples.widths[index];
|
|
1833
|
+
if (!(norm > 0) || !(width > 0)) continue;
|
|
1834
|
+
const sine = Math.sin(Math.PI * norm * width);
|
|
1835
|
+
const localAReal = Math.cos(Math.PI * norm * width);
|
|
1836
|
+
const localAImaginary = -frequencyOffsetHz / norm * sine;
|
|
1837
|
+
const localBReal = by / norm * sine;
|
|
1838
|
+
const localBImaginary = -bx / norm * sine;
|
|
1839
|
+
const nextAReal = localAReal * stateAReal - localAImaginary * stateAImaginary - localBReal * stateBReal - localBImaginary * stateBImaginary;
|
|
1840
|
+
const nextAImaginary = localAReal * stateAImaginary + localAImaginary * stateAReal - localBReal * stateBImaginary + localBImaginary * stateBReal;
|
|
1841
|
+
const nextBReal = localBReal * stateAReal - localBImaginary * stateAImaginary + localAReal * stateBReal + localAImaginary * stateBImaginary;
|
|
1842
|
+
const nextBImaginary = localBReal * stateAImaginary + localBImaginary * stateAReal + localAReal * stateBImaginary - localAImaginary * stateBReal;
|
|
1843
|
+
stateAReal = nextAReal;
|
|
1844
|
+
stateAImaginary = nextAImaginary;
|
|
1845
|
+
stateBReal = nextBReal;
|
|
1846
|
+
stateBImaginary = nextBImaginary;
|
|
1847
|
+
}
|
|
1848
|
+
const aMagnitudeSquared = stateAReal * stateAReal + stateAImaginary * stateAImaginary;
|
|
1849
|
+
const bMagnitudeSquared = stateBReal * stateBReal + stateBImaginary * stateBImaginary;
|
|
1850
|
+
const normalization = aMagnitudeSquared + bMagnitudeSquared;
|
|
1851
|
+
const mz = normalization > 0 ? clamp((aMagnitudeSquared - bMagnitudeSquared) / normalization, -1, 1) : 1;
|
|
1852
|
+
return { mz, polarFlipDeg: Math.acos(mz) * 180 / Math.PI };
|
|
1853
|
+
}
|
|
1854
|
+
function clamp(value, minimum, maximum) {
|
|
1855
|
+
return Math.max(minimum, Math.min(maximum, value));
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1525
1858
|
// src/pulseq/decoder.ts
|
|
1526
1859
|
var GAMMA_HZ_T2 = 42576e3;
|
|
1527
1860
|
var DEFAULT_B0_T2 = 3;
|
|
@@ -1550,6 +1883,7 @@ var Pulseq = (() => {
|
|
|
1550
1883
|
sequence: seq,
|
|
1551
1884
|
blockStartTimes,
|
|
1552
1885
|
classifiedRfUses: classifyRfUses(seq),
|
|
1886
|
+
rfResponseCache: /* @__PURE__ */ new Map(),
|
|
1553
1887
|
triggerCache: /* @__PURE__ */ new Map(),
|
|
1554
1888
|
ncoCache: /* @__PURE__ */ new Map()
|
|
1555
1889
|
};
|
|
@@ -1568,7 +1902,15 @@ var Pulseq = (() => {
|
|
|
1568
1902
|
const db = { index: block.num, duration: dur, startTime: cumulative };
|
|
1569
1903
|
if (block.rfId > 0) {
|
|
1570
1904
|
const rf = seq.rfs.get(block.rfId);
|
|
1571
|
-
if (rf)
|
|
1905
|
+
if (rf) {
|
|
1906
|
+
const use = context.classifiedRfUses[i];
|
|
1907
|
+
let response = context.rfResponseCache.get(rf.id);
|
|
1908
|
+
if (!response) {
|
|
1909
|
+
response = analyzeRfResponse(rf, seq, use);
|
|
1910
|
+
context.rfResponseCache.set(rf.id, response);
|
|
1911
|
+
}
|
|
1912
|
+
db.rf = decodeRF(seq, rf, cumulative, dur, use, response);
|
|
1913
|
+
}
|
|
1572
1914
|
}
|
|
1573
1915
|
db.gx = decodeGradient(seq, block.gxId, cumulative, dur, "gx");
|
|
1574
1916
|
db.gy = decodeGradient(seq, block.gyId, cumulative, dur, "gy");
|
|
@@ -1597,7 +1939,7 @@ var Pulseq = (() => {
|
|
|
1597
1939
|
if (seq.versionCombined < VER_PRE_14) return block.dur * 1e-6;
|
|
1598
1940
|
return block.dur * seq.rasterTimes.blockDurationRaster;
|
|
1599
1941
|
}
|
|
1600
|
-
function decodeRF(seq, rf, blockStart, _blockDur, classifiedUse) {
|
|
1942
|
+
function decodeRF(seq, rf, blockStart, _blockDur, classifiedUse, response) {
|
|
1601
1943
|
const raster = seq.rasterTimes.rfRaster;
|
|
1602
1944
|
const rfDelay = rf.delay * 1e-6;
|
|
1603
1945
|
const rfStart = blockStart + rfDelay;
|
|
@@ -1632,6 +1974,7 @@ var Pulseq = (() => {
|
|
|
1632
1974
|
magnitude: amp,
|
|
1633
1975
|
phase,
|
|
1634
1976
|
amplitude: rf.amplitude,
|
|
1977
|
+
response,
|
|
1635
1978
|
freqOffset: freqFull,
|
|
1636
1979
|
phaseOffset: phaseFull,
|
|
1637
1980
|
use
|
|
@@ -2143,7 +2486,7 @@ var Pulseq = (() => {
|
|
|
2143
2486
|
// src/editor/blockTransport.ts
|
|
2144
2487
|
var MAX_DISPLAY_PTS = 500;
|
|
2145
2488
|
var MIN_DISPLAY_PTS = 8;
|
|
2146
|
-
var
|
|
2489
|
+
var TAU2 = 2 * Math.PI;
|
|
2147
2490
|
var EMPTY_PAIR = { o: 0, n: 0 };
|
|
2148
2491
|
var CountingSink = class {
|
|
2149
2492
|
constructor(cap) {
|
|
@@ -2167,7 +2510,7 @@ var Pulseq = (() => {
|
|
|
2167
2510
|
/** Hoisted so the hot path allocates one closure, not one per waveform. */
|
|
2168
2511
|
__publicField(this, "emit", (time, value) => {
|
|
2169
2512
|
this.times[this.cursor] = time;
|
|
2170
|
-
this.values[this.cursor] = this.wrapPhase ? (value %
|
|
2513
|
+
this.values[this.cursor] = this.wrapPhase ? (value % TAU2 + TAU2) % TAU2 : value;
|
|
2171
2514
|
this.cursor++;
|
|
2172
2515
|
});
|
|
2173
2516
|
}
|
|
@@ -2309,6 +2652,12 @@ var Pulseq = (() => {
|
|
|
2309
2652
|
}
|
|
2310
2653
|
function packRf(rf, magnitude, phase) {
|
|
2311
2654
|
const metrics = waveformMagnitudeMetrics(rf.timePoints, rf.magnitude);
|
|
2655
|
+
const responseBands = rf.response.bands.map((band) => [
|
|
2656
|
+
band.frequencyOffsetHz,
|
|
2657
|
+
band.spectralAreaDeg,
|
|
2658
|
+
band.polarFlipDeg,
|
|
2659
|
+
band.mz
|
|
2660
|
+
]);
|
|
2312
2661
|
return {
|
|
2313
2662
|
s: rf.startTime,
|
|
2314
2663
|
d: rf.duration,
|
|
@@ -2321,6 +2670,10 @@ var Pulseq = (() => {
|
|
|
2321
2670
|
ar: metrics.area,
|
|
2322
2671
|
bp: metrics.blockPulse,
|
|
2323
2672
|
a: rf.amplitude,
|
|
2673
|
+
a0: rf.response.carrierAreaDeg,
|
|
2674
|
+
rb: responseBands,
|
|
2675
|
+
rs: rf.response.spectrumAnalyzed ? 1 : 0,
|
|
2676
|
+
rl: rf.response.limited ? 1 : 0,
|
|
2324
2677
|
fo: rf.freqOffset,
|
|
2325
2678
|
po: rf.phaseOffset,
|
|
2326
2679
|
u: rf.use || "u"
|
|
@@ -3919,124 +4272,6 @@ var Pulseq = (() => {
|
|
|
3919
4272
|
return outside;
|
|
3920
4273
|
}
|
|
3921
4274
|
|
|
3922
|
-
// src/pulseq/fft.ts
|
|
3923
|
-
var twiddleCache = /* @__PURE__ */ new Map();
|
|
3924
|
-
var hannCache = /* @__PURE__ */ new Map();
|
|
3925
|
-
function isPowerOfTwo(n) {
|
|
3926
|
-
return n > 0 && (n & n - 1) === 0;
|
|
3927
|
-
}
|
|
3928
|
-
function nextPowerOfTwo(n) {
|
|
3929
|
-
if (n <= 1) return 1;
|
|
3930
|
-
let p = 1;
|
|
3931
|
-
while (p < n) p *= 2;
|
|
3932
|
-
return p;
|
|
3933
|
-
}
|
|
3934
|
-
function previousPowerOfTwo(n) {
|
|
3935
|
-
if (n <= 1) return 1;
|
|
3936
|
-
let p = 1;
|
|
3937
|
-
while (p * 2 <= n) p *= 2;
|
|
3938
|
-
return p;
|
|
3939
|
-
}
|
|
3940
|
-
function getTwiddles(n) {
|
|
3941
|
-
const cached = twiddleCache.get(n);
|
|
3942
|
-
if (cached) return cached;
|
|
3943
|
-
if (!isPowerOfTwo(n)) throw new Error(`FFT size must be a power of two, got ${n}`);
|
|
3944
|
-
const cos = new Float64Array(n / 2);
|
|
3945
|
-
const sin = new Float64Array(n / 2);
|
|
3946
|
-
for (let i = 0; i < n / 2; i++) {
|
|
3947
|
-
const angle = -2 * Math.PI * i / n;
|
|
3948
|
-
cos[i] = Math.cos(angle);
|
|
3949
|
-
sin[i] = Math.sin(angle);
|
|
3950
|
-
}
|
|
3951
|
-
const bits = Math.round(Math.log2(n));
|
|
3952
|
-
const reverse = new Uint32Array(n);
|
|
3953
|
-
for (let i = 0; i < n; i++) {
|
|
3954
|
-
let r = 0;
|
|
3955
|
-
for (let b = 0; b < bits; b++) if (i & 1 << b) r |= 1 << bits - 1 - b;
|
|
3956
|
-
reverse[i] = r;
|
|
3957
|
-
}
|
|
3958
|
-
const table = { cos, sin, reverse };
|
|
3959
|
-
twiddleCache.set(n, table);
|
|
3960
|
-
return table;
|
|
3961
|
-
}
|
|
3962
|
-
function fftInPlace(re, im, n) {
|
|
3963
|
-
const { cos, sin, reverse } = getTwiddles(n);
|
|
3964
|
-
for (let i = 0; i < n; i++) {
|
|
3965
|
-
const j = reverse[i];
|
|
3966
|
-
if (j > i) {
|
|
3967
|
-
let tmp = re[i];
|
|
3968
|
-
re[i] = re[j];
|
|
3969
|
-
re[j] = tmp;
|
|
3970
|
-
tmp = im[i];
|
|
3971
|
-
im[i] = im[j];
|
|
3972
|
-
im[j] = tmp;
|
|
3973
|
-
}
|
|
3974
|
-
}
|
|
3975
|
-
for (let size = 2; size <= n; size *= 2) {
|
|
3976
|
-
const half = size / 2;
|
|
3977
|
-
const step = n / size;
|
|
3978
|
-
for (let start = 0; start < n; start += size) {
|
|
3979
|
-
for (let k = 0; k < half; k++) {
|
|
3980
|
-
const twiddleIndex = k * step;
|
|
3981
|
-
const wr = cos[twiddleIndex];
|
|
3982
|
-
const wi = sin[twiddleIndex];
|
|
3983
|
-
const a = start + k;
|
|
3984
|
-
const b = a + half;
|
|
3985
|
-
const xr = re[b] * wr - im[b] * wi;
|
|
3986
|
-
const xi = re[b] * wi + im[b] * wr;
|
|
3987
|
-
re[b] = re[a] - xr;
|
|
3988
|
-
im[b] = im[a] - xi;
|
|
3989
|
-
re[a] += xr;
|
|
3990
|
-
im[a] += xi;
|
|
3991
|
-
}
|
|
3992
|
-
}
|
|
3993
|
-
}
|
|
3994
|
-
}
|
|
3995
|
-
function realFFTPairMagnitude(a, b, n, scratchRe, scratchIm, outA, outB) {
|
|
3996
|
-
for (let i = 0; i < n; i++) {
|
|
3997
|
-
scratchRe[i] = a[i];
|
|
3998
|
-
scratchIm[i] = b[i];
|
|
3999
|
-
}
|
|
4000
|
-
fftInPlace(scratchRe, scratchIm, n);
|
|
4001
|
-
const half = n >> 1;
|
|
4002
|
-
for (let k = 0; k <= half; k++) {
|
|
4003
|
-
const j = (n - k) % n;
|
|
4004
|
-
const zr = scratchRe[k], zi = scratchIm[k];
|
|
4005
|
-
const cr = scratchRe[j], ci = -scratchIm[j];
|
|
4006
|
-
const ar = 0.5 * (zr + cr);
|
|
4007
|
-
const ai = 0.5 * (zi + ci);
|
|
4008
|
-
const br = 0.5 * (zi - ci);
|
|
4009
|
-
const bi = -0.5 * (zr - cr);
|
|
4010
|
-
outA[k] = Math.sqrt(ar * ar + ai * ai);
|
|
4011
|
-
outB[k] = Math.sqrt(br * br + bi * bi);
|
|
4012
|
-
}
|
|
4013
|
-
}
|
|
4014
|
-
function realFFTMagnitude(a, n, scratchRe, scratchIm, out) {
|
|
4015
|
-
for (let i = 0; i < n; i++) {
|
|
4016
|
-
scratchRe[i] = a[i];
|
|
4017
|
-
scratchIm[i] = 0;
|
|
4018
|
-
}
|
|
4019
|
-
fftInPlace(scratchRe, scratchIm, n);
|
|
4020
|
-
const half = n >> 1;
|
|
4021
|
-
for (let k = 0; k <= half; k++) {
|
|
4022
|
-
const re = scratchRe[k], im = scratchIm[k];
|
|
4023
|
-
out[k] = Math.sqrt(re * re + im * im);
|
|
4024
|
-
}
|
|
4025
|
-
}
|
|
4026
|
-
function hannWindow(n) {
|
|
4027
|
-
const cached = hannCache.get(n);
|
|
4028
|
-
if (cached) return cached;
|
|
4029
|
-
const w = new Float64Array(n);
|
|
4030
|
-
for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * (i + 1) / n));
|
|
4031
|
-
hannCache.set(n, w);
|
|
4032
|
-
return w;
|
|
4033
|
-
}
|
|
4034
|
-
function windowCoherentGain(w) {
|
|
4035
|
-
let sum = 0;
|
|
4036
|
-
for (let i = 0; i < w.length; i++) sum += w[i];
|
|
4037
|
-
return sum;
|
|
4038
|
-
}
|
|
4039
|
-
|
|
4040
4275
|
// src/pulseq/decimator.ts
|
|
4041
4276
|
var DECIMATION_STOPBAND_DB = 80;
|
|
4042
4277
|
var planCache = /* @__PURE__ */ new Map();
|
|
@@ -4151,24 +4386,24 @@ var Pulseq = (() => {
|
|
|
4151
4386
|
});
|
|
4152
4387
|
function resolveSpectrogramParams(params) {
|
|
4153
4388
|
const merged = { ...DEFAULT_SPECTROGRAM_PARAMS, ...params ?? {} };
|
|
4154
|
-
const fMaxHz =
|
|
4389
|
+
const fMaxHz = clamp2(finite(merged.fMaxHz, 3e3), 1, 5e6);
|
|
4155
4390
|
return {
|
|
4156
4391
|
source: merged.source === "dGdt" ? "dGdt" : "G",
|
|
4157
|
-
fMinHz:
|
|
4392
|
+
fMinHz: clamp2(finite(merged.fMinHz, 0), 0, fMaxHz - 1),
|
|
4158
4393
|
fMaxHz,
|
|
4159
4394
|
windowSamples: Math.max(0, Math.floor(finite(merged.windowSamples, 0))),
|
|
4160
|
-
overlap:
|
|
4161
|
-
oversample:
|
|
4162
|
-
targetColumns:
|
|
4395
|
+
overlap: clamp2(finite(merged.overlap, 0.75), 0, 0.9375),
|
|
4396
|
+
oversample: clamp2(Math.round(finite(merged.oversample, 3)), 1, 4),
|
|
4397
|
+
targetColumns: clamp2(Math.round(finite(merged.targetColumns, 256)), 64, 512),
|
|
4163
4398
|
normalize: merged.normalize !== false
|
|
4164
4399
|
};
|
|
4165
4400
|
}
|
|
4166
4401
|
function chooseWindowSamples(params, decimatedDt, viewDurationSec, warnings) {
|
|
4167
|
-
if (params.windowSamples > 0) return
|
|
4402
|
+
if (params.windowSamples > 0) return clamp2(params.windowSamples, MIN_WINDOW_SAMPLES, 4096);
|
|
4168
4403
|
const dfTarget = Math.max(20, (params.fMaxHz - params.fMinHz) / 64);
|
|
4169
|
-
let nwin =
|
|
4404
|
+
let nwin = clamp2(nextPowerOfTwo(Math.round(1 / (dfTarget * decimatedDt))), MIN_WINDOW_SAMPLES, 4096);
|
|
4170
4405
|
if (nwin * decimatedDt > viewDurationSec / 3) {
|
|
4171
|
-
const shrunk =
|
|
4406
|
+
const shrunk = clamp2(previousPowerOfTwo(Math.floor(viewDurationSec / (3 * decimatedDt))), MIN_WINDOW_SAMPLES, 4096);
|
|
4172
4407
|
if (shrunk < nwin) {
|
|
4173
4408
|
nwin = shrunk;
|
|
4174
4409
|
const achievedDf = 1 / (nwin * decimatedDt);
|
|
@@ -4201,7 +4436,7 @@ var Pulseq = (() => {
|
|
|
4201
4436
|
let windowSamples = chooseWindowSamples(params, decimatedDt, viewDuration, warnings);
|
|
4202
4437
|
const tooShort = nDecimated < MIN_WINDOW_SAMPLES;
|
|
4203
4438
|
if (windowSamples > nDecimated) {
|
|
4204
|
-
windowSamples =
|
|
4439
|
+
windowSamples = clamp2(previousPowerOfTwo(nDecimated), MIN_WINDOW_SAMPLES, 4096);
|
|
4205
4440
|
}
|
|
4206
4441
|
let hop = Math.max(1, Math.round(windowSamples * (1 - params.overlap)));
|
|
4207
4442
|
let columns = nDecimated >= windowSamples ? Math.floor((nDecimated - windowSamples) / hop) + 1 : 0;
|
|
@@ -4400,7 +4635,7 @@ var Pulseq = (() => {
|
|
|
4400
4635
|
if (spec.nTime <= 0) return -1;
|
|
4401
4636
|
if (!(spec.tStepSec > 0)) return 0;
|
|
4402
4637
|
const raw = Math.round((timeSec - spec.tStartSec) / spec.tStepSec);
|
|
4403
|
-
return
|
|
4638
|
+
return clamp2(raw, 0, spec.nTime - 1);
|
|
4404
4639
|
}
|
|
4405
4640
|
function computeGradientSpectrumSlice(spec, timeSec) {
|
|
4406
4641
|
const col = spectrogramColumnAt(spec, timeSec);
|
|
@@ -4513,7 +4748,7 @@ var Pulseq = (() => {
|
|
|
4513
4748
|
}
|
|
4514
4749
|
return { frequencyHz, gx: accX, gy: accY, gz: accZ, rss, segments };
|
|
4515
4750
|
}
|
|
4516
|
-
function
|
|
4751
|
+
function clamp2(value, lo, hi) {
|
|
4517
4752
|
return Math.max(lo, Math.min(hi, value));
|
|
4518
4753
|
}
|
|
4519
4754
|
function finite(value, fallback) {
|
|
@@ -1127,6 +1127,55 @@ function appendDerivedTooltipLines(lines,ct){
|
|
|
1127
1127
|
}
|
|
1128
1128
|
}
|
|
1129
1129
|
|
|
1130
|
+
function fmtFlipAngle(value){
|
|
1131
|
+
if(!isFinite(value))return null;
|
|
1132
|
+
var absolute=Math.abs(value),digits=absolute<10?2:(absolute<100?1:0);
|
|
1133
|
+
return value.toFixed(digits)+'°';
|
|
1134
|
+
}
|
|
1135
|
+
function fmtRfFrequency(value){
|
|
1136
|
+
var absolute=Math.abs(value),sign=value<0?'−':'+';
|
|
1137
|
+
if(absolute>=1000)return sign+(absolute/1000).toFixed(absolute<10000?2:1)+' kHz';
|
|
1138
|
+
return sign+absolute.toFixed(absolute<100?1:0)+' Hz';
|
|
1139
|
+
}
|
|
1140
|
+
function rfBandMetric(bands,index){
|
|
1141
|
+
if(!bands.length)return null;
|
|
1142
|
+
var min=Infinity,max=-Infinity,total=0;
|
|
1143
|
+
for(var i=0;i<bands.length;i++){var value=bands[i][index];if(!isFinite(value))return null;min=Math.min(min,value);max=Math.max(max,value);total+=value;}
|
|
1144
|
+
var mean=total/bands.length;
|
|
1145
|
+
if(max-min<=Math.max(.25,Math.abs(mean)*.01))return fmtFlipAngle(mean);
|
|
1146
|
+
return fmtFlipAngle(min)+'–'+fmtFlipAngle(max);
|
|
1147
|
+
}
|
|
1148
|
+
function rfBandOffsets(bands){
|
|
1149
|
+
if(!bands.length)return '';
|
|
1150
|
+
if(bands.length===2){
|
|
1151
|
+
var left=bands[0][0],right=bands[1][0],scale=Math.max(1,Math.abs(left),Math.abs(right));
|
|
1152
|
+
if(Math.abs(left+right)<=.02*scale)return '±'+fmtRfFrequency(Math.abs(right)).slice(1);
|
|
1153
|
+
}
|
|
1154
|
+
if(bands.length<=3)return bands.map(function(v){return fmtRfFrequency(v[0]);}).join(',');
|
|
1155
|
+
return fmtRfFrequency(bands[0][0])+'…'+fmtRfFrequency(bands[bands.length-1][0]);
|
|
1156
|
+
}
|
|
1157
|
+
function rfResponseSummary(rf){
|
|
1158
|
+
var bands=(rf.rb||[]).filter(function(v){return v&&v.length>=4&&isFinite(v[0])&&isFinite(v[1])&&isFinite(v[2])&&isFinite(v[3]);});
|
|
1159
|
+
var area0=fmtFlipAngle(rf.a0),use=(rf.u||'u').toLowerCase();
|
|
1160
|
+
if(!bands.length)return area0?' area₀='+area0:'';
|
|
1161
|
+
var polar=rfBandMetric(bands,2),spectral=rfBandMetric(bands,1);
|
|
1162
|
+
if(use==='i'||use==='inversion'){
|
|
1163
|
+
var center=bands[0];for(var i=1;i<bands.length;i++)if(Math.abs(bands[i][0])<Math.abs(center[0]))center=bands[i];
|
|
1164
|
+
var efficiency=Math.max(0,Math.min(1,-center[3]))*100;
|
|
1165
|
+
return ' Inv≈'+efficiency.toFixed(1)+'% θz≈'+fmtFlipAngle(center[2])+(area0?' area₀='+area0:'');
|
|
1166
|
+
}
|
|
1167
|
+
if(bands.length>1){
|
|
1168
|
+
var prefix=(use==='e'||use==='excitation')?'FA≈':'θz≈';
|
|
1169
|
+
var summary=' MB'+bands.length+' '+prefix+polar;
|
|
1170
|
+
var meanPolar=0,meanArea=0;for(var j=0;j<bands.length;j++){meanPolar+=bands[j][2];meanArea+=bands[j][1];}
|
|
1171
|
+
if(Math.abs(meanArea-meanPolar)/bands.length>.5)summary+=' (area≈'+spectral+')';
|
|
1172
|
+
return summary+' Δf='+rfBandOffsets(bands);
|
|
1173
|
+
}
|
|
1174
|
+
var single=' '+((use==='e'||use==='excitation')?'FA≈':'θz≈')+polar;
|
|
1175
|
+
if(Math.abs(bands[0][0])>1)single+=' Δf='+fmtRfFrequency(bands[0][0]);
|
|
1176
|
+
return single;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1130
1179
|
// Tooltip
|
|
1131
1180
|
function showTooltipAt(cx,cy,ct){
|
|
1132
1181
|
var ch=cH(),vc=visChannels(),vi2=Math.floor((cy-mc.getBoundingClientRect().top-M.t)/ch);
|
|
@@ -1136,7 +1185,7 @@ function showTooltipAt(cx,cy,ct){
|
|
|
1136
1185
|
if(found){
|
|
1137
1186
|
var blockDt=Math.max(0,Math.min(found.d,ct-found.s));
|
|
1138
1187
|
lines=['Block #'+found.i,'Time: '+fmtT(timeConv(ct))+' '+timeUnitStr()+' (Δ '+fmtT(timeConv(blockDt))+' / dur: '+fmtT(timeConv(found.d))+' '+timeUnitStr()+')'];
|
|
1139
|
-
if(found.rf){lines.push('RF: '+(found.rf.a||0).toFixed(1)+' Hz fo='+(found.rf.fo||0).toFixed(0)+' Hz φ₀='+((found.rf.po||0)%6.283).toFixed(2)+' rad');}
|
|
1188
|
+
if(found.rf){lines.push('RF: '+(found.rf.a||0).toFixed(1)+' Hz'+rfResponseSummary(found.rf)+' fo='+(found.rf.fo||0).toFixed(0)+' Hz φ₀='+((found.rf.po||0)%6.283).toFixed(2)+' rad');}
|
|
1140
1189
|
if(found.gx&&found.gx.ty!=='none')lines.push('Gx: '+fmtG(found.gx,ct));
|
|
1141
1190
|
if(found.gy&&found.gy.ty!=='none')lines.push('Gy: '+fmtG(found.gy,ct));
|
|
1142
1191
|
if(found.gz&&found.gz.ty!=='none')lines.push('Gz: '+fmtG(found.gz,ct));
|
|
@@ -1345,6 +1394,12 @@ function convertBlock(blk) {
|
|
|
1345
1394
|
s: blk.rf.startTime || 0,
|
|
1346
1395
|
d: blk.rf.duration || 0,
|
|
1347
1396
|
a: blk.rf.amplitude || 0,
|
|
1397
|
+
a0: blk.rf.response ? blk.rf.response.carrierAreaDeg : 0,
|
|
1398
|
+
rb: blk.rf.response ? blk.rf.response.bands.map(function(v) {
|
|
1399
|
+
return [v.frequencyOffsetHz, v.spectralAreaDeg, v.polarFlipDeg, v.mz];
|
|
1400
|
+
}) : [],
|
|
1401
|
+
rs: blk.rf.response && blk.rf.response.spectrumAnalyzed ? 1 : 0,
|
|
1402
|
+
rl: blk.rf.response && blk.rf.response.limited ? 1 : 0,
|
|
1348
1403
|
fo: blk.rf.freqOffset || 0,
|
|
1349
1404
|
po: ((blk.rf.phaseOffset || 0) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI),
|
|
1350
1405
|
t: blk.rf.timePoints || [],
|
|
@@ -4823,7 +4823,16 @@ var SeqEyesPanel = (function () {
|
|
|
4823
4823
|
if (e.key === 'Escape' && isFinite(markerTimeSec)) { setMarkerTime(NaN); syncControls(); }
|
|
4824
4824
|
});
|
|
4825
4825
|
document.addEventListener('visibilitychange', function () {
|
|
4826
|
-
if (document.hidden)
|
|
4826
|
+
if (document.hidden) {
|
|
4827
|
+
// Web Audio can keep rendering in a background tab, but animation frames
|
|
4828
|
+
// cannot. Preserve the audition and suspend only its visual updates.
|
|
4829
|
+
stopPlayheadLoop();
|
|
4830
|
+
return;
|
|
4831
|
+
}
|
|
4832
|
+
if (panelMode !== 'spectrogram') return;
|
|
4833
|
+
if (SeqEyesAudio.isPlaying()) startPlayheadLoop();
|
|
4834
|
+
syncTransport();
|
|
4835
|
+
render();
|
|
4827
4836
|
});
|
|
4828
4837
|
window.addEventListener('pagehide', function () { SeqEyesAudio.dispose(); });
|
|
4829
4838
|
window.addEventListener('resize', function () { if (panelMode === 'spectrogram') resize(); });
|
|
@@ -5566,6 +5575,54 @@ function placeTooltip(cx,cy){
|
|
|
5566
5575
|
var top=cy+12;if(top+r.height+pad>vh)top=cy-r.height-12;if(top<pad)top=pad;
|
|
5567
5576
|
tt.style.left=left+'px';tt.style.top=top+'px';
|
|
5568
5577
|
}
|
|
5578
|
+
function fmtFlipAngle(value){
|
|
5579
|
+
if(!isFinite(value))return null;
|
|
5580
|
+
var absolute=Math.abs(value),digits=absolute<10?2:(absolute<100?1:0);
|
|
5581
|
+
return value.toFixed(digits)+'\u00b0';
|
|
5582
|
+
}
|
|
5583
|
+
function fmtRfFrequency(value){
|
|
5584
|
+
var absolute=Math.abs(value),sign=value<0?'\u2212':'+';
|
|
5585
|
+
if(absolute>=1000)return sign+(absolute/1000).toFixed(absolute<10000?2:1)+' kHz';
|
|
5586
|
+
return sign+absolute.toFixed(absolute<100?1:0)+' Hz';
|
|
5587
|
+
}
|
|
5588
|
+
function rfBandMetric(bands,index){
|
|
5589
|
+
if(!bands.length)return null;
|
|
5590
|
+
var min=Infinity,max=-Infinity,total=0;
|
|
5591
|
+
for(var i=0;i<bands.length;i++){var value=bands[i][index];if(!isFinite(value))return null;min=Math.min(min,value);max=Math.max(max,value);total+=value;}
|
|
5592
|
+
var mean=total/bands.length;
|
|
5593
|
+
if(max-min<=Math.max(.25,Math.abs(mean)*.01))return fmtFlipAngle(mean);
|
|
5594
|
+
return fmtFlipAngle(min)+'\u2013'+fmtFlipAngle(max);
|
|
5595
|
+
}
|
|
5596
|
+
function rfBandOffsets(bands){
|
|
5597
|
+
if(!bands.length)return '';
|
|
5598
|
+
if(bands.length===2){
|
|
5599
|
+
var left=bands[0][0],right=bands[1][0],scale=Math.max(1,Math.abs(left),Math.abs(right));
|
|
5600
|
+
if(Math.abs(left+right)<=.02*scale)return '\u00b1'+fmtRfFrequency(Math.abs(right)).slice(1);
|
|
5601
|
+
}
|
|
5602
|
+
if(bands.length<=3)return bands.map(function(v){return fmtRfFrequency(v[0]);}).join(',');
|
|
5603
|
+
return fmtRfFrequency(bands[0][0])+'\u2026'+fmtRfFrequency(bands[bands.length-1][0]);
|
|
5604
|
+
}
|
|
5605
|
+
function rfResponseSummary(rf){
|
|
5606
|
+
var bands=(rf.rb||[]).filter(function(v){return v&&v.length>=4&&isFinite(v[0])&&isFinite(v[1])&&isFinite(v[2])&&isFinite(v[3]);});
|
|
5607
|
+
var area0=fmtFlipAngle(rf.a0),use=(rf.u||'u').toLowerCase();
|
|
5608
|
+
if(!bands.length)return area0?' area\u2080='+area0:'';
|
|
5609
|
+
var polar=rfBandMetric(bands,2),spectral=rfBandMetric(bands,1);
|
|
5610
|
+
if(use==='i'||use==='inversion'){
|
|
5611
|
+
var center=bands[0];for(var i=1;i<bands.length;i++)if(Math.abs(bands[i][0])<Math.abs(center[0]))center=bands[i];
|
|
5612
|
+
var efficiency=Math.max(0,Math.min(1,-center[3]))*100;
|
|
5613
|
+
return ' Inv\u2248'+efficiency.toFixed(1)+'% \u03b8z\u2248'+fmtFlipAngle(center[2])+(area0?' area\u2080='+area0:'');
|
|
5614
|
+
}
|
|
5615
|
+
if(bands.length>1){
|
|
5616
|
+
var prefix=(use==='e'||use==='excitation')?'FA\u2248':'\u03b8z\u2248';
|
|
5617
|
+
var summary=' MB'+bands.length+' '+prefix+polar;
|
|
5618
|
+
var meanPolar=0,meanArea=0;for(var j=0;j<bands.length;j++){meanPolar+=bands[j][2];meanArea+=bands[j][1];}
|
|
5619
|
+
if(Math.abs(meanArea-meanPolar)/bands.length>.5)summary+=' (area\u2248'+spectral+')';
|
|
5620
|
+
return summary+' \u0394f='+rfBandOffsets(bands);
|
|
5621
|
+
}
|
|
5622
|
+
var single=' '+((use==='e'||use==='excitation')?'FA\u2248':'\u03b8z\u2248')+polar;
|
|
5623
|
+
if(Math.abs(bands[0][0])>1)single+=' \u0394f='+fmtRfFrequency(bands[0][0]);
|
|
5624
|
+
return single;
|
|
5625
|
+
}
|
|
5569
5626
|
function showTooltipAt(cx,cy,ct){
|
|
5570
5627
|
var ch=cH(),vc=visChannels(),vi2=Math.floor((cy-mc.getBoundingClientRect().top-M.t)/ch);
|
|
5571
5628
|
if(vi2>=0&&vi2<vc.length){
|
|
@@ -5574,7 +5631,7 @@ function showTooltipAt(cx,cy,ct){
|
|
|
5574
5631
|
if(found){
|
|
5575
5632
|
var blockDt=Math.max(0,Math.min(found.d,ct-found.s));
|
|
5576
5633
|
lines=['Block #'+found.i,'Time: '+fmtT(timeConv(ct))+' '+timeUnitStr()+' (\u0394 '+fmtT(timeConv(blockDt))+' / dur: '+fmtT(timeConv(found.d))+' '+timeUnitStr()+')'];
|
|
5577
|
-
if(found.rf){lines.push('RF: '+(found.rf.a||0).toFixed(1)+' Hz fo='+(found.rf.fo||0).toFixed(0)+' Hz \u03c6\u2080='+((found.rf.po||0)%6.283).toFixed(2)+' rad');}
|
|
5634
|
+
if(found.rf){lines.push('RF: '+(found.rf.a||0).toFixed(1)+' Hz'+rfResponseSummary(found.rf)+' fo='+(found.rf.fo||0).toFixed(0)+' Hz \u03c6\u2080='+((found.rf.po||0)%6.283).toFixed(2)+' rad');}
|
|
5578
5635
|
if(found.gx&&found.gx.ty!=='none')lines.push('Gx: '+fmtG(found.gx,ct));
|
|
5579
5636
|
if(found.gy&&found.gy.ty!=='none')lines.push('Gy: '+fmtG(found.gy,ct));
|
|
5580
5637
|
if(found.gz&&found.gz.ty!=='none')lines.push('Gz: '+fmtG(found.gz,ct));
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|