From c3e45b43402953baeded520ac4eef96248e9cfe2 Mon Sep 17 00:00:00 2001 From: Bruno Carlos <6951456+brnovasco@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:44:47 -0300 Subject: [PATCH 1/7] feat: New class for neaspec reader of a txt dataset containing multiple channels Reader needs to resample the data to the same domain to compensate M position deviations and comply with the orange-spectroscopy data model. --- .../Test_Au_Fourier_Scan_Synchrotron.txt | 61 ++++ orangecontrib/spectroscopy/io/__init__.py | 2 +- orangecontrib/spectroscopy/io/neaspec.py | 300 +++++++++++++++++- .../spectroscopy/tests/test_readers.py | 17 +- 4 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 orangecontrib/spectroscopy/datasets/NeaReaderMultichannel_test/Test_Au_Fourier_Scan_Synchrotron.txt diff --git a/orangecontrib/spectroscopy/datasets/NeaReaderMultichannel_test/Test_Au_Fourier_Scan_Synchrotron.txt b/orangecontrib/spectroscopy/datasets/NeaReaderMultichannel_test/Test_Au_Fourier_Scan_Synchrotron.txt new file mode 100644 index 000000000..e831e6eb5 --- /dev/null +++ b/orangecontrib/spectroscopy/datasets/NeaReaderMultichannel_test/Test_Au_Fourier_Scan_Synchrotron.txt @@ -0,0 +1,61 @@ +# www.neaspec.com +# Scan:   Fourier Scan +# Project:   User_Jun2024 +# Description:   test +# Date:   06/06/2024 11:16:43 +# Scanner Center Position (X, Y): [µm] 45.11 43.71   +# Rotation: [°] 0     +# Scan Area (X, Y, Z): [µm] 0.000 0.000 0.000 +# Pixel Area (X, Y, Z): [px] 1 1 10 +# Interferometer Center/Distance: [µm] 715.000 489.670   +# Averaging:   3     +# Integration time: [ms] 20     +# Wavenumber Scaling:   0.979340     +# Laser Source:   Synchrotron +# Detector:   R +# Target Wavelength: [µm]     +# Demodulation Mode:   Fourier +# Tip Frequency: [Hz] 283,708.188     +# Tip Amplitude: [mV] 60.000     +# Tapping Amplitude: [nm] 85.484     +# Modulation Frequency: [Hz] 0.000     +# Modulation Amplitude: [mV] 0.000     +# Modulation Offset: [mV] 0.000     +# Setpoint: [%] 80.45     +# Regulator (P, I, D):   2.077946 2.992700 1.000000 +# Tip Potential: [mV] 0.000     +# M1A Scaling: [nm/V] 0.362     +# M1A Cantilever Factor:   1,000     +# Q-Factor:   486.9     +# Version:   2.1.11145.0 +Row Column Run Depth Z M O0A O0P O1A O1P O2A O2P O3A O3P +0 0 0 0 1.4830833e-06 0.0004702841 140.833 0 6.4348807 0.6645405 1.9587896 1.3941813 0.32648554 2.02239 +0 0 0 1 1.4830523e-06 0.00047094605 140.89937 0 6.376804 0.6775657 2.06971 1.3237468 0.34186834 2.239848 +0 0 0 2 1.4830403e-06 0.00047150656 140.72264 0 6.4284115 0.6680331 2.0108752 1.4282312 0.3066287 2.083407 +0 0 0 3 1.4830321e-06 0.00047198377 141.02126 0 6.440742 0.6651447 2.1001942 1.4406391 0.3243808 2.114817 +0 0 0 4 1.4830205e-06 0.00047250776 140.71957 0 6.4532413 0.6639509 2.0856764 1.3328111 0.39436918 2.2339847 +0 0 0 5 1.4830451e-06 0.00047301615 140.70483 0 6.519065 0.67524594 2.0188963 1.3270559 0.3245534 2.140174 +0 0 0 6 1.4830032e-06 0.00047354892 140.81462 0 6.385892 0.6718575 2.1171083 1.4143238 0.37795374 2.1460977 +0 0 0 7 1.482964e-06 0.00047397628 140.86049 0 6.4647484 0.6580694 1.9117932 1.346964 0.32442412 2.06036 +0 0 0 8 1.4830063e-06 0.00047444427 140.72115 0 6.3940635 0.66261894 1.9369293 1.3707676 0.34072402 1.9546772 +0 0 0 9 1.482989e-06 0.00047498295 140.80338 0 6.4686675 0.67149276 2.1063504 1.3728113 0.37116763 2.19878 +0 0 1 0 1.4825338e-06 0.0004702481 140.81854 0 6.4119663 0.6709071 1.9422241 1.3830261 0.38319752 2.1862426 +0 0 1 1 1.482516e-06 0.0004709231 140.87456 0 6.409243 0.6731461 2.1233785 1.3569052 0.3769568 2.1356432 +0 0 1 2 1.4825473e-06 0.00047149902 140.76886 0 6.450717 0.66784745 1.960403 1.39625 0.3452003 2.030382 +0 0 1 3 1.4825597e-06 0.00047205604 140.92456 0 6.4817934 0.66957307 2.0517309 1.4051965 0.36747894 2.1893978 +0 0 1 4 1.4825615e-06 0.00047252374 140.7098 0 6.4945116 0.6747239 2.1202147 1.3261337 0.34824422 2.1677 +0 0 1 5 1.4825405e-06 0.00047299115 140.82637 0 6.4944396 0.6650799 2.078894 1.4402506 0.35788947 2.054634 +0 0 1 6 1.4825727e-06 0.00047346027 140.84087 0 6.491022 0.66714317 2.0637867 1.4566696 0.40971443 2.188562 +0 0 1 7 1.4824976e-06 0.00047399005 140.68579 0 6.4839234 0.6726401 2.0141037 1.316157 0.37277195 2.093988 +0 0 1 8 1.4824902e-06 0.00047446677 140.74852 0 6.434334 0.6701001 2.093401 1.4185503 0.40026203 2.138171 +0 0 1 9 1.4825383e-06 0.0004750186 140.77066 0 6.458346 0.68138194 2.1652575 1.4089698 0.33343133 2.2287297 +0 0 2 0 1.4835095e-06 0.0004655871 140.84943 0 6.428511 0.66852653 2.1124306 1.406314 0.32755885 2.1679842 +0 0 2 1 1.4834801e-06 0.00046994354 140.78981 0 6.461014 0.68173933 1.9941256 1.3394383 0.36892703 2.0712361 +0 0 2 2 1.4834723e-06 0.00047150397 140.75708 0 6.4619493 0.67282456 2.177506 1.4042937 0.36492768 2.0972927 +0 0 2 3 1.4835148e-06 0.00047200115 140.81462 0 6.5146437 0.6615875 2.1562595 1.3933375 0.31578675 2.1395767 +0 0 2 4 1.4835242e-06 0.00047247042 140.68408 0 6.51833 0.66206515 1.9239031 1.4076596 0.3802598 2.0594769 +0 0 2 5 1.4835093e-06 0.00047294653 140.9233 0 6.471621 0.67279315 1.953958 1.4078617 0.3587442 1.9888307 +0 0 2 6 1.4834782e-06 0.00047348 140.78293 0 6.4170303 0.6764211 2.159531 1.3579578 0.35871413 2.161588 +0 0 2 7 1.4834847e-06 0.0004740215 140.74995 0 6.4891605 0.6643903 1.979399 1.4139515 0.35736507 1.9134548 +0 0 2 8 1.4834957e-06 0.00047446118 140.81374 0 6.401533 0.66336787 2.1227689 1.3395565 0.36966056 2.1297655 +0 0 2 9 1.4834936e-06 0.00047489564 140.66603 0 6.452134 0.67026573 2.1386855 1.3077831 0.40882605 2.0923867 diff --git a/orangecontrib/spectroscopy/io/__init__.py b/orangecontrib/spectroscopy/io/__init__.py index 685a283c1..ddceab36f 100644 --- a/orangecontrib/spectroscopy/io/__init__.py +++ b/orangecontrib/spectroscopy/io/__init__.py @@ -12,7 +12,7 @@ # Instrument-specific readers from .agilent import AgilentImageReader, AgilentImageIFGReader, agilentMosaicReader,\ agilentMosaicIFGReader, agilentMosaicTileReader -from .neaspec import NeaReader, NeaReaderGSF +from .neaspec import NeaReader, NeaReaderGSF, NeaReaderMultiChannel from .omnic import OmnicMapReader, SPAReader, SPCReader from .opus import OPUSReader from .ptir import PTIRFileReader diff --git a/orangecontrib/spectroscopy/io/neaspec.py b/orangecontrib/spectroscopy/io/neaspec.py index c55835024..3f9752ec4 100644 --- a/orangecontrib/spectroscopy/io/neaspec.py +++ b/orangecontrib/spectroscopy/io/neaspec.py @@ -1,8 +1,10 @@ from html.parser import HTMLParser +import re import Orange import numpy as np -from Orange.data import FileFormat, Table +import pandas as pd +from Orange.data import FileFormat, Table, Domain, ContinuousVariable from scipy.interpolate import interp1d from orangecontrib.spectroscopy.io.gsf import reader_gsf @@ -310,4 +312,298 @@ def handle_data(self, data): def _gsf_reader(self, path): X, _, _ = reader_gsf(path) - return np.asarray(X) \ No newline at end of file + return np.asarray(X) + + +class NeaReaderMultiChannel(FileFormat): + EXTENSIONS = ".txt" + DESCRIPTION = "NeaSPEC multichannel (raw) IFGs" + + def __init__(self, filename): + super().__init__(filename) + self.info = {} + + self.original_df = pd.DataFrame() + self.cartesian_df = pd.DataFrame() + self.resampled_df = pd.DataFrame() + + self.domain = np.array([]) + + self.original_channel_names = [] + self.cartesian_channel_names = [] + + @staticmethod + def _read_table_metas(fpath): + # parser for the header + def lineparser(line): + k, v = line.strip("# ").split(":\t") + v = v.strip().split("\t") + v = v[0] if len(v) == 1 else v + return k, v + + # read file header to get the number of rows to skip + header_length = 0 + metadata_header = [] + with open(fpath, "r", encoding="utf-8") as f: + data = f.readlines() + metadata_header = [row for row in data if row.startswith("#")] + header_length = len(metadata_header) + # creating the dictionary, skipping the first line of the header + meta_info = {} + for line in metadata_header[1:]: + k, v = lineparser(line) + meta_info.update({k: v}) + + return meta_info, header_length + + @staticmethod + def _read_table_data(fpath, header_length): + # table header + formatted_table_header = [] + with open(fpath, "r", encoding="utf-8") as f: + # the line containing the column headers is the first line after the metadata header + table_header = f.readlines()[header_length] + formatted_table_header = table_header.split("\t") + formatted_table_header = [ + header.strip() for header in formatted_table_header + ] + # reading the data + df = pd.read_csv( + fpath, + sep="\t", + skiprows=header_length + 1, + encoding="utf-8", + names=formatted_table_header, + ).dropna(axis=1, how="all") + return df + + @staticmethod + def _create_cartesian_df(df, original_channel_names): + # create a new DataFrame with the cartesian form of the data + # as Re(On) = OnA * cos(OnP), Im(On) = OnA * sin(OnP) + amplitude_idx = [c[1] for c in original_channel_names if c[2] == "A"] + phase_idx = [c[1] for c in original_channel_names if c[2] == "P"] + channel_numbers = [idx for idx in amplitude_idx if idx in phase_idx] + cartesian_channel_names = [] + cartesian_df = df.copy() + cartesian_df.drop(columns=original_channel_names, inplace=True) + for cn in channel_numbers: + channel_A = f"O{cn}A" + channel_P = f"O{cn}P" + re_channel = f"O{cn}R" + im_channel = f"O{cn}I" + Re_data = df[channel_A] * np.cos(df[channel_P]) + Im_data = df[channel_A] * np.sin(df[channel_P]) + cartesian_df[re_channel] = Re_data + cartesian_df[im_channel] = Im_data + cartesian_channel_names.append(re_channel) + cartesian_channel_names.append(im_channel) + return cartesian_df, cartesian_channel_names + + @staticmethod + def _cartesian_to_polar(re_data, im_data): + amplitude = np.abs(re_data + 1j * im_data) + phase = np.angle(re_data + 1j * im_data) + return amplitude, phase + + @staticmethod + def _extract_channel_names(df): + return [channel for channel in df.columns if re.match(r"O[0-9][A|P]", channel)] + + @staticmethod + def _domain_spacing(interferometer_distance, no_points): + return interferometer_distance / (float(no_points) - 1.0) + + @staticmethod + def _define_M_range(df): + # find the smallest M value at the end of every run + # and the largest M value at the beginning of every run + # so that we can create a common domain for all runs + # and interpolate the data to the same domain + M_min = df.groupby("Run")["M"].min().max() + M_max = df.groupby("Run")["M"].max().min() + return M_min, M_max + + def _create_resampled_polar_df_from_cartesian( + self, cartesian_df, cartesian_channel_names, domain, padding=100 + ): + # create a new DataFrame resampling the cartesian data to the new domain + # and transforming it to polar form (amplitude and phase) + domain_indexes = range(len(domain)) # same as Depth values + resampled_df = pd.DataFrame( + columns=["Row", "Column", "Run", "Channel", *domain_indexes] + ) + re_idx = [c[1] for c in cartesian_channel_names if c[2] == "R"] + im_idx = [c[1] for c in cartesian_channel_names if c[2] == "I"] + channel_numbers = [idx for idx in re_idx if idx in im_idx] + # List to collect all rows for appending to the DataFrame + dfrows = [] + for row in cartesian_df["Row"].unique(): + for col in cartesian_df["Column"].unique(): + for run in cartesian_df["Run"].unique(): + for cn in channel_numbers: + re_channel = f"O{cn}R" + im_channel = f"O{cn}I" + # filter the data for the current row, column, and run + fdata = cartesian_df[ + (cartesian_df["Row"] == row) + & (cartesian_df["Column"] == col) + & (cartesian_df["Run"] == run) + ] + # resampling the data with extra 100 points of padding on each side + # using the mean of the first and last 100 points to pad the data + re_data = np.interp( + domain, + fdata["M"], + fdata[re_channel], + left=np.mean(fdata[re_channel][:padding]), + right=np.mean(fdata[re_channel][-padding:]), + ) + im_data = np.interp( + domain, + fdata["M"], + fdata[im_channel], + left=np.mean(fdata[im_channel][:padding]), + right=np.mean(fdata[im_channel][-padding:]), + ) + # transform the data to polar form + amplitude, phase = self._cartesian_to_polar(re_data, im_data) + # add the resampled data to the list as OnA and OnP channels + dfrows.append( + { + "Row": row, + "Column": col, + "Run": run, + "Channel": f"O{cn}A", + **dict(enumerate(amplitude)), + } + ) + dfrows.append( + { + "Row": row, + "Column": col, + "Run": run, + "Channel": f"O{cn}P", + **dict(enumerate(phase)), + } + ) + # Create the DataFrame from the list of rows + resampled_df = pd.concat( + [pd.DataFrame([row]) for row in dfrows], ignore_index=True + ) + return resampled_df + + def create_padded_domain(self, df, padding=100): + # creates a new domain for resampling the data: + # the domain will be created from the M values in the data + # its spacing interval will be calculated from metadata + # it will contain the number of points of the original data + # but with added padding on each side to accomodate the resampling + # to a common interval + + if not self.info: + raise ValueError("No metadata found") + # parse info from the metadata to create the domain + interferometer_units, _, interferometer_distance = self.info[ + "Interferometer Center/Distance" + ] + if interferometer_units != "[µm]": + raise ValueError("Interferometer units are not in micrometers") + interferometer_distance = ( + float(interferometer_distance) * 1e-6 + ) # convert [µm] to [m] + px_area_units, _, _, z = self.info["Pixel Area (X, Y, Z)"] + if px_area_units != "[px]": + raise ValueError("Pixel area units are not in pixels") + no_points = int(z) + m_min, m_max = self._define_M_range(df) + dm = self._domain_spacing(interferometer_distance, no_points) + # create the domain with the padding + d_start = m_min - padding * dm + d_end = m_min + (no_points + padding) * dm + if d_end < m_max: + raise ValueError( + "Could not create a domain with the given padding. \ + M maximum values are outside the expected range." + ) + self.domain = np.arange(d_start, d_end, dm) + dx = 2 * float(dm) * 1e2 # convert [m] to [cm] + self.info["Calculated Datapoint Spacing (Δx)"] = ["[cm]", dx] + + def create_original_df(self, fpath): + self.filename = fpath + self.info, header_length = self._read_table_metas(self.filename) + self.original_df = self._read_table_data(self.filename, header_length) + self.original_channel_names = self._extract_channel_names(self.original_df) + + def create_cartesian_df(self): + if self.original_df.empty or not self.original_channel_names: + raise ValueError("Original data not found") + + self.cartesian_df, self.cartesian_channel_names = self._create_cartesian_df( + self.original_df, self.original_channel_names + ) + # clean up the original DataFrame + self.original_df = pd.DataFrame() + + def create_resampled_df(self, padding=100): + if self.cartesian_df.empty or not self.cartesian_channel_names: + raise ValueError("Cartesian data not found") + if self.domain.size == 0: + raise ValueError("Domain not found") + self.resampled_df = self._create_resampled_polar_df_from_cartesian( + self.cartesian_df, + self.cartesian_channel_names, + self.domain, + padding=padding, + ) + # clean up the cartesian DataFrame + self.cartesian_df = pd.DataFrame() + + def read(self): + # builds the spectra table from the output of the read_spectra method + out_data_headers, out_data, meta_data = self.read_spectra() + features = [ + ContinuousVariable(name=f"{f}", number_of_decimals=10, compute_value=f) + for f in out_data_headers + ] + domain = Domain( + features, + class_vars=meta_data.domain.class_vars, + metas=meta_data.domain.metas, + ) + return Table.from_numpy( + domain, X=out_data, metas=meta_data.metas, attributes=meta_data.attributes + ) + + def read_spectra(self): + self.create_original_df(self.filename) + self.create_padded_domain(self.original_df, padding=100) + self.create_cartesian_df() + self.create_resampled_df(padding=100) + df = self.resampled_df + # format output to be used in the read method + + self.original_df = [] + out_data = df.drop(columns=["Row", "Column", "Run", "Channel"]).values + out_data = out_data.astype(np.float64) + # formatting metas + meta_domain = [ + Orange.data.ContinuousVariable.make("column"), + Orange.data.ContinuousVariable.make("row"), + Orange.data.ContinuousVariable.make("run"), + Orange.data.StringVariable.make("channel"), + ] + out_meta = df[["Column", "Row", "Run", "Channel"]].values + + # formatting domain + orange_domain = Orange.data.Domain([], None, metas=meta_domain) + meta_data = Table.from_numpy( + orange_domain, + X=np.zeros((out_data.shape[0], 0)), + metas=out_meta, + ) + self.info["Channel Data Type"] = "Polar", "i.e. Amplitude and Phase separated" + + meta_data.attributes = self.info + return self.domain, out_data, meta_data diff --git a/orangecontrib/spectroscopy/tests/test_readers.py b/orangecontrib/spectroscopy/tests/test_readers.py index 921241cab..7d59baa0a 100644 --- a/orangecontrib/spectroscopy/tests/test_readers.py +++ b/orangecontrib/spectroscopy/tests/test_readers.py @@ -9,7 +9,7 @@ from Orange.tests import named_file from Orange.widgets.data.owfile import OWFile from orangecontrib.spectroscopy.data import getx, build_spec_table -from orangecontrib.spectroscopy.io.neaspec import NeaReader, NeaReaderGSF +from orangecontrib.spectroscopy.io.neaspec import NeaReader, NeaReaderGSF, NeaReaderMultiChannel from orangecontrib.spectroscopy.io.util import ConstantBytesVisibleImage from orangecontrib.spectroscopy.io.soleil import SelectColumnReader, HDF5Reader_HERMES from orangecontrib.spectroscopy.preprocess import features_with_interpolation @@ -441,8 +441,23 @@ def test_read(self): n_ifg = int(data.attributes['Pixel Area (X, Y, Z)'][3]) self.assertEqual(n_ifg, 1024) self.assertEqual(n_ifg, len(data.domain.attributes)) + self.assertEqual(data.attributes['Channel Data Type'][0], 'Polar') + self.assertEqual(data.attributes['Calculated Datapoint Spacing (Δx)'][0], '[cm]') check_attributes(data) +class TestNeaMultiChannel(unittest.TestCase): + def test_read(self): + fn = 'NeaReaderMultichannel_test/Test_Au_Fourier_Scan_Synchrotron.txt' + absolute_filename = FileFormat.locate(fn, dataset_dirs) + data = NeaReaderMultiChannel(absolute_filename).read() + self.assertEqual(len(data), 24) + self.assertEqual("channel", data.domain.metas[3].name) + self.assertEqual("O0A", data.metas[0][3]) + self.assertEqual("O0P", data.metas[1][3]) + self.assertEqual(data.attributes['Channel Data Type'][0], 'Polar') + self.assertEqual(data.attributes['Calculated Datapoint Spacing (Δx)'][0], '[cm]') + self.assertEqual(data.attributes['Scan'], 'Fourier Scan') + check_attributes(data) class TestEnvi(unittest.TestCase): From f6a9af9beceeeaa4b2ad637eda25e6a1dd6f1bb7 Mon Sep 17 00:00:00 2001 From: Bruno Carlos <6951456+brnovasco@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:57:08 -0300 Subject: [PATCH 2/7] feat: Add new method to calculate the complex fft for input data in the polar form (separated amplitude and phase channels in alternating rows). --- .../spectroscopy/tests/test_owfft.py | 14 ++- orangecontrib/spectroscopy/widgets/owfft.py | 98 ++++++++++++++++++- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/orangecontrib/spectroscopy/tests/test_owfft.py b/orangecontrib/spectroscopy/tests/test_owfft.py index 3eda07ffd..d9c0c4b4b 100644 --- a/orangecontrib/spectroscopy/tests/test_owfft.py +++ b/orangecontrib/spectroscopy/tests/test_owfft.py @@ -128,8 +128,14 @@ def test_complex_calculation(self): self.send_signal(self.widget.Inputs.data, self.ifg_gsf) self.commit_and_wait() - result_gsf = self.get_output(self.widget.Outputs.spectra) + # testing info panel text associated with the input file metadata + widget_text = self.widget.infoc.text() + self.assertIn('Applying Complex Fourier Transform.', widget_text) + self.assertIn('Using Calculated Datapoint Spacing (Δx) from metadata', widget_text) - np.testing.assert_allclose(result_gsf.X.size, (4098)) #array - np.testing.assert_allclose(result_gsf.X[0, 396:399], (23.67618359, 25.02051088, 25.82566789)) #Amplitude - np.testing.assert_allclose(result_gsf.X[1, 396:399], (2.61539453, 2.65495979, 2.72814989)) #Phase + spectra = self.get_output(self.widget.Outputs.spectra) + phases = self.get_output(self.widget.Outputs.phases) + np.testing.assert_allclose(spectra.X.size, (2049)) + np.testing.assert_allclose(phases.X.size, (2049)) + np.testing.assert_allclose(spectra.X[0, 396:399], (23.67618359, 25.02051088, 25.82566789)) + np.testing.assert_allclose(phases.X[0, 396:399], (2.61539453, 2.65495979, 2.72814989)) diff --git a/orangecontrib/spectroscopy/widgets/owfft.py b/orangecontrib/spectroscopy/widgets/owfft.py index 6cbeaccda..d5138595d 100644 --- a/orangecontrib/spectroscopy/widgets/owfft.py +++ b/orangecontrib/spectroscopy/widgets/owfft.py @@ -4,7 +4,7 @@ from AnyQt.QtWidgets import QGridLayout, QApplication import Orange.data -from Orange.data import ContinuousVariable, Domain +from Orange.data import ContinuousVariable, Domain, Table from Orange.widgets.widget import OWWidget, Input, Output, Msg from Orange.widgets import gui, settings @@ -110,6 +110,7 @@ def __init__(self): self.reader = None if self.dx_HeNe is True: self.dx = 1.0 / self.laser_wavenumber / 2.0 + self.use_polar_FFT = False layout = QGridLayout() layout.setContentsMargins(0, 0, 0, 0) @@ -353,7 +354,10 @@ def peak_search_changed(self): @gui.deferred def commit(self): if self.data is not None: - self.calculateFFT() + if self.use_polar_FFT: + self.calculate_polar_FFT() + else: + self.calculateFFT() def calculateFFT(self): """ @@ -517,6 +521,55 @@ def calculateFFT(self): self.Outputs.spectra.send(self.spectra_table) self.Outputs.phases.send(self.phases_table) + def calculate_polar_FFT(self): + # polar channel data comes in alternating channel pairs + # of amplitude and phase data for the same channel for each run + amplitude_in = self.data.X[::2] + phases_in = self.data.X[1::2] + ifg_data = amplitude_in * np.exp(phases_in * 1j) + + wavenumbers = None + spectra = [] + phases = [] + + # Reset info, error and warning dialogs + self.Error.clear() + self.Warning.clear() + + fft_single = irfft.ComplexFFT( + dx=self.dx, + apod_func=self.apod_func, + zff=2**self.zff, + phase_res=self.phase_resolution if self.phase_res_limit else None, + phase_corr=self.phase_corr, + peak_search=self.peak_search, + ) + for row in ifg_data: + spectrum_out, phase_out, wavenumbers = fft_single(row, zpd=None) + spectra.append(spectrum_out) + phases.append(phase_out) + + spectra = np.vstack(spectra) + phases = np.vstack(phases) + + if self.limit_output is True: + wavenumbers, spectra = self.limit_range(wavenumbers, spectra) + _, phases = self.limit_range(wavenumbers, phases) + + wavenumbers_domain = Domain( + [ContinuousVariable.make(f"{w}") for w in wavenumbers], + metas=self.data.domain.metas, + ) + self.spectra_table = Table.from_numpy( + wavenumbers_domain, X=spectra, metas=self.data.metas[::2] + ) + phases_table = Table.from_numpy( + wavenumbers_domain, X=phases, metas=self.data.metas[1::2] + ) + + self.Outputs.spectra.send(self.spectra_table) + self.Outputs.phases.send(phases_table) + def determine_sweeps(self): """ Determine if input interferogram is single-sweep or @@ -561,8 +614,7 @@ def determine_sweeps(self): self.sweeps = 2 def check_metadata(self): - """ Look for laser wavenumber and sampling interval metadata """ - + """Look for laser wavenumber and sampling interval metadata""" try: self.reader = self.data.attributes['Reader'] except KeyError: @@ -593,6 +645,44 @@ def check_metadata(self): self.infoc.setText(f"Using an automatic datapoint spacing (Δx).\nΔx:\t{self.dx:.8} cm\nApplying Complex Fourier Transform.") return + try: + channel_data, detail = self.data.attributes["Channel Data Type"] + if channel_data == "Polar": + self.use_polar_FFT = True + self.infoc.setText( + f"Channel data type: {channel_data} ({detail}).\n" + + "Applying Complex Fourier Transform." + ) + except KeyError: + pass + try: + domain_units, dx = self.data.attributes["Calculated Datapoint Spacing (Δx)"] + if domain_units != "[cm]" or not dx: + raise KeyError + + self.dx = dx + self.zff = 2 + self.dx_HeNe = False + self.dx_HeNe_cb.setDisabled(True) + self.dx_edit.setDisabled(True) + self.controls.auto_sweeps.setDisabled(True) + self.controls.sweeps.setDisabled(True) + self.controls.peak_search.setEnabled(True) + self.controls.zpd1.setDisabled(True) + self.controls.zpd2.setDisabled(True) + self.controls.phase_corr.setDisabled(True) + self.controls.phase_res_limit.setDisabled(True) + self.controls.phase_resolution.setDisabled(True) + + self.infoc.setText( + self.infoc.text() + + "\n" + + "Using Calculated Datapoint Spacing (Δx) from metadata." + ) + return + except KeyError: + pass + try: lwn = self.data.get_column("Effective Laser Wavenumber") except ValueError: From 359c71c005a831e9afa4d06030dbbe4356b0d3ca Mon Sep 17 00:00:00 2001 From: Bruno Carlos <6951456+brnovasco@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:07:14 -0300 Subject: [PATCH 3/7] fix: Fixing the "Avoid magic word" problem in the owfft data for the .gsf format Do so by making the reader calculate its datapoint spacing and save it as metadata that can be consumed in the fft widget. This allow the owfft widget to treat the data from the NeaReaderGSF and the NeaReaderMultiChannel the same way, as it should since they both use the same complex fft and provide data in similar form. --- orangecontrib/spectroscopy/io/neaspec.py | 12 +++++ orangecontrib/spectroscopy/widgets/owfft.py | 57 +-------------------- 2 files changed, 14 insertions(+), 55 deletions(-) diff --git a/orangecontrib/spectroscopy/io/neaspec.py b/orangecontrib/spectroscopy/io/neaspec.py index 3f9752ec4..ae672cdba 100644 --- a/orangecontrib/spectroscopy/io/neaspec.py +++ b/orangecontrib/spectroscopy/io/neaspec.py @@ -273,6 +273,18 @@ def _format_file(self, gsf_a, gsf_p, parameters): i = f f = i + px_z + # calculate datapoint spacing in cm for the fft widget + number_of_points = px_z + scan_size = float( + info["Interferometer Center/Distance"][2].replace(",", "") + ) # Microns + scan_size = scan_size * 1e-4 # Convert to cm + step_size = (scan_size * 2) / (number_of_points - 1) + # metadata info for the fft widget calculation + info["Calculated Datapoint Spacing (Δx)"] = ["[cm]", step_size] + # metadata info for selecting the correct fft method in the fft widget + info["Channel Data Type"] = "Polar", "i.e. Amplitude and Phase separated" + return np.asarray(data_complete), info, final_metas def _html_reader(self, path): diff --git a/orangecontrib/spectroscopy/widgets/owfft.py b/orangecontrib/spectroscopy/widgets/owfft.py index d5138595d..e4a2cbcc0 100644 --- a/orangecontrib/spectroscopy/widgets/owfft.py +++ b/orangecontrib/spectroscopy/widgets/owfft.py @@ -417,32 +417,8 @@ def calculateFFT(self): phase_res=self.phase_resolution if self.phase_res_limit else None, phase_corr=self.phase_corr, peak_search=self.peak_search, - ) - - if self.reader == 'NeaReaderGSF': - fft_single = irfft.ComplexFFT( - dx=self.dx, - apod_func=self.apod_func, - zff=2**self.zff, - phase_res=self.phase_resolution if self.phase_res_limit else None, - phase_corr=self.phase_corr, - peak_search=self.peak_search, - ) - full_data = self.data.X[::2] * np.exp(self.data.X[1::2]* 1j) - for row in full_data: - spectrum_out, phase_out, wavenumbers = fft_single( - row, zpd=stored_zpd_fwd) - spectra.append(spectrum_out) - spectra.append(phase_out) - spectra = np.vstack(spectra) - - if self.limit_output is True: - wavenumbers, spectra = self.limit_range(wavenumbers, spectra) - self.spectra_table = build_spec_table(wavenumbers, spectra, - additional_table=self.data) - self.Outputs.spectra.send(self.spectra_table) - return - + ) + for row in ifg_data: if self.sweeps in [2, 3]: # split double-sweep for forward/backward @@ -615,35 +591,6 @@ def determine_sweeps(self): def check_metadata(self): """Look for laser wavenumber and sampling interval metadata""" - try: - self.reader = self.data.attributes['Reader'] - except KeyError: - self.reader = None - - if self.reader == 'NeaReaderGSF': # TODO Avoid the magic word - self.dx_HeNe = False - self.dx_HeNe_cb.setDisabled(True) - self.dx_edit.setDisabled(True) - self.controls.auto_sweeps.setDisabled(True) - self.controls.sweeps.setDisabled(True) - self.controls.peak_search.setEnabled(True) - self.controls.zpd1.setDisabled(True) - self.controls.zpd2.setDisabled(True) - self.controls.phase_corr.setDisabled(True) - self.controls.phase_res_limit.setDisabled(True) - self.controls.phase_resolution.setDisabled(True) - - info = self.data.attributes - number_of_points = int(info['Pixel Area (X, Y, Z)'][3]) - scan_size = float(info['Interferometer Center/Distance'][2].replace(',', '')) #Microns - scan_size = scan_size*1e-4 #Convert to cm - step_size = (scan_size * 2) / (number_of_points - 1) - - self.dx = step_size - self.zff = 2 #Because is power of 2 - - self.infoc.setText(f"Using an automatic datapoint spacing (Δx).\nΔx:\t{self.dx:.8} cm\nApplying Complex Fourier Transform.") - return try: channel_data, detail = self.data.attributes["Channel Data Type"] From c0b81f7f73416f7d4ddefe0416a719c0ae2eb6c4 Mon Sep 17 00:00:00 2001 From: Bruno Carlos <6951456+brnovasco@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:13:34 -0300 Subject: [PATCH 4/7] fix: fixing small linting errors in the NeaReaderGSF class --- orangecontrib/spectroscopy/io/neaspec.py | 36 +++++++++++++++--------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/orangecontrib/spectroscopy/io/neaspec.py b/orangecontrib/spectroscopy/io/neaspec.py index ae672cdba..c03e48277 100644 --- a/orangecontrib/spectroscopy/io/neaspec.py +++ b/orangecontrib/spectroscopy/io/neaspec.py @@ -206,24 +206,32 @@ def read_spectra(self): file_channel = str(self.filename.split(' ')[-2]).strip() folder_file = str(self.filename.split(file_channel)[-2]).strip() - if 'P' in file_channel: - self.channel_p = file_channel - self.channel_a = file_channel.replace('P', 'A') + channel_p = "" + channel_a = "" + file_gsf_a = "" + file_gsf_p = "" + file_html = "" + + if "P" in file_channel: + channel_p = file_channel + channel_a = file_channel.replace("P", "A") file_gsf_p = self.filename - file_gsf_a = self.filename.replace('P raw.gsf', 'A raw.gsf') - file_html = folder_file + '.html' - elif 'A' in file_channel: - self.channel_a = file_channel - self.channel_p = file_channel.replace('A', 'P') + file_gsf_a = self.filename.replace("P raw.gsf", "A raw.gsf") + file_html = folder_file + ".html" + elif "A" in file_channel: + channel_a = file_channel + channel_p = file_channel.replace("A", "P") file_gsf_a = self.filename - file_gsf_p = self.filename.replace('A raw.gsf', 'P raw.gsf') - file_html = folder_file + '.html' + file_gsf_p = self.filename.replace("A raw.gsf", "P raw.gsf") + file_html = folder_file + ".html" data_gsf_a = self._gsf_reader(file_gsf_a) data_gsf_p = self._gsf_reader(file_gsf_p) info = self._html_reader(file_html) - final_data, parameters, final_metas = self._format_file(data_gsf_a, data_gsf_p, info) + final_data, parameters, final_metas = self._format_file( + data_gsf_a, data_gsf_p, info, channel_a, channel_p + ) metas = [Orange.data.ContinuousVariable.make("column"), Orange.data.ContinuousVariable.make("row"), @@ -240,7 +248,7 @@ def read_spectra(self): return depth, final_data, meta_data - def _format_file(self, gsf_a, gsf_p, parameters): + def _format_file(self, gsf_a, gsf_p, parameters, channel_a, channel_p): info = {} for row in parameters: @@ -268,8 +276,8 @@ def _format_file(self, gsf_a, gsf_p, parameters): for run in range(0, averaging): data_complete += [amplitude[i:f]] data_complete += [phase[i:f]] - final_metas += [[x, y, run, self.channel_a]] - final_metas += [[x, y, run, self.channel_p]] + final_metas += [[x, y, run, channel_a]] + final_metas += [[x, y, run, channel_p]] i = f f = i + px_z From 99755f54026ff4a83c73ac2816e5c75ee47a5fa0 Mon Sep 17 00:00:00 2001 From: Bruno Carlos <6951456+brnovasco@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:26:15 -0300 Subject: [PATCH 5/7] fix: error in extensions format. --- orangecontrib/spectroscopy/io/neaspec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orangecontrib/spectroscopy/io/neaspec.py b/orangecontrib/spectroscopy/io/neaspec.py index c03e48277..dfdc02b87 100644 --- a/orangecontrib/spectroscopy/io/neaspec.py +++ b/orangecontrib/spectroscopy/io/neaspec.py @@ -336,7 +336,7 @@ def _gsf_reader(self, path): class NeaReaderMultiChannel(FileFormat): - EXTENSIONS = ".txt" + EXTENSIONS = (".txt",) DESCRIPTION = "NeaSPEC multichannel (raw) IFGs" def __init__(self, filename): From ab03ff9cbbcbd13e4f3b3afe4f3cf73a06b70f97 Mon Sep 17 00:00:00 2001 From: Bruno Carlos <6951456+brnovasco@users.noreply.github.com> Date: Wed, 2 Oct 2024 08:51:10 -0300 Subject: [PATCH 6/7] =?UTF-8?q?enh:=20Using=20the=20default=20read=20metho?= =?UTF-8?q?d=20inherited=20from=20SpectralFileFormat,=20scaling=20the=20do?= =?UTF-8?q?main=20to=20[=C2=B5m]=20and=20renaming=20column=20and=20row=20t?= =?UTF-8?q?o=20standard=20map=5Fx=20and=20map=5Fy=20vars.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orangecontrib/spectroscopy/io/neaspec.py | 31 +++++++----------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/orangecontrib/spectroscopy/io/neaspec.py b/orangecontrib/spectroscopy/io/neaspec.py index dfdc02b87..40d0b01b7 100644 --- a/orangecontrib/spectroscopy/io/neaspec.py +++ b/orangecontrib/spectroscopy/io/neaspec.py @@ -335,7 +335,7 @@ def _gsf_reader(self, path): return np.asarray(X) -class NeaReaderMultiChannel(FileFormat): +class NeaReaderMultiChannel(FileFormat, SpectralFileFormat): EXTENSIONS = (".txt",) DESCRIPTION = "NeaSPEC multichannel (raw) IFGs" @@ -580,22 +580,6 @@ def create_resampled_df(self, padding=100): # clean up the cartesian DataFrame self.cartesian_df = pd.DataFrame() - def read(self): - # builds the spectra table from the output of the read_spectra method - out_data_headers, out_data, meta_data = self.read_spectra() - features = [ - ContinuousVariable(name=f"{f}", number_of_decimals=10, compute_value=f) - for f in out_data_headers - ] - domain = Domain( - features, - class_vars=meta_data.domain.class_vars, - metas=meta_data.domain.metas, - ) - return Table.from_numpy( - domain, X=out_data, metas=meta_data.metas, attributes=meta_data.attributes - ) - def read_spectra(self): self.create_original_df(self.filename) self.create_padded_domain(self.original_df, padding=100) @@ -604,26 +588,29 @@ def read_spectra(self): df = self.resampled_df # format output to be used in the read method - self.original_df = [] + # format data out_data = df.drop(columns=["Row", "Column", "Run", "Channel"]).values out_data = out_data.astype(np.float64) # formatting metas meta_domain = [ - Orange.data.ContinuousVariable.make("column"), - Orange.data.ContinuousVariable.make("row"), + Orange.data.ContinuousVariable.make("map_x"), + Orange.data.ContinuousVariable.make("map_y"), Orange.data.ContinuousVariable.make("run"), Orange.data.StringVariable.make("channel"), ] out_meta = df[["Column", "Row", "Run", "Channel"]].values # formatting domain + # scale the domain to micrometers + scaled_domain = self.domain * 1e6 + self.info["Domain Units"] = "[µm]" orange_domain = Orange.data.Domain([], None, metas=meta_domain) meta_data = Table.from_numpy( orange_domain, X=np.zeros((out_data.shape[0], 0)), metas=out_meta, ) + # info for the fft widget self.info["Channel Data Type"] = "Polar", "i.e. Amplitude and Phase separated" - meta_data.attributes = self.info - return self.domain, out_data, meta_data + return scaled_domain, out_data, meta_data From 781b1d2e7d14ec25a2a2ee3cab67fd8a5302eb13 Mon Sep 17 00:00:00 2001 From: Bruno Carlos <6951456+brnovasco@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:49:03 -0300 Subject: [PATCH 7/7] (enh) Replacing map_x and map_y with constants. --- orangecontrib/spectroscopy/io/neaspec.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/orangecontrib/spectroscopy/io/neaspec.py b/orangecontrib/spectroscopy/io/neaspec.py index 40d0b01b7..26849590b 100644 --- a/orangecontrib/spectroscopy/io/neaspec.py +++ b/orangecontrib/spectroscopy/io/neaspec.py @@ -4,12 +4,12 @@ import Orange import numpy as np import pandas as pd -from Orange.data import FileFormat, Table, Domain, ContinuousVariable +from Orange.data import FileFormat, Table from scipy.interpolate import interp1d from orangecontrib.spectroscopy.io.gsf import reader_gsf from orangecontrib.spectroscopy.io.util import SpectralFileFormat - +from orangecontrib.spectroscopy.utils import MAP_X_VAR, MAP_Y_VAR class NeaReader(FileFormat, SpectralFileFormat): @@ -593,8 +593,8 @@ def read_spectra(self): out_data = out_data.astype(np.float64) # formatting metas meta_domain = [ - Orange.data.ContinuousVariable.make("map_x"), - Orange.data.ContinuousVariable.make("map_y"), + Orange.data.ContinuousVariable.make(MAP_X_VAR), + Orange.data.ContinuousVariable.make(MAP_Y_VAR), Orange.data.ContinuousVariable.make("run"), Orange.data.StringVariable.make("channel"), ]