Skip to content

Commit 8160e9a

Browse files
authored
Merge pull request #140 from AFM-SPM/maxgamill-sheffield/general-file-loader
Add general loader module
2 parents 8e531fc + 02e0506 commit 8160e9a

7 files changed

Lines changed: 273 additions & 8 deletions

File tree

AFMReader/asd.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ def load_asd(file_path: str | Path, channel: str):
262262
_ = open_file.read(length_of_all_first_channel_frames)
263263
else:
264264
raise ValueError(
265-
f"Channel {channel} not found in this file's available channels: "
265+
f"'{channel}' not found {file_path.suffix} channel list: "
266266
f"{header_dict['channel1']}, {header_dict['channel2']}"
267267
)
268268

@@ -288,6 +288,7 @@ def load_asd(file_path: str | Path, channel: str):
288288

289289
frames = np.array(frames)
290290

291+
logger.info(f"[{filename}] : Extracted image.")
291292
return frames, pixel_to_nanometre_scaling_factor, header_dict
292293

293294

AFMReader/general_loader.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Switchboard for input files."""
2+
3+
from __future__ import annotations
4+
from pathlib import Path
5+
6+
import numpy.typing as npt
7+
8+
from AFMReader import asd, gwy, ibw, jpk, spm, stp, top, topostats
9+
from AFMReader.logging import logger
10+
11+
logger.enable(__package__)
12+
13+
14+
# pylint: disable=too-few-public-methods
15+
class LoadFile:
16+
"""
17+
Class to handle the general loading of an AFM file.
18+
19+
Parameters
20+
----------
21+
filepath : Path
22+
Path to the AFM image.
23+
channel : str
24+
Channel to extract from the AFM image.
25+
"""
26+
27+
def __init__(self, filepath: str | Path, channel: str):
28+
"""
29+
Initialise the general LoadFile class with a filepath and channel.
30+
31+
Parameters
32+
----------
33+
filepath : str | Path
34+
Path to the AFM image.
35+
channel : str
36+
Channel to extract from the AFM image.
37+
"""
38+
self.filepath = Path(filepath)
39+
self.channel = channel
40+
self.suffix = self.filepath.suffix
41+
42+
def load(self) -> tuple[npt.NDArray | str, float | None]: # noqa: C901
43+
"""
44+
Generally loads a file type that can be handled by AFMReader.
45+
46+
Returns
47+
-------
48+
tuple
49+
The image data (stack if ''.asd'') and the pixel to nanometre scaling ratio.
50+
51+
Raises
52+
------
53+
ValueError
54+
Where the channel is not found, returned as a tuple of "error message" and "None" so that this can be
55+
propagated to Napari without outright failing.
56+
"""
57+
try:
58+
if self.suffix == ".asd":
59+
image, pixel_to_nanometre_scaling_factor, _ = asd.load_asd(self.filepath, self.channel)
60+
elif self.suffix == ".gwy":
61+
image, pixel_to_nanometre_scaling_factor = gwy.load_gwy(self.filepath, self.channel)
62+
elif self.suffix == ".ibw":
63+
image, pixel_to_nanometre_scaling_factor = ibw.load_ibw(self.filepath, self.channel)
64+
elif self.suffix == ".jpk":
65+
image, pixel_to_nanometre_scaling_factor = jpk.load_jpk(self.filepath, self.channel)
66+
elif self.suffix == ".spm":
67+
image, pixel_to_nanometre_scaling_factor = spm.load_spm(self.filepath, self.channel)
68+
elif self.suffix == ".stp":
69+
image, pixel_to_nanometre_scaling_factor = stp.load_stp(self.filepath)
70+
elif self.suffix == ".top":
71+
image, pixel_to_nanometre_scaling_factor = top.load_top(self.filepath)
72+
elif self.suffix == ".topostats":
73+
ts_dict = topostats.load_topostats(self.filepath)
74+
try:
75+
image = ts_dict[self.channel]
76+
pixel_to_nanometre_scaling_factor = ts_dict["pixel_to_nm_scaling"]
77+
except KeyError as exc:
78+
image_keys = ["image", "image_original"]
79+
topostats_keys = list(ts_dict.keys())
80+
raise ValueError(
81+
f"'{self.channel}' not in available image keys: "
82+
f"{[im for im in image_keys if im in topostats_keys]}"
83+
) from exc
84+
else:
85+
raise ValueError(f"File type '{self.suffix}' is not currently handled by AFMReader.")
86+
87+
return image, pixel_to_nanometre_scaling_factor
88+
89+
except ValueError as e:
90+
logger.error(f"{e}")
91+
return (e, None) # cheeky return of an image, px2nm-like tuple object to propagate error message to Napari
92+
93+
# scope for a "check what channels are available" function similar to above.

AFMReader/gwy.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def load_gwy(file_path: Path | str, channel: str) -> tuple[np.ndarray[Any, np.dt
6363
channel_ids = gwy_get_channels(gwy_file_structure=image_data_dict)
6464

6565
if channel not in channel_ids:
66-
raise KeyError(f"Channel {channel} not found in {file_path.suffix} channel list: {channel_ids}")
66+
raise KeyError(f"Channel '{channel}' not found in {file_path.suffix} channel list: {channel_ids}")
6767

6868
# Get the image data
6969
image = image_data_dict[f"/{channel_ids[channel]}/data"]["data"]
@@ -85,7 +85,11 @@ def load_gwy(file_path: Path | str, channel: str) -> tuple[np.ndarray[Any, np.dt
8585
except FileNotFoundError:
8686
logger.info(f"[{filename}] File not found : {file_path}")
8787
raise
88+
except KeyError as e:
89+
logger.error(f"[{filename}] : '{channel}' not found in {file_path.suffix} channel list: {channel_ids}")
90+
raise ValueError(f"'{channel}' not found in {file_path.suffix} channel list: {channel_ids}") from e
8891

92+
logger.info(f"[{filename}] : Extracted image.")
8993
return (image, px_to_nm)
9094

9195

AFMReader/ibw.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ def load_ibw(file_path: Path | str, channel: str) -> tuple[np.ndarray, float]:
9393
except FileNotFoundError:
9494
logger.error(f"[{filename}] File not found : {file_path}")
9595
raise
96-
except ValueError:
97-
logger.error(f"[{filename}] : {channel} not in {file_path.suffix} channel list: {labels}")
98-
raise
96+
except ValueError as e:
97+
logger.error(f"[{filename}] : '{channel}' not in {file_path.suffix} channel list: {labels}")
98+
raise ValueError(f"'{channel}' not in {file_path.suffix} channel list: {labels}") from e
9999
except Exception as e:
100100
logger.error(f"[{filename}] : {e}")
101101
raise e
102102

103+
logger.info(f"[{filename}] : Extracted image.")
103104
return (image, _ibw_pixel_to_nm_scaling(scan))

AFMReader/jpk.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,9 @@ def load_jpk(
225225
channel_list[f"{available_channel}_{tr_rt}"] = i + 1
226226
try:
227227
channel_idx = channel_list[channel]
228-
except KeyError:
229-
logger.error(f"{channel} not in channel list: {channel_list}")
230-
raise
228+
except KeyError as e:
229+
logger.error(f"'{channel}' not in {file_path.suffix} channel list: {channel_list}")
230+
raise ValueError(f"'{channel}' not in {file_path.suffix} channel list: {channel_list}") from e
231231

232232
# Get image and if applicable, scale it
233233
channel_page = tif.pages[channel_idx]
@@ -242,6 +242,8 @@ def load_jpk(
242242

243243
# Get page for common metadata between scans
244244
metadata_page = tif.pages[0]
245+
246+
logger.info(f"[{filename}] : Extracted image.")
245247
return (image, _jpk_pixel_to_nm_scaling(metadata_page, jpk_tags))
246248

247249

AFMReader/topostats.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ def load_topostats(file_path: Path | str) -> dict[str, Any]:
5252
logger.error(f"[{filename}] File not found : {file_path}")
5353
raise e
5454

55+
logger.info(f"[{filename}] : Extracted .topostats dictionary.")
5556
return data

tests/test_general_loader.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Test the general loader module."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
import numpy as np
7+
from AFMReader import general_loader
8+
9+
10+
BASE_DIR = Path.cwd()
11+
RESOURCES = BASE_DIR / "tests" / "resources"
12+
13+
14+
@pytest.mark.parametrize(
15+
("filepath", "channel", "error", "message"),
16+
[
17+
pytest.param(
18+
RESOURCES / "sample_0.asd",
19+
"TP",
20+
False,
21+
"Extracted image",
22+
id="'.asd' success.",
23+
),
24+
pytest.param(
25+
RESOURCES / "sample_0.asd",
26+
"notherelol",
27+
True,
28+
"'notherelol' not found .asd channel list: TP, PH",
29+
id="'asd' channel not found.",
30+
),
31+
pytest.param(
32+
RESOURCES / "sample_0.gwy",
33+
"ZSensor",
34+
False,
35+
"Extracted image",
36+
id="'.gwy' success.",
37+
),
38+
pytest.param(
39+
RESOURCES / "sample_0.gwy",
40+
"SenZor",
41+
True,
42+
"'SenZor' not found in .gwy channel list: {'ZSensor': '0', 'Peak Force Error': '1', 'Stiffness': '2', "
43+
"'LogStiffness': '3', 'Adhesion': '4', 'Deformation': '5', 'Dissipation': '6', 'Height': '7'}",
44+
id="'.gwy' channel not found.",
45+
),
46+
pytest.param(
47+
RESOURCES / "sample_0.ibw",
48+
"HeightTracee",
49+
False,
50+
"Extracted image",
51+
id="'.ibw' success.",
52+
),
53+
pytest.param(
54+
RESOURCES / "sample_0.ibw",
55+
"Hight",
56+
True,
57+
"'Hight' not in .ibw channel list: ['HeightTracee', 'HeightRetrace', 'ZSensorTrace', 'ZSensorRetrace', "
58+
"'UserIn0Trace', 'UserIn0Retrace', 'UserIn1Trace', 'UserIn1Retrace']",
59+
id="'.ibw' channel not found.",
60+
),
61+
pytest.param(
62+
RESOURCES / "sample_0.jpk",
63+
"height_trace",
64+
False,
65+
"Extracted image",
66+
id="'.jpk' success.",
67+
),
68+
pytest.param(
69+
RESOURCES / "sample_0.jpk",
70+
"might_base",
71+
True,
72+
"'might_base' not in .jpk channel list: {'height_retrace': 1, 'measuredHeight_retrace': 2, "
73+
"'amplitude_retrace': 3, 'phase_retrace': 4, 'error_retrace': 5, 'height_trace': 6, "
74+
"'measuredHeight_trace': 7, 'amplitude_trace': 8, 'phase_trace': 9, 'error_trace': 10}",
75+
id="'.jpk' channel not found.",
76+
),
77+
pytest.param(
78+
RESOURCES / "sample_0.spm",
79+
"Height",
80+
False,
81+
"Extracted channel Height",
82+
id="'.spm' success.",
83+
),
84+
pytest.param(
85+
RESOURCES / "sample_0.spm",
86+
"Force",
87+
True,
88+
"'Force' not in .spm channel list: ['Height Sensor', 'Peak Force Error', 'DMTModulus', 'LogDMTModulus', "
89+
"'Adhesion', 'Deformation', 'Dissipation', 'Height']",
90+
id="'.spm' channel not found.",
91+
),
92+
pytest.param(
93+
RESOURCES / "sample_0.stp",
94+
"",
95+
False,
96+
"Extracted image",
97+
id="'.stp' success.",
98+
),
99+
pytest.param(
100+
RESOURCES / "sample_0.top",
101+
"",
102+
False,
103+
"Extracted image",
104+
id="'.top' success.",
105+
),
106+
pytest.param(
107+
RESOURCES / "sample_0_1.topostats",
108+
"image",
109+
False,
110+
"Extracted .topostats dictionary.",
111+
id="'.topostats' success.",
112+
),
113+
pytest.param(
114+
RESOURCES / "sample_0_1.topostats",
115+
"hgjswbweongp",
116+
True,
117+
"'hgjswbweongp' not in available image keys: ['image']",
118+
id="'.topostats' channel not found.",
119+
),
120+
pytest.param(
121+
RESOURCES / "sample_0.xxx",
122+
"NotAChannel",
123+
True,
124+
"File type '.xxx' is not currently handled by AFMReader.",
125+
id="'.xxx' unsupported filetype.",
126+
),
127+
],
128+
)
129+
def test_load(caplog: pytest.LogCaptureFixture, filepath: Path, channel: str, error: bool, message: str) -> None:
130+
"""Test loading of all (asd, gwy, ibw, jpk, spm, stp, top, topostats) filetypes."""
131+
loader = general_loader.LoadFile(filepath, channel)
132+
133+
image, px2nm = loader.load()
134+
135+
if not error:
136+
# check array and px2nm returned
137+
assert isinstance(image, np.ndarray)
138+
assert isinstance(px2nm, float)
139+
else:
140+
# check when channel wrong
141+
assert isinstance(image, ValueError)
142+
assert px2nm is None
143+
144+
# check output logs
145+
assert message in caplog.text
146+
147+
148+
@pytest.mark.parametrize(
149+
("filepath"),
150+
[
151+
pytest.param(
152+
RESOURCES / "not_a_real_file.spm",
153+
id="File not found error raised.",
154+
),
155+
],
156+
)
157+
def test_load_filenotfounderror(filepath: Path) -> None:
158+
"""Test that a file not found error is raise when filepath is wrong."""
159+
loader = general_loader.LoadFile(filepath, "channel")
160+
161+
with pytest.raises(FileNotFoundError) as execinfo: # noqa: PT012
162+
_, _ = loader.load()
163+
assert "[not_a_real_file] FileNotFoundError" in execinfo.value

0 commit comments

Comments
 (0)