@cornerstonejs/metadata 5.13.2 → 5.15.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 (100) hide show
  1. package/dist/esm/displayset/BaseDisplaySet.d.ts +3 -4
  2. package/dist/esm/displayset/BaseDisplaySet.js +1 -5
  3. package/dist/esm/displayset/IDisplaySet.d.ts +9 -3
  4. package/dist/esm/displayset/ImageStackDisplaySet.d.ts +1 -3
  5. package/dist/esm/displayset/ImageStackDisplaySet.js +0 -1
  6. package/dist/esm/displayset/__fixtures__/scenarioInstances.d.ts +12 -0
  7. package/dist/esm/displayset/__fixtures__/scenarioInstances.js +210 -0
  8. package/dist/esm/displayset/compileSplitRules.d.ts +3 -0
  9. package/dist/esm/displayset/compileSplitRules.js +197 -0
  10. package/dist/esm/displayset/compiledDisplaySetRules.d.ts +3 -0
  11. package/dist/esm/displayset/compiledDisplaySetRules.js +3 -0
  12. package/dist/esm/displayset/createDisplaySetFromGroup.js +54 -19
  13. package/dist/esm/displayset/displaySetContext.d.ts +150 -0
  14. package/dist/esm/displayset/displaySetContext.js +918 -0
  15. package/dist/esm/displayset/displaySetRules.d.ts +5 -0
  16. package/dist/esm/displayset/displaySetRules.js +142 -0
  17. package/dist/esm/displayset/displaySetRules.test.d.ts +1 -0
  18. package/dist/esm/displayset/displaySetRules.test.js +1306 -0
  19. package/dist/esm/displayset/displaySetRulesTypes.d.ts +34 -0
  20. package/dist/esm/displayset/displaySetRulesTypes.js +0 -0
  21. package/dist/esm/displayset/displayset.test.js +422 -130
  22. package/dist/esm/displayset/groupInstancesBySplitRules.d.ts +8 -2
  23. package/dist/esm/displayset/groupInstancesBySplitRules.js +174 -17
  24. package/dist/esm/displayset/index.d.ts +14 -4
  25. package/dist/esm/displayset/index.js +10 -3
  26. package/dist/esm/displayset/isEcgInstance.d.ts +2 -1
  27. package/dist/esm/displayset/isEcgInstance.js +4 -9
  28. package/dist/esm/displayset/isImageInstance.d.ts +2 -1
  29. package/dist/esm/displayset/isImageInstance.js +4 -44
  30. package/dist/esm/displayset/isReconstructableInstances.d.ts +2 -0
  31. package/dist/esm/displayset/isReconstructableInstances.js +91 -0
  32. package/dist/esm/displayset/isVideoInstance.d.ts +2 -1
  33. package/dist/esm/displayset/isVideoInstance.js +4 -30
  34. package/dist/esm/displayset/isWsiInstance.d.ts +2 -1
  35. package/dist/esm/displayset/isWsiInstance.js +4 -6
  36. package/dist/esm/displayset/metadataContext.d.ts +13 -0
  37. package/dist/esm/displayset/metadataContext.js +42 -0
  38. package/dist/esm/displayset/metadataContext.test.d.ts +1 -0
  39. package/dist/esm/displayset/metadataContext.test.js +62 -0
  40. package/dist/esm/displayset/scenarioFixtures.test.d.ts +1 -0
  41. package/dist/esm/displayset/scenarioFixtures.test.js +206 -0
  42. package/dist/esm/displayset/seriesContext.test.d.ts +1 -0
  43. package/dist/esm/displayset/seriesContext.test.js +238 -0
  44. package/dist/esm/displayset/seriesFunctions.d.ts +29 -0
  45. package/dist/esm/displayset/seriesFunctions.js +327 -0
  46. package/dist/esm/displayset/sopClassUidLists.d.ts +14 -0
  47. package/dist/esm/displayset/sopClassUidLists.js +100 -0
  48. package/dist/esm/displayset/sortInstances.d.ts +10 -0
  49. package/dist/esm/displayset/sortInstances.js +33 -0
  50. package/dist/esm/displayset/sortInstances.test.d.ts +1 -0
  51. package/dist/esm/displayset/sortInstances.test.js +56 -0
  52. package/dist/esm/displayset/splitImageIdsBySplitRules.d.ts +3 -3
  53. package/dist/esm/displayset/splitImageIdsBySplitRules.js +5 -5
  54. package/dist/esm/displayset/splitRuleSchema.d.ts +11 -0
  55. package/dist/esm/displayset/splitRuleSchema.js +168 -0
  56. package/dist/esm/displayset/splitRuleSet.d.ts +12 -0
  57. package/dist/esm/displayset/splitRuleSet.js +47 -0
  58. package/dist/esm/displayset/splitRuleSet.test.d.ts +1 -0
  59. package/dist/esm/displayset/splitRuleSet.test.js +119 -0
  60. package/dist/esm/displayset/types.d.ts +37 -4
  61. package/dist/esm/displayset/types.js +15 -0
  62. package/dist/esm/index.d.ts +4 -2
  63. package/dist/esm/index.js +2 -1
  64. package/dist/esm/safeFunctions/compile.d.ts +31 -0
  65. package/dist/esm/safeFunctions/compile.js +142 -0
  66. package/dist/esm/safeFunctions/compile.test.d.ts +1 -0
  67. package/dist/esm/safeFunctions/compile.test.js +82 -0
  68. package/dist/esm/safeFunctions/expression/compiler.d.ts +23 -0
  69. package/dist/esm/safeFunctions/expression/compiler.js +481 -0
  70. package/dist/esm/safeFunctions/expression/expression.test.d.ts +1 -0
  71. package/dist/esm/safeFunctions/expression/expression.test.js +325 -0
  72. package/dist/esm/safeFunctions/expression/index.d.ts +4 -0
  73. package/dist/esm/safeFunctions/expression/index.js +3 -0
  74. package/dist/esm/safeFunctions/expression/parser.d.ts +56 -0
  75. package/dist/esm/safeFunctions/expression/parser.js +248 -0
  76. package/dist/esm/safeFunctions/expression/tokenizer.d.ts +29 -0
  77. package/dist/esm/safeFunctions/expression/tokenizer.js +198 -0
  78. package/dist/esm/safeFunctions/index.d.ts +5 -0
  79. package/dist/esm/safeFunctions/index.js +4 -0
  80. package/dist/esm/safeFunctions/matchTests.d.ts +16 -0
  81. package/dist/esm/safeFunctions/matchTests.js +203 -0
  82. package/dist/esm/safeFunctions/schema.d.ts +72 -0
  83. package/dist/esm/safeFunctions/schema.js +361 -0
  84. package/dist/esm/safeFunctions/types.d.ts +47 -0
  85. package/dist/esm/safeFunctions/types.js +0 -0
  86. package/dist/esm/safeFunctions/values.d.ts +6 -0
  87. package/dist/esm/safeFunctions/values.js +71 -0
  88. package/dist/esm/utilities/Tags.d.ts +1 -0
  89. package/dist/esm/utilities/Tags.js +14 -2
  90. package/dist/esm/utilities/index.d.ts +1 -1
  91. package/dist/esm/utilities/index.js +1 -1
  92. package/dist/esm/utilities/toNumber.d.ts +1 -1
  93. package/dist/esm/utilities/toNumber.js +1 -1
  94. package/dist/esm/version.d.ts +1 -1
  95. package/dist/esm/version.js +1 -1
  96. package/package.json +2 -2
  97. package/dist/esm/displayset/defaultDisplaySetSplitRules.d.ts +0 -2
  98. package/dist/esm/displayset/defaultDisplaySetSplitRules.js +0 -93
  99. package/dist/esm/displayset/viewportTypes.d.ts +0 -4
  100. package/dist/esm/displayset/viewportTypes.js +0 -13
@@ -0,0 +1,206 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { createDisplaySetFromGroup } from './createDisplaySetFromGroup.js';
4
+ import { compiledDisplaySetRules } from './compiledDisplaySetRules.js';
5
+ import { createDisplaySetSplitRules, displaySetRules } from './displaySetRules.js';
6
+ import { groupInstancesBySplitRules } from './groupInstancesBySplitRules.js';
7
+ import { ctLocalizers, ctSeriesWithScouts, fourViewMammogram, geDiffusionSeries, t2SeriesWithZeroBValue, uidsOf, usSweepsAndClips, } from './__fixtures__/scenarioInstances.js';
8
+ function displaySetsOf(instances, rules, read = (displaySet) => displaySet.SeriesDescription) {
9
+ return groupInstancesBySplitRules([...instances].reverse(), rules).map((group, splitNumber) => {
10
+ const displaySet = createDisplaySetFromGroup(group, {
11
+ splitNumber,
12
+ });
13
+ return {
14
+ rule: displaySet.splitRuleId,
15
+ uids: uidsOf(group.instances),
16
+ description: read(displaySet),
17
+ };
18
+ });
19
+ }
20
+ function withDefaults(rules, options) {
21
+ return createDisplaySetSplitRules({ ...displaySetRules, ...rules }, options);
22
+ }
23
+ const bySliceLocation = (a, b) => a.SliceLocation - b.SliceLocation;
24
+ const dwiByBValue = {
25
+ dwiByBValue: {
26
+ priority: -1,
27
+ context: {
28
+ bValue: 'Number(DiffusionBValue)',
29
+ isDiffusion: 'series.instances.max(bValue) > 0',
30
+ },
31
+ matches: {
32
+ Modality: 'MR',
33
+ isDiffusion: true,
34
+ isImage: true,
35
+ DiffusionBValue: { notNull: true },
36
+ },
37
+ groupBy: ['SeriesInstanceUID', 'bValue'],
38
+ sortBy: ['SliceLocation'],
39
+ attributes: {
40
+ SeriesDescription: {
41
+ template: '{SeriesDescription} b={DiffusionBValue}',
42
+ },
43
+ },
44
+ },
45
+ };
46
+ describe('diffusion MR', () => {
47
+ it('splits a GE DWI series by the b-value of the private tag (0043,1039), only with a declaration of the tag', () => {
48
+ const series = geDiffusionSeries();
49
+ const geDwiByBValue = {
50
+ geDwiByBValue: {
51
+ priority: -1,
52
+ context: {
53
+ geBValues: { attribute: '00431039', vr: 'IS', vm: '4' },
54
+ bValue: 'Number(geBValues)',
55
+ isDiffusion: 'series.instances.max(bValue) > 0',
56
+ },
57
+ matches: {
58
+ Modality: 'MR',
59
+ isDiffusion: true,
60
+ isImage: true,
61
+ bValue: { notNull: true },
62
+ },
63
+ groupBy: ['SeriesInstanceUID', 'bValue'],
64
+ sortBy: ['SliceLocation'],
65
+ attributes: {
66
+ SeriesDescription: { template: '{SeriesDescription} b={bValue}' },
67
+ },
68
+ },
69
+ };
70
+ const atB = (bValue) => uidsOf(series
71
+ .filter((i) => i['00431039'][0] === bValue)
72
+ .sort(bySliceLocation));
73
+ expect(displaySetsOf(series, withDefaults(geDwiByBValue))).toEqual([
74
+ { rule: 'geDwiByBValue', uids: atB('0'), description: 'DWI ASSET b=0' },
75
+ {
76
+ rule: 'geDwiByBValue',
77
+ uids: atB('1000'),
78
+ description: 'DWI ASSET b=1000',
79
+ },
80
+ ]);
81
+ expect(atB('0').length).toBe(32);
82
+ const defaults = displaySetsOf(series, compiledDisplaySetRules);
83
+ expect(defaults.map((d) => [d.rule, d.uids.length])).toEqual([
84
+ ['volume3d', 64],
85
+ ]);
86
+ });
87
+ it('does not split an MR series whose images all carry DiffusionBValue = 0', () => {
88
+ const series = t2SeriesWithZeroBValue();
89
+ expect(displaySetsOf(series, withDefaults(dwiByBValue))).toEqual([
90
+ {
91
+ rule: 'volume3d',
92
+ uids: uidsOf(series),
93
+ description: undefined,
94
+ },
95
+ ]);
96
+ });
97
+ });
98
+ describe('CT localizers', () => {
99
+ const ctScout = {
100
+ ctScout: {
101
+ priority: -1,
102
+ context: {
103
+ isLocalizer: "includes(ImageType, 'LOCALIZER')",
104
+ hasScout: 'series.instances.count(isLocalizer) > 0 && ' +
105
+ 'series.instances.count(isLocalizer) < series.instances.count()',
106
+ },
107
+ matches: { Modality: 'CT', hasScout: true, isLocalizer: true },
108
+ groupBy: ['SeriesInstanceUID'],
109
+ attributes: {
110
+ label: { template: 'SCOUT' },
111
+ SeriesDescription: { template: 'SCOUT {SeriesDescription}' },
112
+ },
113
+ },
114
+ };
115
+ const rules = withDefaults(ctScout);
116
+ const viewOf = (displaySet) => displaySet.label;
117
+ it('puts the localizers of a mixed series in their own display set, and keeps the axial slices in volume3d', () => {
118
+ const series = ctSeriesWithScouts();
119
+ const localizers = series.slice(0, 2);
120
+ const axial = series.slice(2);
121
+ expect(displaySetsOf(series, rules, viewOf)).toEqual([
122
+ {
123
+ rule: 'ctScout',
124
+ uids: uidsOf(localizers),
125
+ description: 'SCOUT',
126
+ },
127
+ {
128
+ rule: 'volume3d',
129
+ uids: uidsOf(axial),
130
+ description: undefined,
131
+ },
132
+ ]);
133
+ });
134
+ it('keeps a series of localizers only as one display set', () => {
135
+ const series = ctLocalizers('1.2.392.200036.9116.2.6.1.48.1214833767.99');
136
+ expect(displaySetsOf(series, rules).map((d) => [d.rule, d.uids])).toEqual([
137
+ ['volume3d', uidsOf(series)],
138
+ ]);
139
+ });
140
+ });
141
+ describe('ultrasound', () => {
142
+ const usTimeClusters = {
143
+ usTimeClusters: {
144
+ priority: -1,
145
+ context: { timeClustersMaxGap: 20 },
146
+ matches: {
147
+ isImage: true,
148
+ Modality: 'US',
149
+ isMultiFrame: false,
150
+ sweepCount: { greaterThan: 1 },
151
+ sweepStart: { notNull: true },
152
+ },
153
+ groupBy: ['SeriesInstanceUID', 'sweepStart'],
154
+ sortBy: ['acquisitionSeconds'],
155
+ attributes: {
156
+ SeriesDescription: {
157
+ template: '{SeriesDescription} +{sweepOffset} s',
158
+ },
159
+ },
160
+ },
161
+ };
162
+ const { instances, sweeps, clips } = usSweepsAndClips();
163
+ const rules = withDefaults(usTimeClusters);
164
+ it('makes one display set for each sweep of single images, in time order', () => {
165
+ expect(displaySetsOf(instances, rules).slice(0, 2)).toEqual([
166
+ {
167
+ rule: 'usTimeClusters',
168
+ uids: uidsOf(sweeps[0]),
169
+ description: 'US ABDOMEN +0 s',
170
+ },
171
+ {
172
+ rule: 'usTimeClusters',
173
+ uids: uidsOf(sweeps[1]),
174
+ description: 'US ABDOMEN +90 s',
175
+ },
176
+ ]);
177
+ });
178
+ it('keeps each cine clip of the series as its own display set', () => {
179
+ expect(displaySetsOf(instances, rules)
180
+ .slice(2)
181
+ .map((d) => d.uids)).toEqual(clips.map((clip) => uidsOf([clip])));
182
+ });
183
+ });
184
+ describe('mammography', () => {
185
+ it('splits a four-view series without ViewPosition into RCC, LCC, RMLO and LMLO with mammoViewSplit.json', () => {
186
+ const ruleSet = JSON.parse(readFileSync(join(__dirname, '../../../core/examples/displaySetRules/mammoViewSplit.json'), 'utf8'));
187
+ const viewCodeOf = (instance) => instance.ViewCodeSequence?.[0]
188
+ ?.CodeValue;
189
+ const options = {
190
+ context: {
191
+ viewCode: viewCodeOf,
192
+ mammoView: (instance) => ({ 'R-10242': 'CC', 'R-10226': 'MLO' })[viewCodeOf(instance) ?? ''],
193
+ },
194
+ };
195
+ expect(ruleSet.requires.context).toEqual(Object.keys(options.context ?? {}).sort());
196
+ const series = fourViewMammogram();
197
+ const result = displaySetsOf(series, withDefaults(ruleSet.rules, options), (displaySet) => displaySet.descriptionName);
198
+ expect(result.map((d) => d.rule)).toEqual(Array(4).fill('mammoViewSplit'));
199
+ expect(Object.fromEntries(result.map((d) => [d.description, d.uids]))).toEqual({
200
+ RCC: uidsOf([series[0]]),
201
+ LCC: uidsOf([series[1]]),
202
+ RMLO: uidsOf([series[2]]),
203
+ LMLO: uidsOf([series[3]]),
204
+ });
205
+ });
206
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,238 @@
1
+ import { createDisplaySetFromGroup } from './createDisplaySetFromGroup.js';
2
+ import { groupInstancesBySplitRules, orderInstancesForRule, } from './groupInstancesBySplitRules.js';
3
+ import { withInstances } from './displaySetContext.js';
4
+ import { createDisplaySetSplitRules } from './displaySetRules.js';
5
+ import { planeGeometry, timeClusters } from './seriesFunctions.js';
6
+ const AXIAL = [1, 0, 0, 0, 1, 0];
7
+ const slice = (id, z, overrides = {}) => ({
8
+ SOPInstanceUID: id,
9
+ SeriesInstanceUID: 'series-1',
10
+ Modality: 'CT',
11
+ ImageOrientationPatient: AXIAL,
12
+ ImagePositionPatient: [0, 0, z],
13
+ ...overrides,
14
+ });
15
+ const CT_SOP_CLASS = '1.2.840.10008.5.1.4.1.1.2';
16
+ describe('the display set context over a split', () => {
17
+ const options = {
18
+ context: {
19
+ mixesIrregular: 'planeGeometry.regularCount >= 3 && planeGeometry.irregularCount > 0 && planeGeometry.complete',
20
+ },
21
+ };
22
+ const ruleSet = {
23
+ regularVolume: {
24
+ priority: -2,
25
+ matches: {
26
+ isImage: true,
27
+ Modality: ['CT', 'MR', 'PT', 'NM'],
28
+ mixesIrregular: true,
29
+ sliceIndex: { notNull: true },
30
+ },
31
+ groupBy: ['SeriesInstanceUID'],
32
+ sortBy: ['sliceDistance'],
33
+ attributes: {
34
+ SeriesDescription: {
35
+ template: '{SeriesDescription} ({sliceSpacing} mm)',
36
+ },
37
+ },
38
+ },
39
+ irregularImages: {
40
+ priority: -1,
41
+ matches: {
42
+ isImage: true,
43
+ Modality: ['CT', 'MR', 'PT', 'NM'],
44
+ mixesIrregular: true,
45
+ sliceIndex: null,
46
+ },
47
+ groupBy: ['SeriesInstanceUID'],
48
+ attributes: {
49
+ SeriesDescription: { template: '{SeriesDescription} (other images)' },
50
+ },
51
+ },
52
+ };
53
+ const series = [
54
+ slice('r3', 7.5),
55
+ slice('r0', 0),
56
+ slice('low', -1.3),
57
+ slice('r1', 2.5),
58
+ slice('r4', 10),
59
+ slice('r2', 5),
60
+ slice('mid', 3.7),
61
+ ].map((instance) => ({
62
+ ...instance,
63
+ SOPClassUID: CT_SOP_CLASS,
64
+ Rows: 512,
65
+ SeriesDescription: 'AX',
66
+ }));
67
+ it('splits the lattice from the other images, and labels each display set from its own instances', () => {
68
+ const groups = groupInstancesBySplitRules(series, createDisplaySetSplitRules(ruleSet, options));
69
+ expect(groups.map((group) => [
70
+ group.matchedRule.id,
71
+ group.instances.map((i) => i.SOPInstanceUID),
72
+ createDisplaySetFromGroup(group)
73
+ .SeriesDescription,
74
+ ])).toEqual([
75
+ ['regularVolume', ['r0', 'r1', 'r2', 'r3', 'r4'], 'AX (2.5 mm)'],
76
+ ['irregularImages', ['low', 'mid'], 'AX (other images)'],
77
+ ]);
78
+ });
79
+ });
80
+ describe('series functions in the series and in one display set', () => {
81
+ const series = [0, 1, 2, 3, 4, 5, 6, 7].map((z) => slice(`z${z}`, z, { EchoNumbers: z % 2 ? 2 : 1 }));
82
+ const rules = createDisplaySetSplitRules({
83
+ echoes: {
84
+ priority: 1,
85
+ matches: { sliceSpacing: 1 },
86
+ groupBy: ['SeriesInstanceUID', 'EchoNumbers'],
87
+ sortBy: ['displaySet.sliceDistance'],
88
+ attributes: {
89
+ spacing: 'displaySet.sliceSpacing',
90
+ firstDistance: {
91
+ expression: 'displaySet.instances.min(displaySet.sliceDistance)',
92
+ },
93
+ seriesCount: 'seriesInstanceCount',
94
+ seriesSpacing: 'sliceSpacing',
95
+ seriesFirstDistance: {
96
+ expression: 'displaySet.instances.min(sliceDistance)',
97
+ },
98
+ },
99
+ },
100
+ });
101
+ it('tests the lattice of the series in matches, and measures each display set in sortBy and attributes', () => {
102
+ const groups = groupInstancesBySplitRules([...series].reverse(), rules);
103
+ expect(groups.map((group) => {
104
+ const displaySet = createDisplaySetFromGroup(group);
105
+ return [
106
+ group.instances.map((i) => i.SOPInstanceUID),
107
+ displaySet.spacing,
108
+ displaySet.firstDistance,
109
+ displaySet.seriesCount,
110
+ displaySet.seriesSpacing,
111
+ displaySet.seriesFirstDistance,
112
+ ];
113
+ })).toEqual([
114
+ [['z0', 'z2', 'z4', 'z6'], 2, 0, 8, 1, 0],
115
+ [['z1', 'z3', 'z5', 'z7'], 2, 0, 8, 1, 1],
116
+ ]);
117
+ });
118
+ it('computes a series value one time for each split, and not one time for each display set', () => {
119
+ const compute = jest.fn((instances) => ({
120
+ count: instances.length,
121
+ }));
122
+ const rules = createDisplaySetSplitRules({
123
+ pairs: {
124
+ priority: 1,
125
+ groupBy: ['pair'],
126
+ sortBy: ['seriesCount', 'InstanceNumber'],
127
+ attributes: { count: { expression: 'summary.count' } },
128
+ },
129
+ }, {
130
+ context: {
131
+ pair: { attribute: 'Pair' },
132
+ summary: { function: 'summarize' },
133
+ seriesCount: 'summary.count',
134
+ },
135
+ seriesFunctions: {
136
+ summarize: { compute, fields: { count: 'series' } },
137
+ },
138
+ });
139
+ const groups = groupInstancesBySplitRules([0, 1, 2, 3, 4, 5].map((n) => slice(`z${n}`, n, { InstanceNumber: n, Pair: Math.floor(n / 2) })), rules);
140
+ const counts = groups.map((group) => createDisplaySetFromGroup(group)
141
+ .count);
142
+ expect(counts).toEqual([6, 6, 6]);
143
+ expect(compute).toHaveBeenCalledTimes(1);
144
+ });
145
+ });
146
+ describe('a sort after the series gets new instances', () => {
147
+ it('does not count an instance twice when the host naturalizes it again', () => {
148
+ const rules = createDisplaySetSplitRules({ all: { priority: 1 } });
149
+ const [group] = groupInstancesBySplitRules([slice('z0', 0), slice('z2', 2)], rules);
150
+ const copies = group.instances.map((instance) => ({ ...instance }));
151
+ expect(withInstances(group.series, copies)).toBe(group.series);
152
+ });
153
+ it('computes the context values again, so the new instance takes its place', () => {
154
+ const rules = createDisplaySetSplitRules({
155
+ bySlice: { priority: 1, sortBy: ['sliceDistance'] },
156
+ });
157
+ const [group] = groupInstancesBySplitRules([slice('z4', 4), slice('z0', 0), slice('z2', 2)], rules);
158
+ const reordered = orderInstancesForRule([...group.instances, slice('z1', 1)], group.matchedRule, { series: group.series });
159
+ expect(reordered.map((instance) => instance.ImagePositionPatient[2])).toEqual([0, 1, 2, 4]);
160
+ });
161
+ });
162
+ describe('planeGeometry', () => {
163
+ const series = [
164
+ slice('r0', 0),
165
+ slice('r1', 2.5),
166
+ slice('r2', 5),
167
+ slice('r3', 7.5),
168
+ slice('r4', 10),
169
+ slice('low', -1.3),
170
+ slice('mid', 3.7),
171
+ slice('sag', 0, { ImageOrientationPatient: [0, 1, 0, 0, 0, 1] }),
172
+ ];
173
+ const geometry = (instances) => planeGeometry(instances, { args: {} });
174
+ it('finds the regular lattice, and leaves the other instances off it', () => {
175
+ const result = geometry(series);
176
+ expect(result.spacing).toBe(2.5);
177
+ expect(result.normal).toEqual([0, 0, 1]);
178
+ expect(result.origin).toEqual([0, 0, 0]);
179
+ expect(result.index).toEqual({ r0: 0, r1: 1, r2: 2, r3: 3, r4: 4 });
180
+ expect([
181
+ result.regularCount,
182
+ result.irregularCount,
183
+ result.positions,
184
+ result.complete,
185
+ result.duplicates,
186
+ ]).toEqual([5, 3, 5, true, false]);
187
+ expect(result.distance.low).toBeCloseTo(-1.3);
188
+ });
189
+ it('gives the same result for any order of the instances', () => {
190
+ expect(geometry([...series].reverse())).toEqual(geometry(series));
191
+ });
192
+ it('reads two echoes of one slice as one position, and not as a gap of 0', () => {
193
+ const echoes = [0, 2, 4].flatMap((z) => [
194
+ slice(`a${z}`, z),
195
+ slice(`b${z}`, z + 0.002),
196
+ ]);
197
+ const result = geometry(echoes);
198
+ expect(result.spacing).toBe(2);
199
+ expect([result.regularCount, result.positions, result.duplicates]).toEqual([
200
+ 6,
201
+ 3,
202
+ true,
203
+ ]);
204
+ });
205
+ it('finds the lattice of a long series with work that grows about linearly', () => {
206
+ const count = 5000;
207
+ const long = Array.from({ length: count }, (_, i) => slice(`s${i}`, i % 97 ? i * 1.25 : i * 1.25 + 0.4));
208
+ const round = jest.spyOn(Math, 'round');
209
+ try {
210
+ const result = geometry(long);
211
+ expect([result.spacing, result.regularCount]).toEqual([1.25, 4948]);
212
+ expect(round.mock.calls.length).toBeLessThan(100 * count);
213
+ }
214
+ finally {
215
+ round.mockRestore();
216
+ }
217
+ });
218
+ });
219
+ describe('timeClusters', () => {
220
+ const us = (id, AcquisitionTime) => ({
221
+ SOPInstanceUID: id,
222
+ AcquisitionDate: '20260101',
223
+ AcquisitionTime,
224
+ });
225
+ it('starts a cluster after a gap of more than maxGap seconds', () => {
226
+ const result = timeClusters([
227
+ us('a', '100000'),
228
+ us('b', '100004'),
229
+ us('c', '100100'),
230
+ us('d', '100103.5'),
231
+ { SOPInstanceUID: 'untimed' },
232
+ ], { args: { maxGap: 10 } });
233
+ const startOf = (id) => result.start[id] - result.first;
234
+ expect(result.clusters).toBe(2);
235
+ expect(result.untimedCount).toBe(1);
236
+ expect(['a', 'b', 'c', 'd'].map(startOf)).toEqual([0, 0, 60, 60]);
237
+ });
238
+ });
@@ -0,0 +1,29 @@
1
+ import type { NaturalizedInstance, SeriesFunction, SeriesFunctionDefinition } from './types.js';
2
+ export declare function instanceKey(instance: NaturalizedInstance): string | undefined;
3
+ export type PlaneGeometry = {
4
+ normal?: number[];
5
+ origin?: number[];
6
+ spacing?: number;
7
+ regularCount: number;
8
+ irregularCount: number;
9
+ positions: number;
10
+ complete: boolean;
11
+ duplicates: boolean;
12
+ distance: Record<string, number>;
13
+ index: Record<string, number>;
14
+ };
15
+ export declare const planeGeometry: SeriesFunction;
16
+ export declare function dicomDateTimeToSeconds(date: unknown, time: unknown): number | undefined;
17
+ export type TimeClusters = {
18
+ time: Record<string, number>;
19
+ start: Record<string, number>;
20
+ first?: number;
21
+ clusters: number;
22
+ untimedCount: number;
23
+ };
24
+ export declare const timeClusters: SeriesFunction;
25
+ export type RevisionChains = {
26
+ active: Record<string, string>;
27
+ };
28
+ export declare const revisionChains: SeriesFunction;
29
+ export declare const BUILT_IN_SERIES_FUNCTIONS: Readonly<Record<string, SeriesFunctionDefinition>>;