"""Unified entry point for loading Bruker EPR data files.
Exposes :func:`eprload`, which auto-detects BES3T (.dta/.dsc) or ESP/WinEPR
(.spc/.par) format and dispatches to the appropriate loader in
:mod:`epyr.sub`. Tkinter is imported for the optional file-picker dialog.
"""
import warnings
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import matplotlib.pyplot as plt
import numpy as np
import tkinter as tk
from tkinter import filedialog
from .logging_config import get_logger
from .performance import MemoryMonitor, get_global_cache
logger = get_logger(__name__)
# Import loading modules
try:
from .sub import loadBES3T, loadESP
except ImportError:
try:
from sub import loadBES3T, loadESP
except ImportError as e:
raise ImportError(
"Could not import loading modules from 'sub' directory. "
"Ensure the package is properly installed."
) from e
def _select_file_dialog(initial_dir: Path) -> Optional[Path]:
"""Open a file dialog to select an EPR data file.
Parameters
----------
initial_dir : pathlib.Path
Initial directory shown by the dialog.
Returns
-------
pathlib.Path or None
Selected file path, or ``None`` if the user cancelled.
"""
root = tk.Tk()
root.withdraw() # Hide the main tkinter window
ui_file_path = filedialog.askopenfilename(
title="Load EPR data file...",
initialdir=str(initial_dir),
filetypes=[
("Bruker BES3T", "*.DTA *.dta *.DSC *.dsc"),
("Bruker ESP/WinEPR", "*.spc *.SPC *.par *.PAR"),
("All files", "*.*"),
],
)
root.destroy() # Close the hidden window
if not ui_file_path:
logger.info("File selection cancelled by user")
return None
return Path(ui_file_path)
def _find_file_with_extension(file_path: Path) -> Optional[Path]:
"""Find a file, trying known EPR extensions if needed.
Parameters
----------
file_path : pathlib.Path
Path to search for, with or without extension.
Returns
-------
pathlib.Path or None
Found file path, or ``None`` if no candidate exists.
"""
# First, check if file exists as-is
if file_path.is_file():
return file_path
# If not, try adding known extensions
# Use string concatenation to avoid with_suffix() issues with multiple dots
base_str = str(file_path)
for ext in [".dta", ".DTA", ".dsc", ".DSC", ".spc", ".SPC", ".par", ".PAR"]:
potential_file = Path(base_str + ext)
if potential_file.is_file():
logger.info(f"Found file with extension '{ext}': {potential_file.name}")
return potential_file
return None
def _get_file_extension(file_path: Path) -> str:
"""Return the EPR file extension, tolerating filenames with multiple dots.
Parameters
----------
file_path : pathlib.Path
Path to extract the extension from.
Returns
-------
str
Extension (e.g. ``'.dta'``, ``'.DSC'``), or ``''`` if none matches.
"""
# Get the filename and check known extensions
filename = file_path.name
for ext in [".dta", ".DTA", ".dsc", ".DSC", ".spc", ".SPC", ".par", ".PAR"]:
if filename.endswith(ext):
return ext
# If no known extension found, return empty string
return ""
def _determine_file_format(file_path: Path) -> Tuple[Path, str]:
"""Resolve the data file and identify its Bruker format.
Parameters
----------
file_path : pathlib.Path
Path to the data file (with or without extension).
Returns
-------
tuple of (pathlib.Path, str)
Resolved file path and format tag (``'BrukerBES3T'`` or
``'BrukerESP'``).
Raises
------
FileNotFoundError
If no candidate file is found.
ValueError
If the file extension is not a supported Bruker format.
"""
# Try to find the file with or without extension
found_file = _find_file_with_extension(file_path)
if found_file is None:
raise FileNotFoundError(
f"Could not find EPR data file '{file_path}' with any supported extension "
f"(.dta, .dsc, .spc, .par)"
)
file_path = found_file
file_extension = _get_file_extension(file_path)
if not file_extension:
raise ValueError(
f"File '{file_path}' does not have a recognized EPR extension "
f"(.dta, .dsc, .spc, .par)"
)
# Determine format based on extension (case-insensitive)
ext_upper = file_extension.upper()
if ext_upper in [".DTA", ".DSC"]:
file_format = "BrukerBES3T"
elif ext_upper in [".PAR", ".SPC"]:
file_format = "BrukerESP"
else:
raise ValueError(
f"Unsupported file extension '{file_extension}'. "
"Only Bruker formats "
"(.dta, .dsc, .spc, .par) supported."
)
return file_path, file_format
def _validate_scaling(scaling: str) -> None:
"""Check that ``scaling`` only contains supported flag characters.
Parameters
----------
scaling : str
Scaling specification (subset of ``'nPGTc'``).
Raises
------
ValueError
If ``scaling`` contains any character outside ``'nPGTc'``.
"""
if scaling:
valid_scaling_chars = "nPGTc"
invalid_chars = set(scaling) - set(valid_scaling_chars)
if invalid_chars:
raise ValueError(
f"Scaling string contains invalid characters: {invalid_chars}. "
f"Allowed: '{valid_scaling_chars}'."
)
def _load_data_by_format(file_path: Path, file_format: str, scaling: str) -> Tuple[
Optional[np.ndarray],
Optional[Union[np.ndarray, List[np.ndarray]]],
Optional[Dict[str, Any]],
]:
"""Dispatch to the loader matching ``file_format``.
Parameters
----------
file_path : pathlib.Path
Path to the data file.
file_format : str
Format tag (``'BrukerBES3T'`` or ``'BrukerESP'``).
scaling : str
Scaling specification passed through to the loader.
Returns
-------
tuple
``(y_data, x_data, parameters)``. Each entry may be ``None`` if
the loader could not produce it.
Raises
------
ValueError
If ``file_format`` is not recognized.
"""
# Get extension without using with_suffix() to handle multiple dots correctly
file_extension = _get_file_extension(file_path)
# Remove extension from filename to get base name
file_str = str(file_path)
if file_extension and file_str.endswith(file_extension):
full_base_name = Path(file_str[: -len(file_extension)])
else:
full_base_name = file_path
if file_format == "BrukerBES3T":
return loadBES3T.load(full_base_name, file_extension, scaling)
elif file_format == "BrukerESP":
return loadESP.load(full_base_name, file_extension, scaling)
else:
raise ValueError(f"Unknown file format: {file_format}")
[docs]
def eprload(
file_name: Optional[Union[str, Path]] = None,
scaling: str = "",
plot_if_possible: bool = False,
save_if_possible: bool = False,
return_type: str = "default",
) -> Tuple[
Optional[Union[np.ndarray, List[np.ndarray]]],
Optional[np.ndarray],
Optional[Dict[str, Any]],
Optional[str],
]:
"""
Load experimental EPR data from Bruker BES3T or ESP formats.
Parameters
----------
file_name : str or Path, optional
Path to the data file (.dta, .dsc, .spc, .par) or a directory.
If None or a directory, a file browser is shown. Default is None
(opens browser in the current working directory).
scaling : str, optional
String of characters specifying scaling operations (Bruker files only).
Each character enables one operation:
- ``'n'`` : divide by number of scans (AVGS/JSD)
- ``'P'`` : divide by sqrt of microwave power in mW (MWPW/MP)
- ``'G'`` : divide by receiver gain (RCAG/RRG)
- ``'T'`` : multiply by temperature in Kelvin (STMP/TE)
- ``'c'`` : divide by conversion/sampling time in ms (SPTP/RCT)
Default is "" (no scaling).
plot_if_possible : bool, optional
If True and data loads successfully, generate a matplotlib plot.
Default is False.
save_if_possible : bool, optional
Reserved for future use. Default is False.
return_type : {'default', 'real', 'imag'}, optional
Component of the signal to return:
- ``'default'`` : return y as-is (real + imaginary if complex)
- ``'real'`` : return only ``np.real(y)``
- ``'imag'`` : return only ``np.imag(y)``
Default is "default".
Returns
-------
x : np.ndarray or list of np.ndarray or None
Abscissa data; list of axes for 2D datasets. None on failure.
y : np.ndarray or None
Ordinate data (signal). None on failure.
pars : dict or None
Parameters extracted from the descriptor/parameter file.
None on failure.
file_path : str or None
Resolved absolute path of the loaded file. None on failure.
Raises
------
FileNotFoundError
If the specified file or directory does not exist.
ValueError
If the file format is unsupported, scaling is invalid, or
parameter inconsistencies are found.
IOError
If reading files fails.
Examples
--------
Load a CW EPR file and inspect its shape:
>>> from epyr import eprload
>>> x, y, params, path = eprload("examples/data/130406SB_CaWO4_Er_CW_5K_20.DSC")
>>> y.shape
(1024,)
>>> params["MWFQ"] # microwave frequency, Hz
9387600000.0
Apply gain + averages scaling and keep only the real part:
>>> x, y, _, _ = eprload("file.DSC", scaling="nG", return_type="real")
Load a 2D dataset (returns a list of axes for x):
>>> x, y, _, _ = eprload("examples/data/Rabi2D_GdCaWO4_13dB_3057G.DSC")
>>> y.shape, len(x)
((500, 1024), 2)
"""
# Initialize outputs
x, y, pars, loaded_file_path = None, None, None, None
# Handle file name input
if file_name is None:
file_name = Path.cwd()
else:
file_name = Path(file_name)
# --- File/Directory Handling ---
if file_name.is_dir():
file_path = _select_file_dialog(file_name)
if file_path is None:
return None, None, None, None
else:
# Use file_name as-is, _determine_file_format will try
# to find it with extensions
file_path = file_name
# --- Determine File Format and Validate ---
try:
file_path, file_format = _determine_file_format(file_path)
_validate_scaling(scaling)
except (ValueError, FileNotFoundError) as e:
logger.error(str(e))
return None, None, None, None
# --- Load Data ---
loaded_file_path = str(file_path.resolve())
# Check memory usage before loading
if not MemoryMonitor.check_memory_limit():
logger.warning("Memory usage high, optimizing before loading")
MemoryMonitor.optimize_memory()
# Check cache first for potentially cached data
cache = get_global_cache()
cache_key = file_path.resolve()
cached_result = cache.get(cache_key)
if cached_result is not None:
logger.debug(f"Using cached data for {file_path.name}")
x, y, pars, _ = cached_result
else:
try:
y, x, pars = _load_data_by_format(file_path, file_format, scaling)
# Cache the result for future use
cache.put(cache_key, (x, y, pars, loaded_file_path))
except (ValueError, IOError, NotImplementedError) as e:
logger.error(f"Failed to load data from {file_path}: {e}")
return None, None, None, None
except Exception as e:
logger.error(
f"An unexpected error occurred loading "
f"{file_path}: {type(e).__name__}: {e}"
)
logger.debug("Full traceback:", exc_info=True)
return None, None, None, None
# --- Apply return_type filter ---
if y is not None and return_type != "default":
if return_type == "real":
y = np.real(y)
elif return_type == "imag":
y = np.imag(y)
else:
raise ValueError(
f"Invalid return_type '{return_type}'. "
"Must be 'default', 'real', or 'imag'."
)
# --- Plotting (Optional) ---
if plot_if_possible and y is not None and x is not None:
_plot_data(x, y, loaded_file_path, pars, save_if_possible=save_if_possible)
# --- Return Results ---
# Always return the full tuple, even if some elements are None (e.g., on error)
return x, y, pars, loaded_file_path
def _plot_data(
x: Union[np.ndarray, List[np.ndarray], None],
y: Optional[np.ndarray],
file_name: str,
params: Optional[Dict[str, Any]] = None,
save_if_possible: bool = False,
) -> None:
"""Helper function to plot the loaded EPR data using Matplotlib."""
if y is None or y.size == 0:
logger.warning("No data available to plot.")
return
fig, ax = plt.subplots()
plot_title = Path(file_name).name
ax.set_title(plot_title, fontsize=10) # Use smaller font for title
# --- 1D Data Plotting ---
if y.ndim == 1:
absc = x
if absc is None or not isinstance(absc, np.ndarray) or absc.shape != y.shape:
warnings.warn(
"Abscissa data (x) missing or incompatible "
"shape. Using index for plotting.",
stacklevel=2,
)
absc = np.arange(y.size)
x_label = "Index"
x_unit = "points"
else:
x_label = params.get("XAXIS_NAME")
x_unit = params.get("XAXIS_UNIT") if params else "a.u."
if isinstance(x_unit, list): # Use first unit if list provided for 1D
x_unit = x_unit[0]
if x_unit:
x_label += f" ({x_unit})"
if np.isrealobj(y):
ax.plot(absc, y, label="data")
else:
ax.plot(absc, np.real(y), label="real")
ax.plot(absc, np.imag(y), label="imag") # , linestyle='--')
ax.legend()
ax.set_xlabel(x_label)
ax.set_ylabel("Intensity (a.u.)")
ax.grid(True, linestyle=":", alpha=0.6)
ax.ticklabel_format(
style="sci", axis="y", scilimits=(-3, 4)
) # Use scientific notation if needed
# --- 2D Data Plotting ---
elif y.ndim == 2:
# y shape is typically (ny, nx) after loading
ny, nx = y.shape
# aspect_ratio could be used for layout: nx / ny if ny > 0 else 1.0
# Determine x and y axes for the plot
x_coords = np.arange(nx) # Default x: index
y_coords = np.arange(ny) # Default y: index
x_label = f"Index ({nx} points)"
y_label = f"Index ({ny} points)"
x_units_list = (
[params.get("XAXIS_UNIT"), params.get("YAXIS_UNIT")] if params else None
)
if isinstance(x, list) and len(x) >= 2:
# Assume x = [x_axis, y_axis, ...]
x_axis, y_axis = x[0], x[1]
if isinstance(x_axis, np.ndarray) and x_axis.size == nx:
x_coords = x_axis
x_unit = params.get("XAXIS_UNIT")
# if isinstance(x_units_list, list)
# and len(x_units_list) > 0 else 'a.u.'
x_label = params.get("XAXIS_NAME") + f"({x_unit})"
if isinstance(y_axis, np.ndarray) and y_axis.size == ny:
y_coords = y_axis
y_unit = (
params.get("YAXIS_UNIT")
if isinstance(x_units_list, list) and len(x_units_list) > 1
else "a.u."
)
y_label = params.get("YAXIS_NAME") + f"({y_unit})"
elif isinstance(x, np.ndarray) and x.size == nx:
# Only x-axis provided for 2D plot
x_coords = x
x_unit = x_units_list if isinstance(x_units_list, str) else "a.u."
x_label = f"X Axis ({x_unit})"
# y-axis remains index
logger.debug("Plotting 2D data (real part) using pcolormesh.")
# Take real part if complex
plot_data = np.real(y)
# Use pcolormesh for potentially non-uniform grids
# Shading='auto' tries to guess best behavior for pixel vs grid centers
im = ax.pcolormesh(
x_coords, y_coords, plot_data, shading="auto", cmap="viridis"
)
fig.colorbar(im, ax=ax, label="Intensity (real part, a.u.)")
ax.set_xlabel(x_label)
ax.set_ylabel(y_label)
ax.set_aspect("auto") # Fallback if ny is 0
ax.autoscale(tight=True) # Fit axes to data
else:
logger.warning(f"Cannot plot data with {y.ndim} dimensions.")
return # Don't show empty plot
# Apply tight_layout only for 1D plots
# (colorbar in 2D causes layout engine conflict)
if y.ndim == 1:
plt.tight_layout()
if save_if_possible:
# Save the figure if requested
logger.debug(f"Saving plot for: {file_name}")
save_path = Path(file_name).with_suffix(".png") # Save as PNG
fig.savefig(save_path, dpi=300)
logger.info(f"Plot saved to {save_path}")
plt.show()
# --- Example Usage ---
if __name__ == "__main__":
logger.info("Running eprload example...")
logger.info("This will open a file dialog to select a Bruker EPR file.")
# Example 1: Load data using file dialog and plot
logger.info(
"\n--- Example 1: Load with dialog, default scaling, plotting enabled ---"
)
x_data, y_data, parameters, file = eprload() # plot_if_possible is True by default
if y_data is not None:
logger.info(f"Successfully loaded: {file}")
logger.info(f"Data shape: {y_data.shape}")
if isinstance(x_data, np.ndarray):
logger.info(f"Abscissa shape: {x_data.shape}")
elif isinstance(x_data, (list, tuple)):
logger.info(f"Abscissa shapes: {[ax.shape for ax in x_data]}")
logger.info(f"Number of parameters loaded: {len(parameters)}")
# logger.debug("Parameters:", parameters) # Uncomment to see all parameters
# Example accessing a parameter:
mw_freq = parameters.get("MWFQ", "N/A") # Get MW frequency if available
logger.info(f"Microwave Frequency (MWFQ): {mw_freq}")
else:
logger.warning("Loading failed or was cancelled.")
# Example 2: Specify a file, apply scaling, suppress plotting
# Replace 'path/to/your/datafile.DTA' with an actual file path
# test_file = Path('path/to/your/datafile.DTA')
# if test_file.exists():
# logger.info("\n--- Example 2: Load specific file, scaling='nG', no plot ---")
# x_scaled, y_scaled, pars_scaled, f_scaled = eprload(
# test_file,
# scaling='nG',
# plot_if_possible=False
# )
# if y_scaled is not None:
# logger.info(f"Successfully loaded and scaled: {f_scaled}")
# logger.info(f"Scaled data shape: {y_scaled.shape}")
# # You can plot manually here if needed:
# # import matplotlib.pyplot as plt
# # plt.figure()
# # plt.plot(x_scaled, y_scaled)
# # plt.title("Manually Plotted Scaled Data")
# # plt.show()
# else:
# logger.warning("Loading/scaling failed for specific file.")
# else:
# logger.info("\nSkipping Example 2: Test file path not found or not set.")
logger.info("\neprload example finished.")