|
159 | 159 | } |
160 | 160 |
|
161 | 161 | // Split .set + .fdt path: the .fdt is a flat float32 blob with no |
162 | | - // header. We prefer the BIDS sidecar (_channels.tsv + _eeg.json) |
163 | | - // for nChannels and SamplingFrequency, but it's not always there. |
| 162 | + // header. We need nChannels + SamplingFrequency to interpret it. |
164 | 163 | // |
165 | | - // Real-world examples (EEGDash audit seed=44): |
166 | | - // ds003645: .fdt + .set + _electrodes.tsv only — no _channels.tsv |
167 | | - // ds003751: _eeg.json case-mismatch with .set basename — BIDS |
168 | | - // inheritance walk fails to find the JSON |
| 164 | + // Source priority — the .set is the authority, not the BIDS sidecar: |
| 165 | + // - sidecar _channels.tsv may list ALL acquired channels (including |
| 166 | + // bad/dropped ones, MEG system channels, status, etc.) while the |
| 167 | + // .set stores only the channels that were actually written to |
| 168 | + // the .fdt (after preprocessing / ICA / channel selection). |
| 169 | + // - Real example (ds003645): _channels.tsv has 404 entries (full |
| 170 | + // MEG sensor array + EEG + triggers); .set says nbchan=75 (the |
| 171 | + // subset that was preprocessed). .fdt size 162030000 = 75 × 4 × |
| 172 | + // 540100 → matches the .set, not the sidecar. |
169 | 173 | // |
170 | | - // Fallback: parse the .set itself to extract nbchan + srate. The |
171 | | - // .set has these fields even when it's a split-file pair, because |
172 | | - // EEGLAB writes the full EEG struct to .set and only the numeric |
173 | | - // data array goes to .fdt. Mirrors MNE's behaviour in |
174 | | - // mne/io/eeglab/eeglab.py::_check_load_mat. |
175 | | - let nChannels = nChannelsFromSidecar; |
176 | | - let fs = sidecarFsValid ? sidecarFs : null; |
| 174 | + // Strategy: |
| 175 | + // 1. Always try to parse the .set to get its authoritative nbchan |
| 176 | + // + srate (the .fdt-data writer wrote these in lockstep with |
| 177 | + // the .fdt's actual layout). |
| 178 | + // 2. If .set is unparseable, fall back to the BIDS sidecar values. |
| 179 | + // 3. Warn if sidecar and .set disagree (BIDS data-curation hint). |
| 180 | + let nChannels = null; |
| 181 | + let fs = null; |
| 182 | + let setParseFailed = false; |
| 183 | + let setParseError = null; |
177 | 184 | if (nChannels == null || fs == null) { |
178 | 185 | try { |
179 | 186 | const setBuf = await HttpRange.fetchBuffer(meta.eeg_url); |
|
199 | 206 | }; |
200 | 207 | const nbchanFromSet = scalarFrom('nbchan'); |
201 | 208 | const srateFromSet = scalarFrom('srate'); |
202 | | - if (nChannels == null && nbchanFromSet) nChannels = nbchanFromSet; |
203 | | - if (fs == null && srateFromSet && srateFromSet > 0) fs = srateFromSet; |
204 | | - if (nChannels != null && fs != null) { |
| 209 | + if (nbchanFromSet) nChannels = nbchanFromSet; |
| 210 | + if (srateFromSet && srateFromSet > 0) fs = srateFromSet; |
| 211 | + // Warn loudly when sidecar and .set disagree — almost always a |
| 212 | + // sign of post-acquisition channel selection / preprocessing |
| 213 | + // that wasn't reflected in the BIDS curation. |
| 214 | + if (nChannelsFromSidecar != null && nChannels != null && |
| 215 | + nChannels !== nChannelsFromSidecar) { |
205 | 216 | console.warn( |
206 | | - `EEGLAB .set+.fdt: BIDS sidecar incomplete; using .set's own ` + |
207 | | - `EEG.nbchan=${nChannels} and EEG.srate=${fs}.`, |
| 217 | + `EEGLAB .set+.fdt: sidecar _channels.tsv lists ` + |
| 218 | + `${nChannelsFromSidecar} channels but the .set declares ` + |
| 219 | + `EEG.nbchan=${nChannels}. Trusting the .set (it matches ` + |
| 220 | + `the .fdt's actual layout; the sidecar likely lists all ` + |
| 221 | + `acquired channels including dropped/system ones).`, |
208 | 222 | ); |
209 | 223 | } |
210 | | - } catch (e) { |
211 | | - // .set parse failure is recoverable as long as sidecar gave us |
212 | | - // what we need. Surface only if BOTH sources fail. |
213 | | - if (nChannels == null || fs == null) { |
214 | | - throw new Error( |
215 | | - `EEGLAB .set+.fdt: need either _channels.tsv + _eeg.json BIDS ` + |
216 | | - `sidecars OR a parseable .set with EEG.nbchan + EEG.srate. ` + |
217 | | - `Set parse error: ${e.message}`, |
| 224 | + if (sidecarFsValid && fs != null && Math.abs(fs - sidecarFs) > 0.5) { |
| 225 | + console.warn( |
| 226 | + `EEGLAB .set+.fdt: sidecar SamplingFrequency=${sidecarFs} but ` + |
| 227 | + `EEG.srate=${fs} in the .set. Trusting the .set.`, |
218 | 228 | ); |
219 | 229 | } |
| 230 | + } catch (e) { |
| 231 | + setParseFailed = true; |
| 232 | + setParseError = e; |
220 | 233 | } |
221 | 234 | } |
| 235 | + // Sidecar fallback: only if .set parse failed AND sidecar has values. |
| 236 | + if (nChannels == null && nChannelsFromSidecar != null) { |
| 237 | + nChannels = nChannelsFromSidecar; |
| 238 | + } |
| 239 | + if (fs == null && sidecarFsValid) { |
| 240 | + fs = sidecarFs; |
| 241 | + } |
| 242 | + if (!nChannels && setParseFailed) { |
| 243 | + throw new Error( |
| 244 | + `EEGLAB .set+.fdt: need either parseable .set with EEG.nbchan + EEG.srate ` + |
| 245 | + `OR _channels.tsv + _eeg.json BIDS sidecars. ` + |
| 246 | + `Set parse error: ${setParseError ? setParseError.message : 'unknown'}`, |
| 247 | + ); |
| 248 | + } |
222 | 249 | if (!nChannels) { |
223 | 250 | throw new Error( |
224 | 251 | 'EEGLAB .fdt reader needs nChannels (either from _channels.tsv ' + |
|
0 commit comments