diff --git a/.github/workflows/test-notebooks.yml b/.github/workflows/test-notebooks.yml index e842d3e..59a0101 100644 --- a/.github/workflows/test-notebooks.yml +++ b/.github/workflows/test-notebooks.yml @@ -42,7 +42,7 @@ jobs: done - name: Run notebook tests - run: pytest --nbval-lax --current-env tests/ + run: pytest --nbval-lax --nbval-current-env tests/ - name: Upload executed notebooks on failure if: failure() diff --git a/.gitignore b/.gitignore index db4049f..43aa725 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,8 @@ __pycache__ *.Identifier !requirements.txt .jupyter_cache + +# Ignore log files +tests/**/*.log tests/multiprocess_log.txt .vscode diff --git a/pyAMARES/__init__.py b/pyAMARES/__init__.py index 453b135..f42944a 100644 --- a/pyAMARES/__init__.py +++ b/pyAMARES/__init__.py @@ -1,8 +1,7 @@ __author__ = "Jia Xu, MR Research Facility, University of Iowa" -__version__ = "0.3.33" +__version__ = "0.3.34dev" -# print("Current pyAMARES version is %s" % __version__) -# print("Author: %s" % __author__) +# print(f"Author: {__author__)}" from .fileio import * # noqa: F403 from .kernel import * # noqa: F403 diff --git a/pyAMARES/fileio/readfidall.py b/pyAMARES/fileio/readfidall.py index ae0d7c1..0195682 100644 --- a/pyAMARES/fileio/readfidall.py +++ b/pyAMARES/fileio/readfidall.py @@ -2,12 +2,9 @@ import mat73 import numpy as np +from loguru import logger from scipy import io -from ..libs.logger import get_logger - -logger = get_logger(__name__) - def is_mat_file_v7_3(filename): with open(filename, "rb") as f: @@ -132,10 +129,9 @@ def read_fidall(filename): if len(data.shape) != 1: logger.warning( - "Note pyAMARES.fitAMARES only fits 1D MRS data, however, your data shape is {data.shape}. Is it MRSI or raw MRS data that needs to be coil-combined?" + f"Note pyAMARES.fitAMARES only fits 1D MRS data, however, your data shape is {data.shape}. Is it MRSI or raw MRS data that needs to be coil-combined?" ) - # print("data.shape=", data.shape) - logger.debug("data.shape=%s", data.shape) + logger.debug(f"data.shape={data.shape}") return header, data diff --git a/pyAMARES/fileio/readmat.py b/pyAMARES/fileio/readmat.py index 8d314be..e396d2e 100644 --- a/pyAMARES/fileio/readmat.py +++ b/pyAMARES/fileio/readmat.py @@ -1,12 +1,10 @@ import mat73 import numpy as np +from loguru import logger from scipy import io -from ..libs.logger import get_logger from .readfidall import is_mat_file_v7_3 -logger = get_logger(__name__) - def readmrs(filename): """ @@ -43,28 +41,23 @@ def readmrs(filename): - For MATLAB files, both traditional (.mat) and V7.3 (.mat) files are supported, but the variable must be named ``fid`` or ``data``. """ if filename.endswith("csv"): - # print("Try to load 2-column CSV") logger.debug("Try to load 2-column CSV") data = np.loadtxt(filename, delimiter=",") data = data[:, 0] + 1j * data[:, 1] elif filename.endswith("txt"): - # print("Try to load 2-column ASCII data") logger.debug("Try to load 2-column ASCII data") data = np.loadtxt(filename, delimiter=" ") data = data[:, 0] + 1j * data[:, 1] elif filename.endswith("npy"): - # print("Try to load python NPY file") logger.debug("Try to load python NPY file") data = np.load(filename) elif filename.endswith("mat"): if is_mat_file_v7_3(filename): - # print("Try to load Matlab V7.3 mat file with the var saved as fid or data") logger.debug( "Try to load Matlab V7.3 mat file with the var saved as fid or data" ) matdic = mat73.loadmat(filename) else: - # print("Try to load Matlab mat file with the var saved as fid or data") logger.debug( "Try to load Matlab mat file with the var saved as fid or data" ) @@ -87,6 +80,5 @@ def readmrs(filename): "Note pyAMARES.fitAMARES only fits 1D MRS data, however, your data shape is {data.shape}. Is it MRSI or raw MRS data that needs to be coil-combined?" ) - # print("data.shape=", data.shape) - logger.debug("data.shape=%s", data.shape) + logger.debug(f"data.shape={data.shape}") return data diff --git a/pyAMARES/fileio/readnifti.py b/pyAMARES/fileio/readnifti.py index 48b0ecb..7135db9 100644 --- a/pyAMARES/fileio/readnifti.py +++ b/pyAMARES/fileio/readnifti.py @@ -1,10 +1,7 @@ import argparse import numpy as np - -from ..libs.logger import get_logger - -logger = get_logger(__name__) +from loguru import logger def read_nifti(filename): diff --git a/pyAMARES/kernel/PriorKnowledge.py b/pyAMARES/kernel/PriorKnowledge.py index dc4a4ba..0a3fcca 100644 --- a/pyAMARES/kernel/PriorKnowledge.py +++ b/pyAMARES/kernel/PriorKnowledge.py @@ -5,13 +5,16 @@ import matplotlib.pyplot as plt import numpy as np import pandas as pd + +# Disable automatic string dtype inference for pandas 3.0 and above +if hasattr(pd.options, "future") and hasattr(pd.options.future, "infer_string"): + pd.options.future.infer_string = False + from lmfit import Parameters +from loguru import logger -from ..libs.logger import get_logger from .fid import fft_params -logger = get_logger(__name__) - def safe_convert_to_numeric(x): try: @@ -181,6 +184,9 @@ def unitconverter(df_ini, MHz=120.0): pandas.DataFrame: A DataFrame with converted unit values in specified rows. """ df = deepcopy(df_ini) + df = df.apply( + pd.to_numeric, errors="raise", downcast="float" + ) # By this point the values should only be numeric if "chemicalshift" in df.index: df.loc["chemicalshift", df.notna().loc["chemicalshift"]] *= MHz @@ -189,7 +195,7 @@ def unitconverter(df_ini, MHz=120.0): if "phase" in df.index: df.loc["phase", df.notna().loc["phase"]] = np.deg2rad( - df.loc["phase"][df.notna().loc["phase"]].astype(float) + df.loc["phase"][df.notna().loc["phase"]] ) return df @@ -262,30 +268,27 @@ def assert_peak_format(input_str): if re.search(r"\.\d+$", input_str): logger.error(msg) raise ValueError( - "The peak name %s cannot end with a floating-point number!" % input_str + f"The peak name {input_str} cannot end with a floating-point number!" ) if re.search(r"\d+[\D]", input_str) or re.search(r"^\d+", input_str): logger.error(msg) raise ValueError( - "The peak name %s cannot contain numbers at the beginning or in the middle!" - % input_str + f"The peak name {input_str} cannot contain numbers at the beginning or in the middle!" ) def find_header_row(filename, comment_char="#"): """Determine the index of the first non-commented line.""" with open(filename, "r") as file: - logger.info("Checking comment lines in the prior knowledge file") + logger.debug("Checking comment lines in the prior knowledge file") for i, line in enumerate(file): if "#" in line: - logger.info("Comment: in line %d: %s", i, line) + logger.debug(f"Comment: in line {i}: {line}") with open(filename, "r") as file: for i, line in enumerate(file): processedline = line.replace('"', "").replace("'", "").strip() if not processedline.startswith(comment_char): return i - # else: - # print("Comment:", processedline) return None # Return None if all lines are comments or file is empty @@ -326,41 +329,36 @@ def generateparameter( else: raise NotImplementedError("file format must be Excel (xlsx) or CSV!") - # Compatible with pandas both older and newer than 2.1.0 - pk = ( - pk.map(safe_convert_to_numeric) - if hasattr(pk, "map") - else pk.applymap(safe_convert_to_numeric) - ) # To be compatible with CSV + def backward_compatible_map(df, func): + # Check if the newer 'map' method exists (pandas >= 2.1.0) + if hasattr(df, "map"): + return df.map(func) + # Fallback to the older 'applymap' (pandas < 2.1.0) + elif hasattr(df, "applymap"): + return df.applymap(func) # type: ignore + else: + raise AttributeError( + "Pandas DataFrame has neither 'map' nor 'applymap' method." + ) + pk = backward_compatible_map(pk, safe_convert_to_numeric) peaklist = pk.columns.to_list() # generate a peak list directly from the [assert_peak_format(x) for x in peaklist] dfini = extractini(pk, MHz=MHz) # Parse initial values dfini2 = unitconverter( dfini, MHz=MHz ) # Convert ppm to Hz, convert FWHM to dk, convert degree to radians. - # print(f"{dfini2=}") df_lb, df_ub = parse_bounds(pk) # Parse bounds df_expr = extract_expr(pk, MHz=MHz) # Parse expression df_lb2 = unitconverter(df_lb, MHz=MHz) df_ub2 = unitconverter(df_ub, MHz=MHz) # Make sure the bounds are numeric - # Compatible with pandas both older and newer than 2.1.0 - df_lb2 = ( - df_lb2.map(safe_convert_to_numeric) - if hasattr(df_lb2, "map") - else df_lb2.applymap(safe_convert_to_numeric) - ) - df_ub2 = ( - df_ub2.map(safe_convert_to_numeric) - if hasattr(df_ub2, "map") - else df_ub2.applymap(safe_convert_to_numeric) - ) + df_lb2 = backward_compatible_map(df_lb2, safe_convert_to_numeric) + df_ub2 = backward_compatible_map(df_ub2, safe_convert_to_numeric) if g_global is False: logger.debug( - "Parameter g will be fit with the initial value set in the file %s" % fname + f"Parameter g will be fit with the initial value set in the file {fname}" ) - # print(f"Parameter g will be fit with the initial value set in the file {fname}") allpara = Parameters() for peak in dfini2.columns: for i, para in enumerate(paramter_prefix): @@ -492,16 +490,14 @@ def initialize_FID( deadtime = float(deadtime) dwelltime = 1.0 / sw if truncate_initial_points > 0: - logger.info( - "Truncating %i points from the beginning of the FID signal" - % truncate_initial_points + logger.debug( + f"Truncating {truncate_initial_points} points from the beginning of the FID signal" ) deadtime_old = deadtime * 1.0 deadtime = deadtime + truncate_initial_points * dwelltime fid = fid[truncate_initial_points:] - logger.info( - "The deadtime is changing from %f seconds to %f seconds" - % (deadtime_old, deadtime) + logger.debug( + f"The deadtime is changing from {deadtime} seconds to {deadtime_old} seconds" ) fidpt = len(fid) # TD = fidpt * 2 @@ -509,10 +505,6 @@ def initialize_FID( ppm = np.linspace(-sw / 2, sw / 2, fidpt) / np.abs(MHz) Hz = np.linspace(-sw / 2, sw / 2, fidpt) - # print(f"{sw=}") - # print(f"{np.max(ppm)=} {np.min(ppm)=}") - # print(f"{np.max(Hz)=} {np.min(Hz)=}") - # print(f"{-sw/2=}") opts = argparse.Namespace() opts.deadtime = deadtime @@ -523,7 +515,7 @@ def initialize_FID( # This must be done before the shifting FID for carrier. fid = np.conj(fid) if carrier != 0: - logger.info("Shift FID so that center frequency is at %s ppm!" % carrier) + logger.debug(f"Shift FID so that center frequency is at {carrier} ppm!") fid = fid * np.exp(1j * 2 * np.pi * carrier * MHz * opts.timeaxis) # ppm = ppm + carrier # Hz = Hz + carrier / np.abs(MHz) @@ -584,7 +576,7 @@ def initialize_FID( timeaxis=opts.timeaxis, params=opts.initialParams, fid=True ) if ppm_offset != 0: - logger.info("Shifting the ppm by ppm_offset=%2.2f ppm" % ppm_offset) + logger.debug(f"Shifting the ppm by ppm_offset={ppm_offset:.2f} ppm") for p in opts.initialParams: if p.startswith("freq"): hz_offset = opts.ppm_offset * opts.MHz @@ -597,25 +589,17 @@ def initialize_FID( ): # Check if there's an upper bound set opts.initialParams[p].max += hz_offset logger.debug( - "before opts.initialParams[%s].value=%s" - % (p, opts.initialParams[p].value) + f"before opts.initialParams[{p}].value={opts.initialParams[p].value}" ) logger.debug( - "new value should be opts.initialParams[%s].value + opts.ppm_offset * opts.MHz=%s" - % (p, opts.initialParams[p].value + opts.ppm_offset * opts.MHz) + f"new value should be opts.initialParams[{p}].value + opts.ppm_offset * opts.MHz=" + f"{opts.initialParams[p].value + opts.ppm_offset * opts.MHz}" ) - - # print(f"before {opts.initialParams[p].value=}") - # print( - # f"new value should be {opts.initialParams[p].value + opts.ppm_offset * opts.MHz=}" - # ) opts.initialParams[p].value = ( opts.initialParams[p].value + hz_offset ) - # print(f"after {opts.initialParams[p].value=}") logger.debug( - "after opts.initialParams[%s].value=%s" - % (p, opts.initialParams[p].value) + f"after opts.initialParams[{p}].value={opts.initialParams[p].value}" ) opts.allpara = opts.initialParams # obsolete API, will be removed @@ -635,12 +619,12 @@ def initialize_FID( plt.xlabel("ppm") plt.show() if priorknowledgefile is not None: - logger.info("Printing the Prior Knowledge File %s" % priorknowledgefile) + logger.debug(f"Printing the Prior Knowledge File {priorknowledgefile}") try: from IPython.display import display display(opts.PK_table) # display table except ImportError: - logger.info(opts.PK_table) + logger.debug(opts.PK_table) return opts diff --git a/pyAMARES/kernel/fid.py b/pyAMARES/kernel/fid.py index 538258e..f9dae68 100644 --- a/pyAMARES/kernel/fid.py +++ b/pyAMARES/kernel/fid.py @@ -2,10 +2,7 @@ import matplotlib.pyplot as plt import nmrglue as ng import numpy as np - -from ..libs.logger import get_logger - -logger = get_logger(__name__) +from loguru import logger def interleavefid(fid): @@ -147,10 +144,6 @@ def Jac6(params, x, fid=None): dk = np.array(poptall[2::5]) g = np.array(poptall[4::5]) - # if len(g[g > 1]) > 0: - # print("Warning, g>1", g) - # if len(g[g < 0]): - # print("warning! g<0", g) g[g > 1] = 1.0 g[g < 0] = 0.0 @@ -197,10 +190,6 @@ def Jac6c(params, x, fid=None): dk = np.array(poptall[2::5]) # noqa F841 g = np.array(poptall[4::5]) - # if len(g[g > 1]) > 0: - # print("Warning, g>1", g) - # if len(g[g < 0]): - # print("warning! g<0", g) g[g > 1] = 1.0 g[g < 0] = 0.0 @@ -324,9 +313,9 @@ def Compare_to_OXSA(inputfid, resultfid): dataNormSq = np.linalg.norm(inputfid - np.mean(inputfid)) ** 2 resNormSq = np.sum(np.abs((resultfid - inputfid)) ** 2) relativeNorm = resNormSq / dataNormSq - logger.info("Norm of residual = %3.3f" % resNormSq) - logger.info("Norm of the data = %3.3f" % dataNormSq) - logger.info("resNormSq / dataNormSq = %3.3f" % relativeNorm) + logger.debug(f"Norm of residual = {resNormSq:.3f}") + logger.debug(f"Norm of the data = {dataNormSq:.3f}") + logger.debug(f"resNormSq / dataNormSq = {relativeNorm:.3f}") return resNormSq, relativeNorm @@ -422,9 +411,7 @@ def simulate_fid( timeaxis = np.arange(0, dwelltime * fid_len, dwelltime) + deadtime # timeaxis fidsim = uninterleave(multieq6(x=timeaxis, params=params)) if extra_line_broadening > 0: - logger.info( - "Applying extra line broadening of %2.2f Hz" % extra_line_broadening - ) + logger.info(f"Applying extra line broadening of {extra_line_broadening:.2f} Hz") fidsim = ng.proc_base.em(fidsim, extra_line_broadening / sw) if snr_target is not None: fidsim = add_noise_FID(fidsim, snr_target, indsignal, pts_noise) @@ -434,10 +421,8 @@ def simulate_fid( label = "Pure FID" plt.title("Simulated FID") else: - label = "SNR=%2.2f" % fidSNR( - fid=fidsim, indsignal=indsignal, pts_noise=pts_noise - ) - plt.title("Simulated FID with an SNR of %2.2f" % snr_target) + label = f"SNR={fidSNR(fid=fidsim, indsignal=indsignal, pts_noise=pts_noise):.2f}" + plt.title(f"Simulated FID with an SNR of {snr_target:.2f}") plt.plot(Hz, np.real(ng.proc_base.fft(fidsim)), label=label) plt.legend() plt.xlabel("Hz") diff --git a/pyAMARES/kernel/lmfit.py b/pyAMARES/kernel/lmfit.py index c2b6601..13c6b83 100644 --- a/pyAMARES/kernel/lmfit.py +++ b/pyAMARES/kernel/lmfit.py @@ -4,13 +4,11 @@ import numpy as np import pandas as pd from lmfit import Minimizer, Parameters +from loguru import logger -from ..libs.logger import get_logger from .fid import Compare_to_OXSA, fft_params from .objective_func import default_objective -logger = get_logger(__name__) - def check_removed_expr(df): """ @@ -31,7 +29,7 @@ def check_removed_expr(df): Raises: UserWarning: If an 'expr' is found to be dependent on a removed parameter. """ - logger.info( + logger.debug( "Check if the expr for all parameters is restricted to a parameter that has already been filtered out." ) result_df = df.copy() @@ -71,8 +69,7 @@ def filter_param_by_ppm(allpara, fit_ppm, MHz, delta=100): DataFrame: DataFrame filtered based on the specified criteria. """ fit_Hz = np.array(fit_ppm) * MHz - # print(f"{fit_Hz=}") - logger.info("fit_Hz=%s" % fit_Hz) + logger.debug(f"fit_Hz={fit_Hz}") tofilter_pd = parameters_to_dataframe(allpara) chemshift_pd = tofilter_pd[tofilter_pd["name"].str.startswith("freq")] @@ -204,7 +201,6 @@ def result_pd_to_params(result_table, MHz=120.0): params = Parameters() for row in result_table.iterrows(): - # print(row[0]) for name in df_name: value = row[1][name] new_name = name_dic[name] + "_" + row[0] @@ -270,7 +266,7 @@ def save_parameter_to_csv(params, filename="params.csv"): This function converts the ``params`` object to a DataFrame before saving it as a CSV file. """ df = parameters_to_dataframe(params) - logger.info(f"Saving parameter file to {filename}") + logger.debug(f"Saving parameter file to {filename}") df.to_csv(filename) @@ -353,10 +349,8 @@ def fitAMARES_kernel( from ..util import get_ppm_limit fit_range = get_ppm_limit(fid_parameters.ppm, fit_range) - # print(f"Fitting range {fid_parameters.ppm[fit_range[0]]} ppm to {fid_parameters.ppm[fit_range[1]]} ppm!") logger.debug( - "Fitting range %s ppm to %s ppm!" - % (fid_parameters.ppm[fit_range[0]], fid_parameters.ppm[fit_range[1]]) + f"Fitting range {fid_parameters.ppm[fit_range[0]]} ppm to {fid_parameters.ppm[fit_range[1]]} ppm!" ) min_obj = Minimizer( objective_func, @@ -373,10 +367,8 @@ def fitAMARES_kernel( else: out_obj = min_obj.minimize(method=method) timeafter = datetime.now() - # print(f"Fitting with {method=} took {(timeafter - timebefore).total_seconds()} seconds") logger.debug( - "Fitting with method=%s took %s seconds" - % (method, (timeafter - timebefore).total_seconds()) + f"Fitting with method={method} took {(timeafter - timebefore).total_seconds()} seconds" ) return out_obj @@ -431,8 +423,7 @@ def fitAMARES( from copy import deepcopy logger.debug( - "A copy of the input fid_parameters will be returned because inplace=%s" - % inplace + f"A copy of the input fid_parameters will be returned because inplace={inplace}" ) fid_parameters = deepcopy(fid_parameters) fitting_parameters = deepcopy(fitting_parameters) @@ -442,7 +433,7 @@ def fitAMARES( tol = np.sqrt(amp0) * 1e-6 fit_kws = {"max_nfev": 1000, "xtol": tol, "ftol": tol} # fit_kws = {'max_nfev':1000, 'xtol':tol, 'ftol':tol, 'gtol':tol} - logger.debug("Autogenerated tol is %3.3e" % tol) + logger.debug(f"Autogenerated tolerance is {tol:.3e}") if not initialize_with_lm: # The old API, without an initializer out_obj = fitAMARES_kernel( @@ -455,8 +446,7 @@ def fitAMARES( ) # fitting kernel else: logger.debug( - "Run internal leastsq initializer to optimize fitting parameters for the next %s fitting" - % method + f"Run internal leastsq initializer to optimize fitting parameters for the next {method} fitting" ) params_LM = fitAMARES_kernel( fid_parameters, @@ -543,7 +533,6 @@ def plotAMARES(fid_parameters, fitted_params=None, plotParameters=None, filename ).T if plotParameters is None: plotParameters = fid_parameters.plotParameters - # print(f"{plotParameters.xlim=}") combined_plot( amares_arr, ppm=fid_parameters.ppm, @@ -577,4 +566,4 @@ def print_lmfit_fitting_results(result): msg_string = "\n ".join(msg) - logger.info(msg_string) + logger.debug(msg_string) diff --git a/pyAMARES/libs/MPFIR.py b/pyAMARES/libs/MPFIR.py index fd24871..a61f549 100644 --- a/pyAMARES/libs/MPFIR.py +++ b/pyAMARES/libs/MPFIR.py @@ -87,7 +87,6 @@ def minphlpnew(h0): def pbfirnew(wl, wh, signal, ri, M0): N = np.max(signal.shape) wc = (wh - wl) / 2 - # print(f"{wc=} {wh=} {wl=}") noise = np.std(np.real(signal[-20:])) maxs = np.max(np.abs(np.fft.fft(signal))) / np.sqrt(N) @@ -102,7 +101,6 @@ def pbfirnew(wl, wh, signal, ri, M0): supold = sup # Initialize here while ok == 1: - # print(f'try M={M} wc={wc} ri={ri} sup={sup}') fir_h = fircls1(M, wc, ri, sup) fir_h = minphlpnew(fir_h) M2 = len(fir_h) @@ -113,7 +111,6 @@ def pbfirnew(wl, wh, signal, ri, M0): phas = np.pi + np.arctan(np.imag(phastemp) / np.real(phastemp)) else: phas = np.arctan(np.imag(phastemp) / np.real(phastemp)) - # print(f"{phas=}") fir_h = fir_h * np.exp(-1j * phas) # f = filter(fir_h, 1, signal[::-1]) f = lfilter(fir_h, [1], signal[::-1]) # needs to check @@ -208,7 +205,6 @@ def MPFIR( xmax = (max(ppm_range) - carrier) * frequency / 1e6 # in kHz wl = xmin * 2 * step wh = xmax * 2 * step - # print(f"{wl=} {wh=}") fir_h = pbfirnew(wl, wh, signal, rippass, M) signal = lfilter(np.flip(fir_h), 1, signal) signal = np.concatenate([signal[len(fir_h) - 1 :], np.zeros(len(fir_h) - 1)]) @@ -222,8 +218,7 @@ def MPFIR( ppm_range[1], color="gray", alpha=0.1, - label="selected region\n%i to %i ppm" - % (np.min(ppm_range), np.max(ppm_range)), + label=f"selected region\n{np.min(ppm_range)} to {np.max(ppm_range)} ppm", ) plt.legend() plt.xlabel("ppm") diff --git a/pyAMARES/libs/logger.py b/pyAMARES/libs/logger.py deleted file mode 100644 index 9e35eb5..0000000 --- a/pyAMARES/libs/logger.py +++ /dev/null @@ -1,128 +0,0 @@ -import logging -import sys - -LOG_MODES = { - "debug": logging.DEBUG, - "info": logging.INFO, - "warning": logging.WARNING, - "error": logging.ERROR, - "critical": logging.CRITICAL, -} - -# GLOBAL VARIABLE: LOG_STYLE -# The output mode for the log messages. Options: -# - "plain": Uses `logging.Formatter` and logs to stdout. -# Always displays plain text messages in all environments. -# - "stderr": Uses `logging.Formatter` and logs to stderr. -# This is the normal behaviour of the python logging module, but -# produces red output in Jupyter notebooks. -# -# Accessed by get_logger: -LOG_STYLE = "plain" - -# GLOBAL VARIABLE: DEFAULT_LOG_LEVEL -# The default log level for all loggers. Options: -# - "debug", "info", "warning", "error", "critical" -# -# Accessed by get_logger and set_log_level: -DEFAULT_LOG_LEVEL = "info" - - -def get_logger( - name: str, format_string: str = "[AMARES | {levelname}] {message}" -) -> logging.Logger: - """ - Get or create a logger with the specified name and format. - - If the logger has no existing handlers, it initializes one based on the provided log style. - The log level defaults to `DEFAULT_LOG_LEVEL`. To change it globally, use `set_log_level()`. - - Parameters - ---------- - name : str - The name of the logger. - format_string : str, optional - The format string for log messages (default: "[AMARES | {levelname}] {message}"). - - Returns - ------- - logging.Logger - A configured logger instance. - - Raises - ------ - ValueError - If an invalid `LOG_STYLE` is provided. - - Examples - -------- - >>> logger = get_logger("example_logger") - >>> logger.debug("This is a debug message.") - [DEBUG] example_logger: This is a debug message. - """ - global LOG_STYLE - - logger = logging.getLogger(name) - logger.setLevel(LOG_MODES.get(DEFAULT_LOG_LEVEL, logging.ERROR)) - - if not logger.hasHandlers(): - if LOG_STYLE == "plain": - formatter = logging.Formatter(format_string, style="{") - handler = logging.StreamHandler(sys.stdout) - elif LOG_STYLE == "stderr": - formatter = logging.Formatter(format_string, style="{") - handler = logging.StreamHandler() - else: - raise ValueError( - f"Invalid mode: '{LOG_STYLE}'. Choose from 'plain' or 'stderr'." - ) - - handler.setFormatter(formatter) - logger.addHandler(handler) - - return logger - - -def set_log_level(level: str = DEFAULT_LOG_LEVEL, verbose: bool = True): - """ - Set the global logging level for all loggers. - - This function sets the logging level across the entire application using - the basicConfig method of the logging module. It also ensures that all - existing loggers adhere to this global level. - - Parameters - ---------- - level : str - The logging level to set globally. Acceptable values are 'critical', - 'error', 'warning', 'info', 'debug', and 'notset'. Default is the - `DEFAULT_LOG_LEVEL`. - - verbose : bool - Print an overview of the log levels and highlight the selected level. - - Examples - -------- - >>> set_log_level('info') - >>> logger = logging.getLogger('example_logger') - >>> logger.debug('This debug message will not show.') - >>> logger.info('This info message will show.') - [INFO] example_logger: This info message will show. - """ - if level.lower() not in LOG_MODES.keys(): - raise AssertionError( - f"'{level}' is not a valid logging level. " - + f"Choose from {list(LOG_MODES.keys())}." - ) - - numeric_level = LOG_MODES.get(level.lower(), logging.ERROR) - - if verbose: - for lname in LOG_MODES.keys(): - arrow = "-->" if lname == level.lower() else " " - print(f"{arrow} {lname.upper():<10}") - - for logger_name in logging.root.manager.loggerDict: - if logger_name.startswith("pyAMARES"): - logger = logging.getLogger(logger_name) - logger.setLevel(numeric_level) diff --git a/pyAMARES/util/crlb.py b/pyAMARES/util/crlb.py index 552f3b4..6870cd6 100644 --- a/pyAMARES/util/crlb.py +++ b/pyAMARES/util/crlb.py @@ -4,14 +4,12 @@ import numpy as np import scipy import sympy +from loguru import logger from sympy.parsing import sympy_parser from ..kernel import Jac6, multieq6, uninterleave -from ..libs.logger import get_logger from .report import report_crlb -logger = get_logger(__name__) - def calculateCRB(D, variance, P=None, verbose=False, condthreshold=1e11, cond=False): """ @@ -39,10 +37,8 @@ def calculateCRB(D, variance, P=None, verbose=False, condthreshold=1e11, cond=Fa D = uninterleave(D) Dmat = np.dot(D.conj().T, D) if verbose: - # print("D.shape", D.shape, "Dmat.shape", Dmat.shape) - logger.debug("D.shape=%s Dmat.shape=%s" % (D.shape, Dmat.shape)) - # print("P.shape=%s" % str(P.shape)) - logger.debug("P.shape=%s" % str(P.shape)) + logger.debug(f"D.shape={D.shape} Dmat.shape={Dmat.shape}") + logger.debug(f"P.shape={str(P.shape)}") # Compute the Fisher information matrix if P is None: # No prior knowledge @@ -51,12 +47,9 @@ def calculateCRB(D, variance, P=None, verbose=False, condthreshold=1e11, cond=Fa else: Fisher = np.real(P.T @ Dmat @ P) / variance if verbose: - # print("Fisher.shape=%s P.shape=%s" % (Fisher.shape, P.shape)) - logger.debug("Fisher.shape=%s P.shape=%s" % (Fisher.shape, P.shape)) + logger.debug(f"Fisher.shape={Fisher.shape} P.shape={P.shape}") condition_number = np.linalg.cond(Fisher) if condition_number > condthreshold: - # print("Warning: The matrix may be ill-conditioned. Condition number is high:" - # , condition_number) logger.warning( f"The matrix may be ill-conditioned. Condition number is high: " f"{condition_number:3.3e}" @@ -70,13 +63,10 @@ def calculateCRB(D, variance, P=None, verbose=False, condthreshold=1e11, cond=Fa # Ensure non-negative covariance values if np.min(CRBcov) < 0: if verbose: - # print("np.min(CRBcov)=%s, make the negative values to 0!" % # np.min(CRBcov)) logger.warning( - "np.min(CRBcov)=%s, make the negative values to 0!" % np.min(CRBcov) + f"np.min(CRBcov)={np.min(CRBcov)}, make the negative values to 0!" ) - # warnings.warn("np.min(CRBcov)=%s, make the negative values to 0!" % - # np.min(CRBcov), UserWarning) CRBcov[CRBcov < 0] = 0.0 if cond: @@ -85,7 +75,6 @@ def calculateCRB(D, variance, P=None, verbose=False, condthreshold=1e11, cond=Fa else: return False if np.max(np.diag(CRBcov)) < 1e-5: - # print("Ill conditioned matrix! CRLB not reliable!") logger.warning("Ill conditioned matrix! CRLB not reliable!") if verbose: msg = ["\n Debug Information:"] @@ -96,10 +85,6 @@ def calculateCRB(D, variance, P=None, verbose=False, condthreshold=1e11, cond=Fa msg.append(f"Max mDTD: {np.max(Dmat):.2e}") msg_string = "\n ".join(msg) logger.debug(msg_string) - # print("CRBcov.shape", CRBcov.shape) - # print("max CRBcov", np.max(np.diag(CRBcov))) - # print("max Fisher %2.2e" % np.max(Fisher)) - # print("max mDTD %2.2e" % np.max(Dmat)) return np.sqrt(np.diag(CRBcov)) @@ -148,18 +133,15 @@ def evaluateCRB(outparams, opts, P=None, Jacfunc=Jac6, verbose=False): try: opts.variance = float(opts.noise_var) logger.debug( - "The CRLB estimation will be divided by the input variance %s" - % opts.variance + f"The CRLB estimation will be divided by the input variance {opts.variance}" ) except ValueError: logger.error( - "Error: noise_var %s is not a recognized string or a valid number." - % opts.variance + f"Error: noise_var {opts.variance} is not a recognized string or a valid number." ) if verbose: - # print("opts.D.shape=%s" % str(opts.D.shape)) - logger.debug("opts.D.shape=%s" % str(opts.D.shape)) + logger.debug(f"opts.D.shape={str(opts.D.shape)}") plt.plot(opts.residual.real) plt.title("residual") plt.show() @@ -205,8 +187,6 @@ def create_pmatrix(pkpd, verbose=False, ifplot=False): """ # Extract parameter indices and expressions for Equation 3 in the # Reference. S Cavassila et al NMR Biomed. 2001 Jun;14(4):278-83 - # print(f"{[extract_strings(x) for x in pkpd.dropna(axis=0)['expr']]=}") - # print(f"{pkpd.columns=} {pkpd.index=} {pkpd['name']=}") pm_index = get_matches( pkpd, [extract_strings(x) for x in pkpd.dropna(axis=0)["expr"]] ) @@ -227,7 +207,6 @@ def create_pmatrix(pkpd, verbose=False, ifplot=False): ] # pass freepd ID to all ID. ID will be NaNs for fixed variables pm_index2 = pkpd2.iloc[pm_index]["newid"].to_list() if np.all(np.isnan(pm_index2)): # If all NaN - # print(f"{pm_index2=}") logger.warning( "pm_index are all NaNs, return None so that P matrix is a identity matrix!" ) @@ -238,15 +217,13 @@ def create_pmatrix(pkpd, verbose=False, ifplot=False): # Fill the diagonal for free parameters for ind in freepd.index: if verbose: - # print("ind=%s newid=%s" % (ind, freepd.loc[ind]["newid"])) - logger.debug("ind=%s newid=%s" % (ind, freepd.loc[ind]["newid"])) + logger.debug(f"ind={ind} newid={freepd.loc[ind]['newid']}") Pmatrix[freepd.loc[ind]["newid"], ind] = 1.0 # Fill in partial derivatives for parameter relationships for x, y, partial_d in zip(pl_index, pm_index, plm): if verbose: - # print("x=%s y=%s partial_d=%s" % (x, y, partial_d)) - logger.debug("x=%s y=%s partial_d=%s" % (x, y, partial_d)) + logger.debug(f"x={x} y={y} partial_d={partial_d}") Pmatrix[y, x] = partial_d if ifplot: diff --git a/pyAMARES/util/hsvd.py b/pyAMARES/util/hsvd.py index c2cc4b6..f3577de 100644 --- a/pyAMARES/util/hsvd.py +++ b/pyAMARES/util/hsvd.py @@ -19,12 +19,11 @@ # 2025-03-20 from ..libs import hlsvd +from loguru import logger + from ..kernel.fid import Compare_to_OXSA, equation6, interleavefid, uninterleave from ..kernel.lmfit import parameters_to_dataframe from ..libs.hlsvd import create_hlsvd_fids -from ..libs.logger import get_logger - -logger = get_logger(__name__) def HSVDp0(hsvdfid, timeaxis, ppm, MHz=120, ifplot=True): @@ -161,14 +160,8 @@ def hsvd_initialize_parameters(temp_to_unfold, allpara_hsvd=None, g_global=0.0): var_name ].vary: # v0.23c, HSVDinitializer only changes varying parameters if var_name.startswith("ak") and var < 0: - # print( - # "Warning ak for %s %s is negative!, Make it positive - # and flip the phase!" - # % (peak_name, var) - # ) logger.warning( - "ak for %s %s is negative!, Make it positive and flip the " - "phase!" % (peak_name, var) + f"ak for {peak_name} {var} is negative! Making it positive and flipping the phase!" ) allpara_hsvd[var_name].set(value=np.abs(var)) # Flip the phase @@ -194,23 +187,36 @@ def uniquify_dataframe(df): pandas.DataFrame: A DataFrame where for each unique ``name``, only the entry with the maximum absolute ``ak`` value retains its name, and others have their ``name`` set to NaN. """ - - def process_group(group): - if len(group) > 1: - # Find the index of the row with the max absolute 'ak' value - max_ak_idx = group["ak"].abs().idxmax() - # Set 'name' to NaN for all other rows - group.loc[group.index != max_ak_idx, "name"] = np.nan - return group - - df_non_nan = ( - df[df["name"].notna()].groupby("name", group_keys=False).apply(process_group) - ) - - df_nan = df[df["name"].isna()] - result_df = pd.concat([df_non_nan, df_nan]).sort_index() - - return result_df + pd_version = tuple(int(x) for x in pd.__version__.split(".")[:2]) + + if pd_version >= (3, 0): + # pandas 3.0+: grouping column excluded from apply, use index-based approach + df = df.copy() + non_nan_mask = df["name"].notna() + idx_to_keep = ( + df.loc[non_nan_mask] + .groupby("name")["ak"] + .apply(lambda x: x.abs().idxmax()) + .values + ) + df.loc[non_nan_mask & ~df.index.isin(idx_to_keep), "name"] = np.nan + return df + else: + # pandas 2.x: original approach with copy fix + def process_group(group): + if len(group) > 1: + group = group.copy() + max_ak_idx = group["ak"].abs().idxmax() + group.loc[group.index != max_ak_idx, "name"] = np.nan + return group + + df_non_nan = ( + df[df["name"].notna()] + .groupby("name", group_keys=False) + .apply(process_group) + ) + df_nan = df[df["name"].isna()] + return pd.concat([df_non_nan, df_nan]).sort_index() def HSVDinitializer( @@ -262,14 +268,12 @@ def HSVDinitializer( ) plist.append(p2) if verbose: - # print("fitted p0", p2) - logger.debug("fitted p0 %s" % p2) + logger.debug(f"fitted p0 {p2}") p_pd = pd.DataFrame(np.array(plist)) p_pd.columns = ["ak", "freq", "dk", "phi", "g"] if verbose: - # print("Filtering peaks with linewidth broader than %i Hz" % lw_threshold) - logger.debug("Filtering peaks with linewidth broader than %i Hz" % lw_threshold) + logger.debug(f"Filtering peaks with linewidth broader than {lw_threshold} Hz") p_pd = p_pd[p_pd["dk"] < lw_threshold] # filter out too broadened peaks p_pd["g"] = ( fid_parameters.g_global @@ -279,7 +283,9 @@ def HSVDinitializer( if fitting_parameters is None: # Initialize parameters when there is no prior knowledge temp_to_unfold = assign_hsvd_peaks(p_pd, None) + print("temp_to_unfold before uniquify:", temp_to_unfold) temp_to_unfold = uniquify_dataframe(temp_to_unfold) + print("temp_to_unfold after uniquify:", temp_to_unfold) allpara_hsvd = hsvd_initialize_parameters( temp_to_unfold.dropna(subset=["name"]), None, diff --git a/pyAMARES/util/multiprocessing.py b/pyAMARES/util/multiprocessing.py index 75b856d..34a661c 100644 --- a/pyAMARES/util/multiprocessing.py +++ b/pyAMARES/util/multiprocessing.py @@ -1,31 +1,11 @@ -import contextlib -import sys from concurrent.futures import ProcessPoolExecutor from copy import deepcopy from datetime import datetime -from ..kernel.lmfit import fitAMARES -from ..libs.logger import get_logger - -logger = get_logger(__name__) - - -@contextlib.contextmanager -def redirect_stdout_to_file(filename): - """ - A context manager that redirects stdout and stderr to a specified file. +import pandas as pd +from loguru import logger - This function temporarily redirects the standard output (stdout) and - standard error (stderr) streams to a file, capturing all outputs generated - within the context block. - """ - with open(filename, "w") as f: - old_stdout, old_stderr = sys.stdout, sys.stderr - sys.stdout, sys.stderr = f, f - try: - yield - finally: - sys.stdout, sys.stderr = old_stdout, old_stderr +from ..kernel.lmfit import fitAMARES def fit_dataset( @@ -89,8 +69,7 @@ def fit_dataset( del out return result_table except Exception as e: - # print(f"Error in fit_dataset: {e}") - logger.critical("Error in fit_dataset: %s", e) + logger.critical(f"Error in fit_dataset: {e}") return None @@ -101,7 +80,8 @@ def run_parallel_fitting_with_progress( method="leastsq", initialize_with_lm=False, num_workers=8, - logfilename="multiprocess_log.txt", + logfilename="logs/parellelfitting.log", + loglevel=31, objective_func=None, notebook=True, ): @@ -123,7 +103,8 @@ def run_parallel_fitting_with_progress( initialize_with_lm (bool, optional, default False, new in 0.3.9): If True, a Levenberg-Marquardt initializer (``least_sq``) is executed internally. See ``pyAMARES.lmfit.fitAMARES`` for details. num_workers (int, optional): The number of worker processes to use in parallel processing. Defaults to 8. - logfilename (str, optional): The name of the file where the progress log is saved. Defaults to 'multiprocess_log.txt'. + logfilename (str, optional): The name of the file where the progress log is saved. Defaults to 'logs/parellelfitting.log'. + loglevel (int, optional): The logging level for the logger. Defaults to 31 - just above warning. objective_func (callable, optional): Custom objective function for ``pyAMARES.lmfit.fitAMARES``. If None, the default objective function will be used. Defaults to None. notebook (bool, optional): If True, uses tqdm.notebook for progress display in Jupyter notebooks. @@ -141,43 +122,73 @@ def run_parallel_fitting_with_progress( try: del FIDobj_shared.styled_df except AttributeError: - # print("There is no styled_df!") logger.warning("There is no styled_df!") try: del FIDobj_shared.simple_df except AttributeError: - # print("There is no styled_df!") logger.warning("There is no simple_df!") timebefore = datetime.now() results = [] - with redirect_stdout_to_file(logfilename): - with ProcessPoolExecutor(max_workers=num_workers) as executor: - futures = [ - executor.submit( - fit_dataset, - fid_current=fid_arrs[i, :], - FIDobj_shared=FIDobj_shared, - initial_params=initial_params, - method=method, - initialize_with_lm=initialize_with_lm, - objective_func=objective_func, - ) - for i in range(fid_arrs.shape[0]) - ] - - for future in tqdm(futures, total=len(futures), desc="Processing Datasets"): - results.append(future.result()) + loggerID = logger.add(logfilename, level=loglevel, rotation="10 min") + try: + logger.level("BATCH_INFO", no=loglevel) + except ValueError: + # This means the logger level "BATCH_INFO" was already added + pass + + data = [ + { + "name": name, + "value": float(par.value), + "min": par.min, + "max": par.max, + "vary": par.vary, + "expr": par.expr, + "brute_step": par.brute_step, + "stderr": par.stderr, + } + for name, par in initial_params.items() + ] + + df = pd.DataFrame(data) + df.set_index("name", inplace=True) + df.sort_values(by="name", inplace=True) + logger.log( + "BATCH_INFO", f"Initial Paramerters used for batch fitting:\n{df.to_string()}" + ) + + logger.log( + "BATCH_INFO", + f"Starting fitting of {len(fid_arrs)} datasets with parallel processing. Number of workers: {num_workers}", + ) + with ProcessPoolExecutor(max_workers=num_workers) as executor: + futures = [ + executor.submit( + fit_dataset, + fid_current=fid_arrs[i, :], + FIDobj_shared=FIDobj_shared, + initial_params=initial_params, + method=method, + initialize_with_lm=initialize_with_lm, + objective_func=objective_func, + ) + for i in range(fid_arrs.shape[0]) + ] + + for future in tqdm(futures, total=len(futures), desc="Processing Datasets"): + results.append(future.result()) + + logger.log( + "BATCH_INFO", + "Fitting completed. If no errors were logged, all fits were successful.", + ) timeafter = datetime.now() - # print( - # "Fitting %i spectra with %i processors took %i seconds" - # % (len(fid_arrs), num_workers, (timeafter - timebefore).total_seconds()) - # ) - logger.info( - "Fitting %i spectra with %i processors took %i seconds", - len(fid_arrs), - num_workers, - (timeafter - timebefore).total_seconds(), + logger.log( + "BATCH_INFO", + f"Fitting {len(fid_arrs)} spectra with {num_workers} processors took {(timeafter - timebefore).total_seconds()} seconds", ) + + logger.remove(loggerID) return results diff --git a/pyAMARES/util/report.py b/pyAMARES/util/report.py index 670303b..0e34179 100644 --- a/pyAMARES/util/report.py +++ b/pyAMARES/util/report.py @@ -1,5 +1,6 @@ import numpy as np import pandas as pd +from loguru import logger from ..kernel import ( Jac6, @@ -8,9 +9,6 @@ parameters_to_dataframe_result, remove_zero_padding, ) -from ..libs.logger import get_logger - -logger = get_logger(__name__) try: import jinja2 # noqa F401 @@ -65,7 +63,6 @@ def report_crlb(outparams, crlb, Jacfunc=None): pandas.DataFrame: DataFrame with CRLB information for relevant parameters. """ pdpoptall = parameters_to_dataframe_result(outparams) - # print(f"{Jacfunc=}, {pdpoptall=} {outparams=}") if Jacfunc is None or Jacfunc is Jac6: # You'll need to assert there is a peaklist in the fid_parameters poptall = pdpoptall["value"] @@ -74,8 +71,6 @@ def report_crlb(outparams, crlb, Jacfunc=None): # elif Jacfunc is flexible_Jac: # poptall = pdpoptall[pdpoptall['vary']]['value'] # Parameters with vary=True else: - # print(f"{Jacfunc=} is not supported!") - # print("Jacfunc=%s is not supported!" % Jacfunc) logger.warning(f"Jacfunc={Jacfunc} is not supported!") resultpd = pdpoptall.loc[poptall.index] @@ -216,9 +211,8 @@ def report_amares(outparams, fid_parameters, verbose=False): negative_amplitude = result["amplitude"] < 0 if negative_amplitude.sum() > 0: logger.warning( - "The amplitude of index %s is negative!" - " Make it positive and flip the phase!", - result.loc[negative_amplitude].index.values, + f"The amplitude of index {result.loc[negative_amplitude].index.values} is negative! " + f"Make it positive and flip the phase!" ) result.loc[negative_amplitude, "amplitude"] = result.loc[ negative_amplitude, "amplitude" @@ -237,7 +231,7 @@ def report_amares(outparams, fid_parameters, verbose=False): val_columns = ["amplitude", "chem shift", "lw", "phase", "g_value"] for col, crlb_col, val_col in zip(sd_columns, crlb_columns, val_columns): if result[col].isnull().all(): - logger.info("%s is all None, use crlb instead!" % col) + logger.info(f"{col} is all None, use crlb instead!") result[col] = result[crlb_col] / 100 * result[val_col] result["chem shift"] = result["chem shift"] / MHz @@ -261,8 +255,8 @@ def report_amares(outparams, fid_parameters, verbose=False): result.loc[result["g_CRLB(%)"] == 0.0, "g_CRLB(%)"] = np.nan zero_ind = remove_zero_padding(fid_parameters.fid) if zero_ind > 0: - logger.info("It seems that zeros are padded after %i" % zero_ind) - logger.info("Remove padded zeros from residual estimation!") + logger.debug(f"It seems that zeros are padded after {zero_ind}") + logger.debug("Remove padded zeros from residual estimation!") fid_parameters.fid_padding_removed = fid_parameters.fid[:zero_ind] std_noise = np.std( fid_parameters.fid_padding_removed[ @@ -299,7 +293,7 @@ def report_amares(outparams, fid_parameters, verbose=False): ) # reorder to the peaklist from the pk, not the local peaklist # fid_parameters.peaklist = peaklist else: - logger.info("No peaklist, probably it is from an HSVD initialized object") + logger.debug("No peaklist, probably it is from an HSVD initialized object") fid_parameters.result_multiplets = result # Keep the multiplets # Sum multiplets if needed if contains_non_numeric_strings(result): # assigned peaks in the index @@ -328,7 +322,6 @@ def report_amares(outparams, fid_parameters, verbose=False): ) else: simple_df = None - # print("There is no result_sum generated, simple_df is set to None") logger.warning( "There is no result_sum generated, simple_df is set to None" ) @@ -340,14 +333,13 @@ def report_amares(outparams, fid_parameters, verbose=False): simple_df = extract_key_parameters(fid_parameters.result_sum) else: simple_df = None - # print("There is no result_sum generated, simple_df is set to None") logger.warning( "There is no result_sum generated, simple_df is set to None" ) if hasattr(fid_parameters, "result_sum"): fid_parameters.metabolites = fid_parameters.result_sum.index.to_list() else: - logger.info("There is no result_sum generated, probably there is only 1 peak") + logger.debug("There is no result_sum generated, probably there is only 1 peak") fid_parameters.styled_df = styled_df fid_parameters.simple_df = simple_df return styled_df diff --git a/pyAMARES/util/visualization.py b/pyAMARES/util/visualization.py index 8fc7973..001a88b 100644 --- a/pyAMARES/util/visualization.py +++ b/pyAMARES/util/visualization.py @@ -171,7 +171,6 @@ def combined_plot( filename (str or None, optional): If provided, the figure will be saved to this file. Defaults to None. """ - # print(f"{xlim=}") fig, (ax1, ax2) = plt.subplots( 2, 1, figsize=(10, 8), sharex=True, layout="constrained" ) diff --git a/pytest.ini b/pytest.ini index f7fb197..e481281 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ [pytest] -addopts = --nbval-lax +addopts = --nbval-lax --nbval-current-env testpaths = tests diff --git a/requirements.txt b/requirements.txt index 266cac8..c27223c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ lmfit mat73 tqdm notebook +loguru # hlsvdpro==2.0.0 #arm64 does not have it \ No newline at end of file diff --git a/setup.py b/setup.py index e4c9754..850b597 100644 --- a/setup.py +++ b/setup.py @@ -87,6 +87,7 @@ def run(self): "sympy", "nmrglue", "xlrd", + "loguru", "jinja2", "tqdm", "mat73",