11from __future__ import annotations
22
3+ import dataclasses
34import json
45from typing import TYPE_CHECKING
6+ from unittest .mock import MagicMock , patch
57
68import numpy as np
79import pytest
10+ import torch
811import zarr
12+ from torch .utils .data import Dataset
913
10- from electrai .dataloader .dataset import RhoData
14+ from electrai .dataloader .collate import collate_fn
15+ from electrai .dataloader .dataset import AddDatasetID , DatasetSpec , RhoData , RhoRead
1116from electrai .dataloader .utils import load_zarr
1217
1318if TYPE_CHECKING :
1419 from pathlib import Path
1520
1621
22+ # --- Fixtures ---
23+
24+
1725@pytest .fixture
1826def rng ():
1927 return np .random .default_rng (seed = 0 )
@@ -39,6 +47,24 @@ def filelist(tmp_path: Path, zarr_root: Path) -> Path:
3947 return filelist_path
4048
4149
50+ class SimpleDataset (Dataset ):
51+ def __init__ (self , n : int ):
52+ self .n = n
53+
54+ def __len__ (self ):
55+ return self .n
56+
57+ def __getitem__ (self , idx ):
58+ return {
59+ "data" : torch .tensor ([float (idx )]),
60+ "label" : torch .tensor ([float (idx )]),
61+ "index" : str (idx ),
62+ }
63+
64+
65+ # --- TestLoadZarr ---
66+
67+
4268class TestLoadZarr :
4369 def test_returns_arrays_divided_by_volume (self , zarr_root : Path ):
4470 data , label = load_zarr (zarr_root , "mp-1" )
@@ -57,6 +83,9 @@ def test_missing_structure_attr_raises(self, tmp_path: Path, rng):
5783 load_zarr (tmp_path , "bad" )
5884
5985
86+ # --- TestRhoDataFormatDetection ---
87+
88+
6089class TestRhoDataFormatDetection :
6190 def test_detects_zarr_format (self , filelist : Path ):
6291 dataset = RhoData (str (filelist ), precision = "f32" , augmentation = False )
@@ -90,3 +119,270 @@ def test_getitem_zarr(self, filelist: Path):
90119 assert "label" in item
91120 assert "index" in item
92121 assert item ["data" ].shape [0 ] == 1 # unsqueeze adds channel dim
122+
123+
124+ # --- TestAddDatasetID ---
125+
126+
127+ class TestAddDatasetID :
128+ def test_len_preserved (self ):
129+ base = SimpleDataset (10 )
130+ wrapped = AddDatasetID (base , functional_id = 3 )
131+ assert len (wrapped ) == 10
132+
133+ def test_dataset_id_added (self ):
134+ base = SimpleDataset (5 )
135+ wrapped = AddDatasetID (base , functional_id = 7 )
136+ sample = wrapped [0 ]
137+ assert sample ["Dataset_ID" ] == 7
138+
139+ def test_dataset_id_is_int (self ):
140+ base = SimpleDataset (3 )
141+ wrapped = AddDatasetID (base , functional_id = "2" )
142+ assert isinstance (wrapped [0 ]["Dataset_ID" ], int )
143+
144+ def test_original_keys_preserved (self ):
145+ base = SimpleDataset (3 )
146+ wrapped = AddDatasetID (base , functional_id = 1 )
147+ sample = wrapped [0 ]
148+ assert "data" in sample
149+ assert "label" in sample
150+ assert "index" in sample
151+
152+ def test_custom_key (self ):
153+ base = SimpleDataset (3 )
154+ wrapped = AddDatasetID (base , functional_id = 5 , key = "src_id" )
155+ assert "src_id" in wrapped [0 ]
156+ assert wrapped [0 ]["src_id" ] == 5
157+
158+
159+ # --- TestCollateFn ---
160+
161+
162+ class TestCollateFn :
163+ def test_uniform_shapes_uses_default_collate (self ):
164+ batch = [
165+ {
166+ "data" : torch .zeros (4 , 4 , 4 ),
167+ "label" : torch .ones (4 , 4 , 4 ),
168+ "index" : "a" ,
169+ "Dataset_ID" : 0 ,
170+ },
171+ {
172+ "data" : torch .zeros (4 , 4 , 4 ),
173+ "label" : torch .ones (4 , 4 , 4 ),
174+ "index" : "b" ,
175+ "Dataset_ID" : 0 ,
176+ },
177+ ]
178+ result = collate_fn (batch )
179+ assert isinstance (result , dict )
180+ assert result ["data" ].shape == (2 , 4 , 4 , 4 )
181+
182+ def test_mismatched_shapes_fallback_returns_dict (self ):
183+ batch = [
184+ {
185+ "data" : torch .zeros (4 , 4 , 4 ),
186+ "label" : torch .ones (4 , 4 , 4 ),
187+ "index" : "a" ,
188+ "Dataset_ID" : 0 ,
189+ },
190+ {
191+ "data" : torch .zeros (8 , 8 , 8 ),
192+ "label" : torch .ones (8 , 8 , 8 ),
193+ "index" : "b" ,
194+ "Dataset_ID" : 1 ,
195+ },
196+ ]
197+ result = collate_fn (batch )
198+ assert isinstance (result , dict )
199+ assert len (result ["data" ]) == 2
200+ assert len (result ["label" ]) == 2
201+
202+ def test_fallback_preserves_all_keys (self ):
203+ batch = [
204+ {
205+ "data" : torch .zeros (4 , 4 , 4 ),
206+ "label" : torch .ones (4 , 4 , 4 ),
207+ "index" : "a" ,
208+ "Dataset_ID" : 0 ,
209+ },
210+ {
211+ "data" : torch .zeros (8 , 8 , 8 ),
212+ "label" : torch .ones (8 , 8 , 8 ),
213+ "index" : "b" ,
214+ "Dataset_ID" : 1 ,
215+ },
216+ ]
217+ result = collate_fn (batch )
218+ assert set (result .keys ()) == {"data" , "label" , "index" , "Dataset_ID" }
219+
220+ def test_fallback_correct_values (self ):
221+ batch = [
222+ {
223+ "data" : torch .zeros (2 , 2 , 2 ),
224+ "label" : torch .ones (2 , 2 , 2 ),
225+ "index" : "x" ,
226+ "Dataset_ID" : 3 ,
227+ },
228+ {
229+ "data" : torch .zeros (3 , 3 , 3 ),
230+ "label" : torch .ones (3 , 3 , 3 ),
231+ "index" : "y" ,
232+ "Dataset_ID" : 5 ,
233+ },
234+ ]
235+ result = collate_fn (batch )
236+ assert result ["index" ] == ["x" , "y" ]
237+ assert result ["Dataset_ID" ] == [3 , 5 ]
238+
239+
240+ # --- TestDatasetSpec ---
241+
242+
243+ class TestDatasetSpec :
244+ def test_required_root (self ):
245+ spec = DatasetSpec (root = "/some/path" )
246+ assert spec .root == "/some/path"
247+
248+ def test_defaults_are_none (self ):
249+ spec = DatasetSpec (root = "/p" )
250+ assert spec .split_file is None
251+ assert spec .val_frac is None
252+ assert spec .dataset_id is None
253+
254+ def test_frozen (self ):
255+ spec = DatasetSpec (root = "/p" )
256+ with pytest .raises (dataclasses .FrozenInstanceError ):
257+ spec .root = "/other"
258+
259+
260+ # --- TestRhoReadInit ---
261+
262+
263+ class TestRhoReadInit :
264+ def test_empty_datasets_raises (self ):
265+ with pytest .raises (ValueError , match = "at least one" ):
266+ RhoRead (datasets = [])
267+
268+ def test_none_datasets_raises (self ):
269+ with pytest .raises (ValueError , match = "at least one" ):
270+ RhoRead (datasets = None )
271+
272+ def test_auto_assigns_dataset_id (self ):
273+ reader = RhoRead (
274+ datasets = [{"root" : "/a/filelist.txt" }, {"root" : "/b/filelist.txt" }]
275+ )
276+ assert reader .specs [0 ].dataset_id == 0
277+ assert reader .specs [1 ].dataset_id == 1
278+
279+ def test_explicit_dataset_id_preserved (self ):
280+ reader = RhoRead (datasets = [{"root" : "/a/filelist.txt" , "dataset_id" : 42 }])
281+ assert reader .specs [0 ].dataset_id == 42
282+
283+ def test_default_val_frac_applied (self ):
284+ reader = RhoRead (datasets = [{"root" : "/a/filelist.txt" }], default_val_frac = 0.1 )
285+ assert reader .specs [0 ].val_frac == 0.1
286+
287+ def test_per_spec_val_frac_overrides_default (self ):
288+ reader = RhoRead (
289+ datasets = [{"root" : "/a/filelist.txt" , "val_frac" : 0.2 }],
290+ default_val_frac = 0.1 ,
291+ )
292+ assert reader .specs [0 ].val_frac == 0.2
293+
294+ def test_default_split_file_applied (self ):
295+ reader = RhoRead (
296+ datasets = [{"root" : "/a/filelist.txt" }],
297+ default_split_file = "/default/split.json" ,
298+ )
299+ assert reader .specs [0 ].split_file == "/default/split.json"
300+
301+ def test_per_spec_split_file_overrides_default (self ):
302+ reader = RhoRead (
303+ datasets = [{"root" : "/a/filelist.txt" , "split_file" : "/custom/split.json" }],
304+ default_split_file = "/default/split.json" ,
305+ )
306+ assert reader .specs [0 ].split_file == "/custom/split.json"
307+
308+
309+ # --- TestRhoReadSetup ---
310+
311+
312+ class TestRhoReadSetup :
313+ def _make_fake_splits (self , n : int = 5 ):
314+ ds = SimpleDataset (n )
315+ from torch .utils .data import Subset
316+
317+ train = Subset (ds , list (range (4 )))
318+ val = Subset (ds , [4 ])
319+ return {"train" : train , "validation" : val }
320+
321+ @patch ("electrai.dataloader.dataset.split_data" )
322+ @patch ("electrai.dataloader.dataset.RhoData" )
323+ def test_fit_stage_builds_train_and_val (self , mock_rho , mock_split ):
324+ mock_rho .return_value = MagicMock (spec = Dataset )
325+ mock_split .return_value = self ._make_fake_splits ()
326+
327+ reader = RhoRead (datasets = [{"root" : "/a/filelist.txt" }])
328+ reader .setup ("fit" )
329+
330+ assert reader .train_set is not None
331+ assert reader .val_set is not None
332+ assert reader .test_set is None
333+
334+ @patch ("electrai.dataloader.dataset.split_data" )
335+ @patch ("electrai.dataloader.dataset.RhoData" )
336+ def test_test_stage_does_not_build_train_or_val (self , mock_rho , mock_split ):
337+ mock_rho .return_value = MagicMock (spec = Dataset )
338+ splits = self ._make_fake_splits ()
339+ splits ["test" ] = splits ["train" ]
340+ mock_split .return_value = splits
341+
342+ reader = RhoRead (datasets = [{"root" : "/a/filelist.txt" }])
343+ reader .setup ("test" )
344+
345+ assert reader .test_set is not None
346+ assert reader .train_set is None
347+ assert reader .val_set is None
348+
349+ @patch ("electrai.dataloader.dataset.split_data" )
350+ @patch ("electrai.dataloader.dataset.RhoData" )
351+ def test_multi_dataset_concat (self , mock_rho , mock_split ):
352+ from torch .utils .data import ConcatDataset
353+
354+ mock_rho .return_value = MagicMock (spec = Dataset )
355+ mock_split .return_value = self ._make_fake_splits ()
356+
357+ reader = RhoRead (
358+ datasets = [{"root" : "/a/filelist.txt" }, {"root" : "/b/filelist.txt" }]
359+ )
360+ reader .setup ("fit" )
361+
362+ assert isinstance (reader .train_set , ConcatDataset )
363+ assert isinstance (reader .val_set , ConcatDataset )
364+
365+ @patch ("electrai.dataloader.dataset.split_data" )
366+ @patch ("electrai.dataloader.dataset.RhoData" )
367+ def test_single_dataset_no_concat (self , mock_rho , mock_split ):
368+ from torch .utils .data import ConcatDataset
369+
370+ mock_rho .return_value = MagicMock (spec = Dataset )
371+ mock_split .return_value = self ._make_fake_splits ()
372+
373+ reader = RhoRead (datasets = [{"root" : "/a/filelist.txt" }])
374+ reader .setup ("fit" )
375+
376+ assert not isinstance (reader .train_set , ConcatDataset )
377+
378+ @patch ("electrai.dataloader.dataset.split_data" )
379+ @patch ("electrai.dataloader.dataset.RhoData" )
380+ def test_dataset_id_propagated (self , mock_rho , mock_split ):
381+ mock_rho .return_value = MagicMock (spec = Dataset )
382+ mock_split .return_value = self ._make_fake_splits ()
383+
384+ reader = RhoRead (datasets = [{"root" : "/a/filelist.txt" , "dataset_id" : 99 }])
385+ reader .setup ("fit" )
386+
387+ sample = reader .train_set [0 ]
388+ assert sample ["Dataset_ID" ] == 99
0 commit comments