From d4b0b99830ef523c8626ce82aef7b2ab3c9bd65b Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Wed, 16 Jun 2021 20:00:22 +0300 Subject: [PATCH 01/14] Added first spectroscopic instruments. --- .../tools/observers/spectroscopy/__init__.py | 20 ++ .../observers/spectroscopy/instrument.py | 84 ++++++ .../observers/spectroscopy/polychromator.py | 150 ++++++++++ .../observers/spectroscopy/spectrometer.py | 273 ++++++++++++++++++ 4 files changed, 527 insertions(+) create mode 100644 cherab/tools/observers/spectroscopy/__init__.py create mode 100644 cherab/tools/observers/spectroscopy/instrument.py create mode 100644 cherab/tools/observers/spectroscopy/polychromator.py create mode 100644 cherab/tools/observers/spectroscopy/spectrometer.py diff --git a/cherab/tools/observers/spectroscopy/__init__.py b/cherab/tools/observers/spectroscopy/__init__.py new file mode 100644 index 00000000..1d8cb4cd --- /dev/null +++ b/cherab/tools/observers/spectroscopy/__init__.py @@ -0,0 +1,20 @@ + +# Copyright 2014-2017 United Kingdom Atomic Energy Authority +# +# Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the +# European Commission - subsequent versions of the EUPL (the "Licence"); +# You may not use this work except in compliance with the Licence. +# You may obtain a copy of the Licence at: +# +# https://joinup.ec.europa.eu/software/page/eupl5 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. +# +# See the Licence for the specific language governing permissions and limitations +# under the Licence. + +from .instrument import SpectroscopicInstrument +from .polychromator import PolychromatorFilter, Polychromator +from .spectrometer import Spectrometer, CzernyTurnerSpectrometer, SurveySpectrometer diff --git a/cherab/tools/observers/spectroscopy/instrument.py b/cherab/tools/observers/spectroscopy/instrument.py new file mode 100644 index 00000000..b3d6c65d --- /dev/null +++ b/cherab/tools/observers/spectroscopy/instrument.py @@ -0,0 +1,84 @@ + +# Copyright 2014-2017 United Kingdom Atomic Energy Authority +# +# Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the +# European Commission - subsequent versions of the EUPL (the "Licence"); +# You may not use this work except in compliance with the Licence. +# You may obtain a copy of the Licence at: +# +# https://joinup.ec.europa.eu/software/page/eupl5 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. +# +# See the Licence for the specific language governing permissions and limitations +# under the Licence. + +class SpectroscopicInstrument: + """ + Base class for spectroscopic instruments (spectrometers, polychromators, etc.). + This is an abstract class. + + :param str name: Instrument name. + """ + + def __init__(self, name=''): + self.name = name + self._clear_spectral_settings() + + @property + def name(self): + """ Instrument name.""" + return self._name + + @name.setter + def name(self, value): + self._name = str(value) + self._pipeline_properties = None + + @property + def pipeline_properties(self): + """ + The list of properties (class, name, filter) of the pipelines used with + this instrument. + """ + if self._pipeline_properties is None: + self._update_pipeline_properties() + + return self._pipeline_properties + + @property + def min_wavelength(self): + """ Lower wavelength bound for spectral range.""" + if self._min_wavelength is None: + self._update_spectral_settings() + + return self._min_wavelength + + @property + def max_wavelength(self): + """ Upper wavelength bound for spectral range.""" + if self._max_wavelength is None: + self._update_spectral_settings() + + return self._max_wavelength + + @property + def spectral_bins(self): + """ The number of spectral samples over the wavelength range.""" + if self._spectral_bins is None: + self._update_spectral_settings() + + return self._spectral_bins + + def _clear_spectral_settings(self): + self._min_wavelength = None + self._max_wavelength = None + self._spectral_bins = None + + def _update_spectral_settings(self): + raise NotImplementedError("To be defined in subclass.") + + def _update_pipeline_properties(self): + raise NotImplementedError("To be defined in subclass.") diff --git a/cherab/tools/observers/spectroscopy/polychromator.py b/cherab/tools/observers/spectroscopy/polychromator.py new file mode 100644 index 00000000..7b2a2374 --- /dev/null +++ b/cherab/tools/observers/spectroscopy/polychromator.py @@ -0,0 +1,150 @@ + +# Copyright 2014-2017 United Kingdom Atomic Energy Authority +# +# Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the +# European Commission - subsequent versions of the EUPL (the "Licence"); +# You may not use this work except in compliance with the Licence. +# You may obtain a copy of the Licence at: +# +# https://joinup.ec.europa.eu/software/page/eupl5 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. +# +# See the Licence for the specific language governing permissions and limitations +# under the Licence. + +import numpy as np +from raysect.optical import InterpolatedSF +from raysect.optical.observer import RadiancePipeline0D + +from .instrument import SpectroscopicInstrument + + +class PolychromatorFilter(InterpolatedSF): + """ + Defines a symmetrical trapezoidal polychromator filter as a Raysect's InterpolatedSF. + + :param float wavelength: Central wavelength of the filter in nm. + :param float window: Size of the filtering window in nm. Default is 3. + :param float flat_top: Size of the flat top part of the filter in nm. + Default is None (equal to window). + :param str name: Filter name (e.g. "H-alpha filter"). Default is ''. + + """ + + def __init__(self, wavelength, window=3., flat_top=None, name=''): + + if wavelength <= 0: + raise ValueError("Argument 'wavelength' must be positive.") + + if window <= 0: + raise ValueError("Argument 'window' must be positive.") + + flat_top = flat_top or window - 1.e-15 + + if flat_top <= 0: + raise ValueError("Argument 'flat_top' must be positive.") + if flat_top > window: + raise ValueError("Argument 'flat_top' must be less or equal than 'window'.") + if flat_top == window: + flat_top = window - 1.e-15 + + self._window = window + self._flat_top = flat_top + self._wavelength = wavelength + self._name = str(name) + + wavelengths = [wavelength - 0.5 * window, + wavelength - 0.5 * flat_top, + wavelength + 0.5 * flat_top, + wavelength + 0.5 * window] + samples = [0, 1, 1, 0] + super().__init__(wavelengths, samples, normalise=False) + + @property + def window(self): + """ Size of the filtering window in nm.""" + return self._window + + @property + def flat_top(self): + """ Size of the flat top part of the filter in nm.""" + return self._flat_top + + @property + def wavelength(self): + """ Central wavelength of the filter in nm.""" + return self._wavelength + + @property + def name(self): + """ Filter name.""" + return self._name + + +class Polychromator(SpectroscopicInstrument): + """ + A polychromator assembly with a set of different filters. + + :param list filters: List of the `PolychromatorFilter` instances. + :param int min_bins_per_window: Minimal number of spectral bins + per filtering window. Default is 10. + """ + + def __init__(self, filters, min_bins_per_window=10, name=''): + super().__init__(name) + self.min_bins_per_window = min_bins_per_window + self.filters = filters + + @property + def min_bins_per_window(self): + """ + Minimal number of spectral bins per filtering window. + """ + return self._min_bins_per_window + + @min_bins_per_window.setter + def min_bins_per_window(self, value): + + value = int(value) + if value <= 0: + raise ValueError("Attribute 'min_bins_per_window' must be positive.") + + self._min_bins_per_window = value + self._clear_spectral_settings() + + @property + def filters(self): + """ + List of the PolychromatorFilter instances. + """ + return self._filters + + @filters.setter + def filters(self, value): + for poly_filter in value: + if not isinstance(poly_filter, PolychromatorFilter): + raise TypeError('Property filters must contain only PolychromatorFilter instances.') + + self._filters = value + self._clear_spectral_settings() + self._pipeline_properties = None + + def _update_pipeline_properties(self): + self._pipeline_properties = [(RadiancePipeline0D, self._name + ': ' + poly_filter.name, poly_filter) for poly_filter in self._filters] + + def _update_spectral_settings(self): + + min_wavelength = np.inf + max_wavelength = 0 + step = np.inf + for poly_filter in self._filters: + step = min(step, poly_filter.window / self._min_bins_per_window) + min_wavelength = min(min_wavelength, poly_filter.wavelength - 0.5 * poly_filter.window) + max_wavelength = max(max_wavelength, poly_filter.wavelength + 0.5 * poly_filter.window) + + self._min_wavelength = min_wavelength + self._max_wavelength = max_wavelength + self._spectral_bins = int(np.ceil((max_wavelength - min_wavelength) / step)) diff --git a/cherab/tools/observers/spectroscopy/spectrometer.py b/cherab/tools/observers/spectroscopy/spectrometer.py new file mode 100644 index 00000000..74e098c4 --- /dev/null +++ b/cherab/tools/observers/spectroscopy/spectrometer.py @@ -0,0 +1,273 @@ + +# Copyright 2014-2017 United Kingdom Atomic Energy Authority +# +# Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the +# European Commission - subsequent versions of the EUPL (the "Licence"); +# You may not use this work except in compliance with the Licence. +# You may obtain a copy of the Licence at: +# +# https://joinup.ec.europa.eu/software/page/eupl5 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. +# +# See the Licence for the specific language governing permissions and limitations +# under the Licence. + +import numpy as np +from raysect.optical.observer import SpectralRadiancePipeline0D + +from .instrument import SpectroscopicInstrument + + +class Spectrometer(SpectroscopicInstrument): + """ + Spectrometer base class. + This is an abstract class. + + :param int spectral_bins: The number of spectral samples over the wavelength range. + :param float reference_wavelength: Wavelength (in nm) corresponding to + the centre of reference bin. + :param int reference_bin: Reference bin index. Can be negative to specify the offset. + Default is None (spectral_bins // 2). + :param str name: Spectrometer name. + """ + + def __init__(self, spectral_bins, reference_wavelength, reference_bin=None, name=''): + super().__init__(name) + self.spectral_bins = spectral_bins + if reference_bin is None: + self.reference_bin = self._spectral_bins // 2 + else: + self.reference_bin = reference_bin + self.reference_wavelength = reference_wavelength + + @property + def spectral_bins(self): + """ + The number of spectral samples over the wavelength range. + """ + return self._spectral_bins + + @spectral_bins.setter + def spectral_bins(self, value): + + value = int(value) + if value <= 0: + raise ValueError("Attribute 'spectral_bins' must be > 0.") + + self._spectral_bins = value + self._clear_spectral_settings() + + @property + def reference_wavelength(self): + """ + Wavelength (in nm) corresponding to the centre of reference bin. + """ + return self._reference_wavelength + + @reference_wavelength.setter + def reference_wavelength(self, value): + + if value <= 0: + raise ValueError("Attribute 'reference_wavelength' must be > 0.") + + self._reference_wavelength = value + self._clear_spectral_settings() + + @property + def reference_bin(self): + """ + Reference bin index. + """ + return self._reference_bin + + @reference_bin.setter + def reference_bin(self, value): + + value = int(value) + + self._reference_bin = value + self._clear_spectral_settings() + + def _update_pipeline_properties(self): + self._pipeline_properties = [(SpectralRadiancePipeline0D, self._name, None)] + + def _clear_spectral_settings(self): + self._min_wavelength = None + self._max_wavelength = None + + +class SurveySpectrometer(Spectrometer): + """ + Survey spectrometer with a constant spectral resolution. + + Note: survey spectrometers usually have non-constant spectral resolution + in the supported wavelength range. However, Raysect does not support + the observers with variable spectral resolution. + + :param float resolution: Spectral resolution in nm (can be negative). + :param int spectral_bins: The number of spectral samples over the wavelength range. + :param float reference_wavelength: Wavelength (in nm) corresponding to + the centre of reference bin. + :param int reference_bin: Reference bin index. Can be negative to specify the offset. + Default is None (spectral_bins // 2). + :param str name: Spectrometer name. + """ + + def __init__(self, resolution, spectral_bins, reference_wavelength, reference_bin=None, name=''): + super().__init__(spectral_bins, reference_wavelength, reference_bin, name) + self.resolution = resolution + + @property + def resolution(self): + """ + Spectrometer resolution. + """ + return self._resolution + + @resolution.setter + def resolution(self, value): + """ + Spectral resolution in nm (can be negative). + """ + if value == 0: + raise ValueError("Attribute 'resolution' must be non-zero.") + + self._resolution = value + self._clear_spectral_settings() + + def _update_spectral_settings(self): + + if self._resolution > 0: + self._min_wavelength = self._reference_wavelength - (self._reference_bin + 0.5) * self._resolution + self._max_wavelength = self._min_wavelength + self._spectral_bins * self._resolution + else: + self._min_wavelength = self._reference_wavelength + (self._spectral_bins - self._reference_bin - 0.5) * self._resolution + self._max_wavelength = self._min_wavelength - self._spectral_bins * self._resolution + + +class CzernyTurnerSpectrometer(Spectrometer): + """ + Czerny-Turner high-resolution spectrometer. + + :param int diffraction_order: Diffraction order. + :param float grating: Diffraction grating in nm-1. + :param float focal_length: Focal length in nm. + :param float pixel_spacing: Pixel to pixel spacing on CCD in nm. + :param float diffraction_angle: Angle between incident and diffracted light in degrees. + :param int spectral_bins: The number of spectral samples over the wavelength range. + :param float reference_wavelength: Wavelength (in nm) corresponding to + the centre of reference bin. + :param int reference_bin: Reference bin index. Default is None (spectral_bins // 2). + :param str name: Spectrometer name. + """ + + def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diffraction_angle, spectral_bins, + reference_wavelength, reference_bin=None, name=''): + super().__init__(spectral_bins, reference_wavelength, reference_bin, name) + self.diffraction_order = diffraction_order + self.grating = grating + self.focal_length = focal_length + self.pixel_spacing = pixel_spacing + self.diffraction_angle = diffraction_angle + + @property + def diffraction_order(self): + """ Diffraction order.""" + return self._diffraction_order + + @diffraction_order.setter + def diffraction_order(self, value): + + value = int(value) + if value <= 0: + raise ValueError("Attribute 'diffraction_order' must be positive.") + + self._diffraction_order = value + self._clear_spectral_settings() + + @property + def grating(self): + """ Diffraction grating in nm-1.""" + return self._grating + + @grating.setter + def grating(self, value): + + if value <= 0: + raise ValueError("Attribute 'grating' must be positive.") + + self._grating = value + self._clear_spectral_settings() + + @property + def focal_length(self): + """ Focal length in nm.""" + return self._focal_length + + @focal_length.setter + def focal_length(self, value): + + if value <= 0: + raise ValueError("Attribute 'focal_length' must be positive.") + + self._focal_length = value + self._clear_spectral_settings() + + @property + def pixel_spacing(self): + """ Pixel to pixel spacing on CCD in nm.""" + return self._pixel_spacing + + @pixel_spacing.setter + def pixel_spacing(self, value): + + if value == 0: + raise ValueError("Attribute 'pixel_spacing' must be non-zero.") + + self._pixel_spacing = value + self._clear_spectral_settings() + + @property + def diffraction_angle(self): + """ Angle between incident and diffracted light in degrees.""" + return np.rad2deg(self._diffraction_angle) + + @diffraction_angle.setter + def diffraction_angle(self, value): + + if value <= 0: + raise ValueError("Attribute 'diffraction_angle' must be positive.") + + self._diffraction_angle = np.deg2rad(value) + self._clear_spectral_settings() + + def _update_spectral_settings(self): + + resolution = self.resolution() + + if resolution > 0: + self._min_wavelength = self._reference_wavelength - (self._reference_bin + 0.5) * resolution + self._max_wavelength = self._min_wavelength + self._spectral_bins * resolution + else: + self._min_wavelength = self._reference_wavelength + (self._spectral_bins - self._reference_bin - 0.5) * resolution + self._max_wavelength = self._min_wavelength - self._spectral_bins * resolution + + def resolution(self): + """ + Calculates spectral resolution in nm. + + :return: resolution + """ + grating = self._grating + m = self._diffraction_order + dxdp = self._pixel_spacing + angle = self._diffraction_angle + fl = self._focal_length + + p = 0.5 * m * grating * self._reference_wavelength + resolution = dxdp * (np.sqrt(np.cos(angle)**2 - p * p) - p * np.tan(angle)) / (m * fl * grating) + + return resolution From f63798976a964455f92fcd247451feb5a763ab9c Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Wed, 16 Jun 2021 21:33:18 +0300 Subject: [PATCH 02/14] Made 'resolution' a property of CzernyTurnerSpectrometer. --- .../observers/spectroscopy/spectrometer.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/cherab/tools/observers/spectroscopy/spectrometer.py b/cherab/tools/observers/spectroscopy/spectrometer.py index 74e098c4..fcbe31cf 100644 --- a/cherab/tools/observers/spectroscopy/spectrometer.py +++ b/cherab/tools/observers/spectroscopy/spectrometer.py @@ -244,22 +244,10 @@ def diffraction_angle(self, value): self._diffraction_angle = np.deg2rad(value) self._clear_spectral_settings() - def _update_spectral_settings(self): - - resolution = self.resolution() - - if resolution > 0: - self._min_wavelength = self._reference_wavelength - (self._reference_bin + 0.5) * resolution - self._max_wavelength = self._min_wavelength + self._spectral_bins * resolution - else: - self._min_wavelength = self._reference_wavelength + (self._spectral_bins - self._reference_bin - 0.5) * resolution - self._max_wavelength = self._min_wavelength - self._spectral_bins * resolution - + @property def resolution(self): """ - Calculates spectral resolution in nm. - - :return: resolution + Spectral resolution in nm. """ grating = self._grating m = self._diffraction_order @@ -268,6 +256,17 @@ def resolution(self): fl = self._focal_length p = 0.5 * m * grating * self._reference_wavelength - resolution = dxdp * (np.sqrt(np.cos(angle)**2 - p * p) - p * np.tan(angle)) / (m * fl * grating) + _resolution = dxdp * (np.sqrt(np.cos(angle)**2 - p * p) - p * np.tan(angle)) / (m * fl * grating) + + return _resolution + + def _update_spectral_settings(self): + + resolution = self.resolution - return resolution + if resolution > 0: + self._min_wavelength = self._reference_wavelength - (self._reference_bin + 0.5) * resolution + self._max_wavelength = self._min_wavelength + self._spectral_bins * resolution + else: + self._min_wavelength = self._reference_wavelength + (self._spectral_bins - self._reference_bin - 0.5) * resolution + self._max_wavelength = self._min_wavelength - self._spectral_bins * resolution From 439f5b286f03797eff1dea2693dca36d28e06c5a Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Mon, 26 Jul 2021 18:10:21 +0300 Subject: [PATCH 03/14] Added Sphinx documentation and unit tests for spectroscopic instruments. --- .../tests/test_spectroscopic_instruments.py | 145 ++++++++++++++++++ docs/source/tools/observers.rst | 31 ++++ 2 files changed, 176 insertions(+) create mode 100644 cherab/tools/tests/test_spectroscopic_instruments.py diff --git a/cherab/tools/tests/test_spectroscopic_instruments.py b/cherab/tools/tests/test_spectroscopic_instruments.py new file mode 100644 index 00000000..d79b6445 --- /dev/null +++ b/cherab/tools/tests/test_spectroscopic_instruments.py @@ -0,0 +1,145 @@ +# Copyright 2016-2021 Euratom +# Copyright 2016-2021 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Centro de Investigaciones Energéticas, Medioambientales y Tecnológicas +# +# Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the +# European Commission - subsequent versions of the EUPL (the "Licence"); +# You may not use this work except in compliance with the Licence. +# You may obtain a copy of the Licence at: +# +# https://joinup.ec.europa.eu/software/page/eupl5 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. +# +# See the Licence for the specific language governing permissions and limitations +# under the Licence. + +import unittest +import numpy as np + +from raysect.optical.observer.pipeline import RadiancePipeline0D, SpectralRadiancePipeline0D +from cherab.tools.observers.spectroscopy import PolychromatorFilter, Polychromator, CzernyTurnerSpectrometer, SurveySpectrometer + + +class TestPolychromatorFilter(unittest.TestCase): + """ + Test for PolychromatorFilter class. + """ + + def test_spectrum(self): + wavelength = 500. + window = 6. + flat_top = 2. + poly_filter = PolychromatorFilter(wavelength, window, flat_top, 'test_filter') + wavelengths = np.linspace(496., 504., 9) + spectrum_true = np.array([0, 0, 0.5, 1., 1., 1., 0.5, 0, 0]) + spectrum_test = np.array([poly_filter(wvl) for wvl in wavelengths]) + self.assertTrue(np.all(spectrum_true == spectrum_test)) + + +class TestPolychromator(unittest.TestCase): + """ + Test cases for Polychromator class. + """ + + def setUp(self): + self.poly_filters_default = [PolychromatorFilter(400., 6., 2., 'filter 1'), + PolychromatorFilter(700., 8., 4., 'filter 2')] + self.min_bins_per_window_default = 10 + + def test_pipeline_properties(self): + polychromator = Polychromator(self.poly_filters_default, self.min_bins_per_window_default, 'test polychromator') + pipeline_properties_true = [(RadiancePipeline0D, 'test polychromator: filter 1', self.poly_filters_default[0]), + (RadiancePipeline0D, 'test polychromator: filter 2', self.poly_filters_default[1])] + self.assertSequenceEqual(pipeline_properties_true, polychromator.pipeline_properties) + + def test_spectral_properties(self): + polychromator = Polychromator(self.poly_filters_default, self.min_bins_per_window_default) + min_wavelength_true = 397. + max_wavelength_true = 704. + spectral_bins_true = 512 + self.assertTrue(polychromator.min_wavelength == min_wavelength_true and + polychromator.max_wavelength == max_wavelength_true and + polychromator.spectral_bins == spectral_bins_true) + + def test_filter_change(self): + """ Checks if the spectral properties are updated correctly when the filters are replaced.""" + polychromator = Polychromator(self.poly_filters_default, self.min_bins_per_window_default) + polychromator.min_bins_per_window = 20 + polychromator.filters = [PolychromatorFilter(500., 5., 2., 'filter 1'), + PolychromatorFilter(600., 7., 4., 'filter 2')] + min_wavelength_true = 497.5 + max_wavelength_true = 603.5 + spectral_bins_true = 424 + self.assertTrue(polychromator.min_wavelength == min_wavelength_true and + polychromator.max_wavelength == max_wavelength_true and + polychromator.spectral_bins == spectral_bins_true) + + +class TestSurveySpectrometer(unittest.TestCase): + """ + Test cases for SurveySpectrometer class. + """ + + def test_pipeline_properties(self): + resolution = 0.1 + reference_wavelength = 500 + reference_bin = 50 + spectral_bins = 200 + spectrometer = SurveySpectrometer(resolution, spectral_bins, reference_wavelength, reference_bin, name='test spectrometer') + pipeline_properties_true = [(SpectralRadiancePipeline0D, 'test spectrometer', None)] + self.assertSequenceEqual(pipeline_properties_true, spectrometer.pipeline_properties) + + def test_spectral_properties(self): + resolution = 0.1 + reference_wavelength = 500 + reference_bin = 50 + spectral_bins = 200 + spectrometer = SurveySpectrometer(resolution, spectral_bins, reference_wavelength, reference_bin, name='test spectrometer') + min_wavelength_true = 494.95 + max_wavelength_true = 514.95 + self.assertTrue(spectrometer.min_wavelength == min_wavelength_true and + spectrometer.max_wavelength == max_wavelength_true) + + +class TestCzernyTurnerSpectrometer(unittest.TestCase): + """ + Test cases for CzernyTurnerSpectrometer class. + """ + + def setUp(self): + self.diffraction_order = 1 + self.grating = 2.e-3 + self.focal_length = 1.e9 + self.pixel_spacing = 2.e4 + self.diffraction_angle = 10. + self.spectral_bins = 512 + self.reference_bin = 255 + + def test_resolution(self): + wavelengths = [350., 550., 750.] + resolutions_true = np.array([8.587997e-3, 7.199328e-3, 5.0599164e-3]) + spectrometer = CzernyTurnerSpectrometer(self.diffraction_order, self.grating, self.focal_length, self.pixel_spacing, + self.diffraction_angle, self.spectral_bins, 500., self.reference_bin, + name='test spectrometer') + resolutions = [] + for wvl in wavelengths: + spectrometer.reference_wavelength = wvl + resolutions.append(spectrometer.resolution) + self.assertTrue(np.all(np.abs(resolutions / resolutions_true - 1.) < 1.e-7)) + + def test_spectral_properties(self): + wavelength = 500. + min_wavelength_true = 498.0575 + max_wavelength_true = 501.9501 + spectrometer = CzernyTurnerSpectrometer(self.diffraction_order, self.grating, self.focal_length, self.pixel_spacing, + self.diffraction_angle, self.spectral_bins, wavelength, self.reference_bin, + name='test spectrometer') + self.assertTrue(abs(spectrometer.min_wavelength - min_wavelength_true) < 1.e-4 and + abs(spectrometer.max_wavelength - max_wavelength_true) < 1.e-4) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/source/tools/observers.rst b/docs/source/tools/observers.rst index 57dd5636..493bab2b 100644 --- a/docs/source/tools/observers.rst +++ b/docs/source/tools/observers.rst @@ -61,3 +61,34 @@ bolometer etendue :math:`G`, which is given by: .. autoclass:: cherab.tools.observers.bolometry.BolometerFoil :members: + + +.. _observers_spectroscopic_instruments: + +Spectroscopic instruments +------------------------- + +Spectroscopic instruments such as polychromators, survey and high-resolution spectrometers +simplify the setup of rendering pipelines and observers' spectral properties. The Cherab core +package provides base classes for spectroscopic instruments, so machine-specific packages +can build more advance instruments from them, such as instruments with spectral properties +based on the actual experimental setup for a given shot/pulse. + +.. autoclass:: cherab.tools.observers.spectroscopy.SpectroscopicInstrument + :members: + +.. autoclass:: cherab.tools.observers.spectroscopy.PolychromatorFilter + :members: + +.. autoclass:: cherab.tools.observers.spectroscopy.Polychromator + :members: + +.. autoclass:: cherab.tools.observers.spectroscopy.Spectrometer + :members: + +.. autoclass:: cherab.tools.observers.spectroscopy.SurveySpectrometer + :members: + +.. autoclass:: cherab.tools.observers.spectroscopy.CzernyTurnerSpectrometer + :members: + From 829b231dc2fc1e478d9b587d5ac3ad3bce754218 Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Thu, 29 Jul 2021 23:35:29 +0300 Subject: [PATCH 04/14] Aligned the docs for spectroscopic instruments with the style used in Cherab. --- .../observers/spectroscopy/instrument.py | 20 +++++---- .../observers/spectroscopy/polychromator.py | 18 +++----- .../observers/spectroscopy/spectrometer.py | 45 ++++++------------- 3 files changed, 33 insertions(+), 50 deletions(-) diff --git a/cherab/tools/observers/spectroscopy/instrument.py b/cherab/tools/observers/spectroscopy/instrument.py index b3d6c65d..a2a59f60 100644 --- a/cherab/tools/observers/spectroscopy/instrument.py +++ b/cherab/tools/observers/spectroscopy/instrument.py @@ -21,6 +21,12 @@ class SpectroscopicInstrument: This is an abstract class. :param str name: Instrument name. + + :ivar list pipeline_properties: The list of properties (class, name, filter) of + the pipelines used with this instrument. + :ivar float min_wavelength: Lower wavelength bound for spectral range. + :ivar float max_wavelength: Upper wavelength bound for spectral range. + :ivar int spectral_bins: The number of spectral samples over the wavelength range. """ def __init__(self, name=''): @@ -29,7 +35,7 @@ def __init__(self, name=''): @property def name(self): - """ Instrument name.""" + # Instrument name. return self._name @name.setter @@ -39,10 +45,8 @@ def name(self, value): @property def pipeline_properties(self): - """ - The list of properties (class, name, filter) of the pipelines used with - this instrument. - """ + # The list of properties (class, name, filter) of the pipelines used with + # this instrument. if self._pipeline_properties is None: self._update_pipeline_properties() @@ -50,7 +54,7 @@ def pipeline_properties(self): @property def min_wavelength(self): - """ Lower wavelength bound for spectral range.""" + # Lower wavelength bound for spectral range. if self._min_wavelength is None: self._update_spectral_settings() @@ -58,7 +62,7 @@ def min_wavelength(self): @property def max_wavelength(self): - """ Upper wavelength bound for spectral range.""" + # Upper wavelength bound for spectral range. if self._max_wavelength is None: self._update_spectral_settings() @@ -66,7 +70,7 @@ def max_wavelength(self): @property def spectral_bins(self): - """ The number of spectral samples over the wavelength range.""" + # The number of spectral samples over the wavelength range. if self._spectral_bins is None: self._update_spectral_settings() diff --git a/cherab/tools/observers/spectroscopy/polychromator.py b/cherab/tools/observers/spectroscopy/polychromator.py index 7b2a2374..789a66f8 100644 --- a/cherab/tools/observers/spectroscopy/polychromator.py +++ b/cherab/tools/observers/spectroscopy/polychromator.py @@ -65,22 +65,22 @@ def __init__(self, wavelength, window=3., flat_top=None, name=''): @property def window(self): - """ Size of the filtering window in nm.""" + # Size of the filtering window in nm. return self._window @property def flat_top(self): - """ Size of the flat top part of the filter in nm.""" + # Size of the flat top part of the filter in nm. return self._flat_top @property def wavelength(self): - """ Central wavelength of the filter in nm.""" + # Central wavelength of the filter in nm. return self._wavelength @property def name(self): - """ Filter name.""" + # Filter name. return self._name @@ -91,6 +91,7 @@ class Polychromator(SpectroscopicInstrument): :param list filters: List of the `PolychromatorFilter` instances. :param int min_bins_per_window: Minimal number of spectral bins per filtering window. Default is 10. + :param str name: Polychromator name. """ def __init__(self, filters, min_bins_per_window=10, name=''): @@ -100,14 +101,11 @@ def __init__(self, filters, min_bins_per_window=10, name=''): @property def min_bins_per_window(self): - """ - Minimal number of spectral bins per filtering window. - """ + # Minimal number of spectral bins per filtering window. return self._min_bins_per_window @min_bins_per_window.setter def min_bins_per_window(self, value): - value = int(value) if value <= 0: raise ValueError("Attribute 'min_bins_per_window' must be positive.") @@ -117,9 +115,7 @@ def min_bins_per_window(self, value): @property def filters(self): - """ - List of the PolychromatorFilter instances. - """ + # List of the PolychromatorFilter instances. return self._filters @filters.setter diff --git a/cherab/tools/observers/spectroscopy/spectrometer.py b/cherab/tools/observers/spectroscopy/spectrometer.py index fcbe31cf..99bd5425 100644 --- a/cherab/tools/observers/spectroscopy/spectrometer.py +++ b/cherab/tools/observers/spectroscopy/spectrometer.py @@ -45,14 +45,11 @@ def __init__(self, spectral_bins, reference_wavelength, reference_bin=None, name @property def spectral_bins(self): - """ - The number of spectral samples over the wavelength range. - """ + # The number of spectral samples over the wavelength range. return self._spectral_bins @spectral_bins.setter def spectral_bins(self, value): - value = int(value) if value <= 0: raise ValueError("Attribute 'spectral_bins' must be > 0.") @@ -62,14 +59,11 @@ def spectral_bins(self, value): @property def reference_wavelength(self): - """ - Wavelength (in nm) corresponding to the centre of reference bin. - """ + # Wavelength (in nm) corresponding to the centre of reference bin. return self._reference_wavelength @reference_wavelength.setter def reference_wavelength(self, value): - if value <= 0: raise ValueError("Attribute 'reference_wavelength' must be > 0.") @@ -78,14 +72,11 @@ def reference_wavelength(self, value): @property def reference_bin(self): - """ - Reference bin index. - """ + # Reference bin index. return self._reference_bin @reference_bin.setter def reference_bin(self, value): - value = int(value) self._reference_bin = value @@ -114,6 +105,8 @@ class SurveySpectrometer(Spectrometer): :param int reference_bin: Reference bin index. Can be negative to specify the offset. Default is None (spectral_bins // 2). :param str name: Spectrometer name. + + :ivar float resolution: Spectral resolution in nm (can be negative). """ def __init__(self, resolution, spectral_bins, reference_wavelength, reference_bin=None, name=''): @@ -122,16 +115,11 @@ def __init__(self, resolution, spectral_bins, reference_wavelength, reference_bi @property def resolution(self): - """ - Spectrometer resolution. - """ + # Spectral resolution in nm (can be negative). return self._resolution @resolution.setter def resolution(self, value): - """ - Spectral resolution in nm (can be negative). - """ if value == 0: raise ValueError("Attribute 'resolution' must be non-zero.") @@ -162,6 +150,8 @@ class CzernyTurnerSpectrometer(Spectrometer): the centre of reference bin. :param int reference_bin: Reference bin index. Default is None (spectral_bins // 2). :param str name: Spectrometer name. + + :ivar float resolution: Spectral resolution in nm (can be negative). """ def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diffraction_angle, spectral_bins, @@ -175,12 +165,11 @@ def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diff @property def diffraction_order(self): - """ Diffraction order.""" + # Diffraction order. return self._diffraction_order @diffraction_order.setter def diffraction_order(self, value): - value = int(value) if value <= 0: raise ValueError("Attribute 'diffraction_order' must be positive.") @@ -190,12 +179,11 @@ def diffraction_order(self, value): @property def grating(self): - """ Diffraction grating in nm-1.""" + # Diffraction grating in nm-1. return self._grating @grating.setter def grating(self, value): - if value <= 0: raise ValueError("Attribute 'grating' must be positive.") @@ -204,12 +192,11 @@ def grating(self, value): @property def focal_length(self): - """ Focal length in nm.""" + # Focal length in nm. return self._focal_length @focal_length.setter def focal_length(self, value): - if value <= 0: raise ValueError("Attribute 'focal_length' must be positive.") @@ -218,12 +205,11 @@ def focal_length(self, value): @property def pixel_spacing(self): - """ Pixel to pixel spacing on CCD in nm.""" + # Pixel to pixel spacing on CCD in nm. return self._pixel_spacing @pixel_spacing.setter def pixel_spacing(self, value): - if value == 0: raise ValueError("Attribute 'pixel_spacing' must be non-zero.") @@ -232,12 +218,11 @@ def pixel_spacing(self, value): @property def diffraction_angle(self): - """ Angle between incident and diffracted light in degrees.""" + # Angle between incident and diffracted light in degrees. return np.rad2deg(self._diffraction_angle) @diffraction_angle.setter def diffraction_angle(self, value): - if value <= 0: raise ValueError("Attribute 'diffraction_angle' must be positive.") @@ -246,9 +231,7 @@ def diffraction_angle(self, value): @property def resolution(self): - """ - Spectral resolution in nm. - """ + # Spectral resolution in nm (can be negative). grating = self._grating m = self._diffraction_order dxdp = self._pixel_spacing From b674e01b72daea43675c5fe15516c9150021cf6b Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Tue, 3 Aug 2021 20:33:38 +0300 Subject: [PATCH 05/14] Moved spectroscopic instruments from 'observers' to 'spectroscopy' submodule. Added code examples to docstrings. Added 'pipelines' property. --- .../{observers => }/spectroscopy/__init__.py | 4 +- .../spectroscopy/instrument.py | 17 ++++++- .../spectroscopy/polychromator.py | 21 ++++++++- .../spectroscopy/spectrometer.py | 45 ++++++++++++++++++- .../tests/test_spectroscopic_instruments.py | 2 +- docs/source/tools/observers.rst | 30 ------------- docs/source/tools/spectroscopy.rst | 38 ++++++++++++++++ docs/source/tools/tools.rst | 1 + 8 files changed, 122 insertions(+), 36 deletions(-) rename cherab/tools/{observers => }/spectroscopy/__init__.py (81%) rename cherab/tools/{observers => }/spectroscopy/instrument.py (80%) rename cherab/tools/{observers => }/spectroscopy/polychromator.py (83%) rename cherab/tools/{observers => }/spectroscopy/spectrometer.py (78%) create mode 100644 docs/source/tools/spectroscopy.rst diff --git a/cherab/tools/observers/spectroscopy/__init__.py b/cherab/tools/spectroscopy/__init__.py similarity index 81% rename from cherab/tools/observers/spectroscopy/__init__.py rename to cherab/tools/spectroscopy/__init__.py index 1d8cb4cd..207aaf87 100644 --- a/cherab/tools/observers/spectroscopy/__init__.py +++ b/cherab/tools/spectroscopy/__init__.py @@ -1,5 +1,7 @@ -# Copyright 2014-2017 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Euratom +# Copyright 2016-2021 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Centro de Investigaciones Energéticas, Medioambientales y Tecnológicas # # Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the # European Commission - subsequent versions of the EUPL (the "Licence"); diff --git a/cherab/tools/observers/spectroscopy/instrument.py b/cherab/tools/spectroscopy/instrument.py similarity index 80% rename from cherab/tools/observers/spectroscopy/instrument.py rename to cherab/tools/spectroscopy/instrument.py index a2a59f60..fc0409b1 100644 --- a/cherab/tools/observers/spectroscopy/instrument.py +++ b/cherab/tools/spectroscopy/instrument.py @@ -1,5 +1,7 @@ -# Copyright 2014-2017 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Euratom +# Copyright 2016-2021 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Centro de Investigaciones Energéticas, Medioambientales y Tecnológicas # # Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the # European Commission - subsequent versions of the EUPL (the "Licence"); @@ -24,6 +26,7 @@ class SpectroscopicInstrument: :ivar list pipeline_properties: The list of properties (class, name, filter) of the pipelines used with this instrument. + :ivar list pipelines: The list of pipelines. Each call returns a list with new instances. :ivar float min_wavelength: Lower wavelength bound for spectral range. :ivar float max_wavelength: Upper wavelength bound for spectral range. :ivar int spectral_bins: The number of spectral samples over the wavelength range. @@ -52,6 +55,18 @@ def pipeline_properties(self): return self._pipeline_properties + @property + def pipelines(self): + # The list of pipelines. Each call returns a list with new instances. + pl_list = [] + for (pl_class, pl_name, pl_filter) in self.pipeline_properties: + if pl_filter is None: + pl_list.append(pl_class(name=pl_name)) + else: + pl_list.append(pl_class(name=pl_name, filter=pl_filter)) + + return pl_list + @property def min_wavelength(self): # Lower wavelength bound for spectral range. diff --git a/cherab/tools/observers/spectroscopy/polychromator.py b/cherab/tools/spectroscopy/polychromator.py similarity index 83% rename from cherab/tools/observers/spectroscopy/polychromator.py rename to cherab/tools/spectroscopy/polychromator.py index 789a66f8..2d13a898 100644 --- a/cherab/tools/observers/spectroscopy/polychromator.py +++ b/cherab/tools/spectroscopy/polychromator.py @@ -1,5 +1,7 @@ -# Copyright 2014-2017 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Euratom +# Copyright 2016-2021 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Centro de Investigaciones Energéticas, Medioambientales y Tecnológicas # # Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the # European Commission - subsequent versions of the EUPL (the "Licence"); @@ -31,7 +33,6 @@ class PolychromatorFilter(InterpolatedSF): :param float flat_top: Size of the flat top part of the filter in nm. Default is None (equal to window). :param str name: Filter name (e.g. "H-alpha filter"). Default is ''. - """ def __init__(self, wavelength, window=3., flat_top=None, name=''): @@ -92,6 +93,22 @@ class Polychromator(SpectroscopicInstrument): :param int min_bins_per_window: Minimal number of spectral bins per filtering window. Default is 10. :param str name: Polychromator name. + + .. code-block:: pycon + + >>> from raysect.optical import World + >>> from raysect.optical.observer import FibreOptic + >>> from cherab.tools.spectroscopy import Polychromator, PolychromatorFilter + >>> + >>> world = World() + >>> h_alpha_filter = PolychromatorFilter(656.1, name='H-alpha filter') + >>> ciii_465nm_filter = PolychromatorFilter(464.8, name='CIII 465 nm filter') + >>> polychromator = Polychromator([h_alpha_filter, ciii_465nm_filter], name='MyPolychromator') + >>> fibreoptic = FibreOptic(name="MyFibreOptic", parent=world) + >>> fibreoptic.min_wavelength = polychromator.min_wavelength + >>> fibreoptic.max_wavelength = polychromator.max_wavelength + >>> fibreoptic.spectral_bins = polychromator.spectral_bins + >>> fibreoptic.pipelines = polychromator.pipelines """ def __init__(self, filters, min_bins_per_window=10, name=''): diff --git a/cherab/tools/observers/spectroscopy/spectrometer.py b/cherab/tools/spectroscopy/spectrometer.py similarity index 78% rename from cherab/tools/observers/spectroscopy/spectrometer.py rename to cherab/tools/spectroscopy/spectrometer.py index 99bd5425..e982918e 100644 --- a/cherab/tools/observers/spectroscopy/spectrometer.py +++ b/cherab/tools/spectroscopy/spectrometer.py @@ -1,5 +1,7 @@ -# Copyright 2014-2017 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Euratom +# Copyright 2016-2021 United Kingdom Atomic Energy Authority +# Copyright 2016-2021 Centro de Investigaciones Energéticas, Medioambientales y Tecnológicas # # Licensed under the EUPL, Version 1.1 or – as soon they will be approved by the # European Commission - subsequent versions of the EUPL (the "Licence"); @@ -107,6 +109,32 @@ class SurveySpectrometer(Spectrometer): :param str name: Spectrometer name. :ivar float resolution: Spectral resolution in nm (can be negative). + + .. code-block:: pycon + + >>> from numpy import ceil + >>> from raysect.optical import World + >>> from raysect.optical.observer import FibreOptic + >>> from cherab.tools.spectroscopy import SurveySpectrometer, Polychromator, PolychromatorFilter + >>> + >>> world = World() + >>> fibreoptic = FibreOptic(name="MyFibreOptic", parent=world) + >>> # Here, the fibre optic is "connected" to both survey spectrometer and polychromator. + >>> + >>> # setting up the polychromator + >>> h_alpha_filter = PolychromatorFilter(656.1, name='H-alpha filter') + >>> ciii_465nm_filter = PolychromatorFilter(464.8, name='CIII 465 nm filter') + >>> polychromator = Polychromator([h_alpha_filter, ciii_465nm_filter], name='MyPolychromator') + >>> + >>> # setting up the survey spectrometer + >>> spectrometer = SurveySpectrometer(0.1, 1024, 500, name='MySpectrometer') + >>> + >>> fibreoptic.min_wavelength = min(spectrometer.min_wavelength, polychromator.min_wavelength) + >>> fibreoptic.max_wavelength = max(spectrometer.max_wavelength, polychromator.max_wavelength) + >>> bin_width = min((spectrometer.max_wavelength - spectrometer.min_wavelength) / spectrometer.spectral_bins, + >>> (polychromator.max_wavelength - polychromator.min_wavelength) / polychromator.spectral_bins) + >>> fibreoptic.spectral_bins = int(ceil((fibreoptic.max_wavelength - fibreoptic.min_wavelength) / bin_width)) + >>> fibreoptic.pipelines = spectrometer.pipelines + polychromator.pipelines """ def __init__(self, resolution, spectral_bins, reference_wavelength, reference_bin=None, name=''): @@ -152,6 +180,21 @@ class CzernyTurnerSpectrometer(Spectrometer): :param str name: Spectrometer name. :ivar float resolution: Spectral resolution in nm (can be negative). + + .. code-block:: pycon + + >>> from raysect.optical import World + >>> from raysect.optical.observer import FibreOptic + >>> from cherab.tools.spectroscopy import CzernyTurnerSpectrometer + >>> + >>> world = World() + >>> hires_spectrometer = CzernyTurnerSpectrometer(1, 2.e-3, 1.e9, 2.e4, 10., 512, 600., + >>> name='MySpectrometer') + >>> fibreoptic = FibreOptic(name="MyFibreOptic", parent=world) + >>> fibreoptic.min_wavelength = hires_spectrometer.min_wavelength + >>> fibreoptic.max_wavelength = hires_spectrometer.max_wavelength + >>> fibreoptic.spectral_bins = hires_spectrometer.spectral_bins + >>> fibreoptic.pipelines = hires_spectrometer.pipelines """ def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diffraction_angle, spectral_bins, diff --git a/cherab/tools/tests/test_spectroscopic_instruments.py b/cherab/tools/tests/test_spectroscopic_instruments.py index d79b6445..883dc8c6 100644 --- a/cherab/tools/tests/test_spectroscopic_instruments.py +++ b/cherab/tools/tests/test_spectroscopic_instruments.py @@ -20,7 +20,7 @@ import numpy as np from raysect.optical.observer.pipeline import RadiancePipeline0D, SpectralRadiancePipeline0D -from cherab.tools.observers.spectroscopy import PolychromatorFilter, Polychromator, CzernyTurnerSpectrometer, SurveySpectrometer +from cherab.tools.spectroscopy import PolychromatorFilter, Polychromator, CzernyTurnerSpectrometer, SurveySpectrometer class TestPolychromatorFilter(unittest.TestCase): diff --git a/docs/source/tools/observers.rst b/docs/source/tools/observers.rst index 493bab2b..3feda8d0 100644 --- a/docs/source/tools/observers.rst +++ b/docs/source/tools/observers.rst @@ -62,33 +62,3 @@ bolometer etendue :math:`G`, which is given by: .. autoclass:: cherab.tools.observers.bolometry.BolometerFoil :members: - -.. _observers_spectroscopic_instruments: - -Spectroscopic instruments -------------------------- - -Spectroscopic instruments such as polychromators, survey and high-resolution spectrometers -simplify the setup of rendering pipelines and observers' spectral properties. The Cherab core -package provides base classes for spectroscopic instruments, so machine-specific packages -can build more advance instruments from them, such as instruments with spectral properties -based on the actual experimental setup for a given shot/pulse. - -.. autoclass:: cherab.tools.observers.spectroscopy.SpectroscopicInstrument - :members: - -.. autoclass:: cherab.tools.observers.spectroscopy.PolychromatorFilter - :members: - -.. autoclass:: cherab.tools.observers.spectroscopy.Polychromator - :members: - -.. autoclass:: cherab.tools.observers.spectroscopy.Spectrometer - :members: - -.. autoclass:: cherab.tools.observers.spectroscopy.SurveySpectrometer - :members: - -.. autoclass:: cherab.tools.observers.spectroscopy.CzernyTurnerSpectrometer - :members: - diff --git a/docs/source/tools/spectroscopy.rst b/docs/source/tools/spectroscopy.rst new file mode 100644 index 00000000..406222c4 --- /dev/null +++ b/docs/source/tools/spectroscopy.rst @@ -0,0 +1,38 @@ + +Spectroscopy +============ + +The tools for plasma spectroscopy. + +.. _spectroscopy_instruments: + +Spectroscopic instruments +------------------------- + +Spectroscopic instruments such as polychromators, survey and high-resolution spectrometers +simplify the setup of properties of the observers and rendering pipelines. The instruments +are not connected to the scenegraph, so they cannot observe the world. However, the instruments +have properties, such as `min_wavelength`, `max_wavelength`, `spectral_bins`, +`pipeline_properties`, with which the observer can be configured. +The Cherab core package provides base classes for spectroscopic instruments, +so machine-specific packages can build more advance instruments from them, such as instruments +with spectral properties based on the actual experimental setup for a given shot/pulse. + +.. autoclass:: cherab.tools.spectroscopy.SpectroscopicInstrument + :members: + +.. autoclass:: cherab.tools.spectroscopy.PolychromatorFilter + :members: + +.. autoclass:: cherab.tools.spectroscopy.Polychromator + :members: + +.. autoclass:: cherab.tools.spectroscopy.Spectrometer + :members: + +.. autoclass:: cherab.tools.spectroscopy.SurveySpectrometer + :members: + +.. autoclass:: cherab.tools.spectroscopy.CzernyTurnerSpectrometer + :members: + diff --git a/docs/source/tools/tools.rst b/docs/source/tools/tools.rst index 3b356121..19f58e95 100644 --- a/docs/source/tools/tools.rst +++ b/docs/source/tools/tools.rst @@ -8,6 +8,7 @@ Tools materials primitives observers + spectroscopy tomography utility From 996a36ad913d95752cd09905194bea3c17405387 Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Tue, 3 Aug 2021 21:24:33 +0300 Subject: [PATCH 06/14] Made Instrument.pipelines() a funstion instead of property to avoid confusion. --- cherab/tools/spectroscopy/instrument.py | 5 ++--- cherab/tools/spectroscopy/polychromator.py | 2 +- cherab/tools/spectroscopy/spectrometer.py | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cherab/tools/spectroscopy/instrument.py b/cherab/tools/spectroscopy/instrument.py index fc0409b1..f503c7f3 100644 --- a/cherab/tools/spectroscopy/instrument.py +++ b/cherab/tools/spectroscopy/instrument.py @@ -26,7 +26,6 @@ class SpectroscopicInstrument: :ivar list pipeline_properties: The list of properties (class, name, filter) of the pipelines used with this instrument. - :ivar list pipelines: The list of pipelines. Each call returns a list with new instances. :ivar float min_wavelength: Lower wavelength bound for spectral range. :ivar float max_wavelength: Upper wavelength bound for spectral range. :ivar int spectral_bins: The number of spectral samples over the wavelength range. @@ -55,9 +54,9 @@ def pipeline_properties(self): return self._pipeline_properties - @property def pipelines(self): - # The list of pipelines. Each call returns a list with new instances. + """ Returns a list of new pipelines according to `pipeline_properties`.""" + pl_list = [] for (pl_class, pl_name, pl_filter) in self.pipeline_properties: if pl_filter is None: diff --git a/cherab/tools/spectroscopy/polychromator.py b/cherab/tools/spectroscopy/polychromator.py index 2d13a898..27366a45 100644 --- a/cherab/tools/spectroscopy/polychromator.py +++ b/cherab/tools/spectroscopy/polychromator.py @@ -108,7 +108,7 @@ class Polychromator(SpectroscopicInstrument): >>> fibreoptic.min_wavelength = polychromator.min_wavelength >>> fibreoptic.max_wavelength = polychromator.max_wavelength >>> fibreoptic.spectral_bins = polychromator.spectral_bins - >>> fibreoptic.pipelines = polychromator.pipelines + >>> fibreoptic.pipelines = polychromator.pipelines() """ def __init__(self, filters, min_bins_per_window=10, name=''): diff --git a/cherab/tools/spectroscopy/spectrometer.py b/cherab/tools/spectroscopy/spectrometer.py index e982918e..de67e36a 100644 --- a/cherab/tools/spectroscopy/spectrometer.py +++ b/cherab/tools/spectroscopy/spectrometer.py @@ -134,7 +134,7 @@ class SurveySpectrometer(Spectrometer): >>> bin_width = min((spectrometer.max_wavelength - spectrometer.min_wavelength) / spectrometer.spectral_bins, >>> (polychromator.max_wavelength - polychromator.min_wavelength) / polychromator.spectral_bins) >>> fibreoptic.spectral_bins = int(ceil((fibreoptic.max_wavelength - fibreoptic.min_wavelength) / bin_width)) - >>> fibreoptic.pipelines = spectrometer.pipelines + polychromator.pipelines + >>> fibreoptic.pipelines = spectrometer.pipelines() + polychromator.pipelines() """ def __init__(self, resolution, spectral_bins, reference_wavelength, reference_bin=None, name=''): @@ -194,7 +194,7 @@ class CzernyTurnerSpectrometer(Spectrometer): >>> fibreoptic.min_wavelength = hires_spectrometer.min_wavelength >>> fibreoptic.max_wavelength = hires_spectrometer.max_wavelength >>> fibreoptic.spectral_bins = hires_spectrometer.spectral_bins - >>> fibreoptic.pipelines = hires_spectrometer.pipelines + >>> fibreoptic.pipelines = hires_spectrometer.pipelines() """ def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diffraction_angle, spectral_bins, From 6b48415bcb3a0b18a9cbb5f738cffa2aa80762ee Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Thu, 11 Nov 2021 21:12:41 +0300 Subject: [PATCH 07/14] Renamed SpectroscopicInstrument.pipelines() to SpectroscopicInstrument.new_pipelines() to avoid confusion. --- cherab/tools/spectroscopy/instrument.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cherab/tools/spectroscopy/instrument.py b/cherab/tools/spectroscopy/instrument.py index f503c7f3..e593cafe 100644 --- a/cherab/tools/spectroscopy/instrument.py +++ b/cherab/tools/spectroscopy/instrument.py @@ -54,7 +54,7 @@ def pipeline_properties(self): return self._pipeline_properties - def pipelines(self): + def new_pipelines(self): """ Returns a list of new pipelines according to `pipeline_properties`.""" pl_list = [] From 808126a194150261ac0ea2ea289e46fb7f7fa226 Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Sat, 13 Nov 2021 18:32:08 +0300 Subject: [PATCH 08/14] Improved implementation of Polychromator and PolychromatorFilter. --- cherab/tools/spectroscopy/__init__.py | 2 +- cherab/tools/spectroscopy/polychromator.py | 126 +++++++++++++++------ 2 files changed, 91 insertions(+), 37 deletions(-) diff --git a/cherab/tools/spectroscopy/__init__.py b/cherab/tools/spectroscopy/__init__.py index 207aaf87..74bf4d16 100644 --- a/cherab/tools/spectroscopy/__init__.py +++ b/cherab/tools/spectroscopy/__init__.py @@ -18,5 +18,5 @@ # under the Licence. from .instrument import SpectroscopicInstrument -from .polychromator import PolychromatorFilter, Polychromator +from .polychromator import PolychromatorFilter, TrapezoidalFilter, Polychromator from .spectrometer import Spectrometer, CzernyTurnerSpectrometer, SurveySpectrometer diff --git a/cherab/tools/spectroscopy/polychromator.py b/cherab/tools/spectroscopy/polychromator.py index 27366a45..0a72c970 100644 --- a/cherab/tools/spectroscopy/polychromator.py +++ b/cherab/tools/spectroscopy/polychromator.py @@ -26,7 +26,78 @@ class PolychromatorFilter(InterpolatedSF): """ - Defines a symmetrical trapezoidal polychromator filter as a Raysect's InterpolatedSF. + Defines a polychromator filter as a Raysect's InterpolatedSF. + + :param object wavelengths: 1D array of wavelengths in nanometers. + :param object samples: 1D array of spectral samples. + :param bool normalise: True/false toggle for whether to normalise the + spectral function so its integral equals 1. + :param str name: Filter name (e.g. "H-alpha filter"). Default is ''. + + :ivar float min_wavelength: Lower wavelength bound of the filter's spectral range in nm. + :ivar float max_wavelength: Upper wavelength bound of the filter's spectral range in nm. + """ + + def __init__(self, wavelengths, samples, normalise=False, name=''): + + wavelengths = np.array(wavelengths, dtype=np.float64) + samples = np.array(samples, dtype=np.float64) + + if wavelengths.ndim != 1: + raise ValueError("Wavelength array must be 1D.") + + if samples.shape[0] != wavelengths.shape[0]: + raise ValueError("Wavelength and sample arrays must be the same length.") + + indices = np.argsort(wavelengths) + wavelengths = wavelengths[indices] + samples = samples[indices] + + self._min_wavelength = wavelengths[0] + self._max_wavelength = wavelengths[-1] + self._window = self._max_wavelength - self._min_wavelength + self._central_wavelength = 0.5 * (self._max_wavelength + self._min_wavelength) + + # setting the ends of the filter to zero, if they are not + if samples[0] != 0: + wavelengths = np.insert(wavelengths, 0, wavelengths[0] * (1. - 1.e-15)) + samples = np.insert(samples, 0, 0) + if samples[-1] != 0: + wavelengths = np.append(wavelengths, wavelengths[-1] * (1. + 1.e-15)) + samples = np.append(samples, 0) + + super().__init__(wavelengths, samples, normalise) + self._name = str(name) + + @property + def name(self): + # Filter name. + return self._name + + @property + def min_wavelength(self): + # Lower wavelength bound of the filter's spectral range in nm. + return self._min_wavelength + + @property + def max_wavelength(self): + # Upper wavelength bound of the filter's spectral range in nm. + return self._max_wavelength + + @property + def window(self): + # Size of the filtering window in nm. + return self._window + + @property + def central_wavelength(self): + # Central wavelength of the filter in nm. + return self._central_wavelength + + +class TrapezoidalFilter(PolychromatorFilter): + """ + Symmetrical trapezoidal polychromator filter. :param float wavelength: Central wavelength of the filter in nm. :param float window: Size of the filtering window in nm. Default is 3. @@ -35,55 +106,38 @@ class PolychromatorFilter(InterpolatedSF): :param str name: Filter name (e.g. "H-alpha filter"). Default is ''. """ - def __init__(self, wavelength, window=3., flat_top=None, name=''): + def __init__(self, central_wavelength, window=3., flat_top=None, name=''): - if wavelength <= 0: - raise ValueError("Argument 'wavelength' must be positive.") + if central_wavelength <= 0: + raise ValueError("Argument 'central_wavelength' must be positive.") if window <= 0: raise ValueError("Argument 'window' must be positive.") - flat_top = flat_top or window - 1.e-15 + flat_top = flat_top or window if flat_top <= 0: raise ValueError("Argument 'flat_top' must be positive.") if flat_top > window: raise ValueError("Argument 'flat_top' must be less or equal than 'window'.") - if flat_top == window: - flat_top = window - 1.e-15 - self._window = window self._flat_top = flat_top - self._wavelength = wavelength - self._name = str(name) - wavelengths = [wavelength - 0.5 * window, - wavelength - 0.5 * flat_top, - wavelength + 0.5 * flat_top, - wavelength + 0.5 * window] - samples = [0, 1, 1, 0] - super().__init__(wavelengths, samples, normalise=False) + if flat_top == window: + flat_top -= flat_top * 1.e-15 - @property - def window(self): - # Size of the filtering window in nm. - return self._window + wavelengths = [central_wavelength - 0.5 * window, + central_wavelength - 0.5 * flat_top, + central_wavelength + 0.5 * flat_top, + central_wavelength + 0.5 * window] + samples = [0, 1, 1, 0] + super().__init__(wavelengths, samples, normalise=False, name=name) @property def flat_top(self): # Size of the flat top part of the filter in nm. return self._flat_top - @property - def wavelength(self): - # Central wavelength of the filter in nm. - return self._wavelength - - @property - def name(self): - # Filter name. - return self._name - class Polychromator(SpectroscopicInstrument): """ @@ -98,17 +152,17 @@ class Polychromator(SpectroscopicInstrument): >>> from raysect.optical import World >>> from raysect.optical.observer import FibreOptic - >>> from cherab.tools.spectroscopy import Polychromator, PolychromatorFilter + >>> from cherab.tools.spectroscopy import Polychromator, TrapezoidalFilter >>> >>> world = World() - >>> h_alpha_filter = PolychromatorFilter(656.1, name='H-alpha filter') - >>> ciii_465nm_filter = PolychromatorFilter(464.8, name='CIII 465 nm filter') + >>> h_alpha_filter = TrapezoidalFilter(656.1, name='H-alpha filter') + >>> ciii_465nm_filter = TrapezoidalFilter(464.8, name='CIII 465 nm filter') >>> polychromator = Polychromator([h_alpha_filter, ciii_465nm_filter], name='MyPolychromator') >>> fibreoptic = FibreOptic(name="MyFibreOptic", parent=world) >>> fibreoptic.min_wavelength = polychromator.min_wavelength >>> fibreoptic.max_wavelength = polychromator.max_wavelength >>> fibreoptic.spectral_bins = polychromator.spectral_bins - >>> fibreoptic.pipelines = polychromator.pipelines() + >>> fibreoptic.pipelines = polychromator.new_pipelines() """ def __init__(self, filters, min_bins_per_window=10, name=''): @@ -155,8 +209,8 @@ def _update_spectral_settings(self): step = np.inf for poly_filter in self._filters: step = min(step, poly_filter.window / self._min_bins_per_window) - min_wavelength = min(min_wavelength, poly_filter.wavelength - 0.5 * poly_filter.window) - max_wavelength = max(max_wavelength, poly_filter.wavelength + 0.5 * poly_filter.window) + min_wavelength = min(min_wavelength, poly_filter.min_wavelength) + max_wavelength = max(max_wavelength, poly_filter.max_wavelength) self._min_wavelength = min_wavelength self._max_wavelength = max_wavelength From 5172888fa6eaa17186dbd72bfdbfce800c17ade3 Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Sat, 13 Nov 2021 20:02:01 +0300 Subject: [PATCH 09/14] Updated the tests for the Polychromator and the filters. --- .../tests/test_spectroscopic_instruments.py | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/cherab/tools/tests/test_spectroscopic_instruments.py b/cherab/tools/tests/test_spectroscopic_instruments.py index 883dc8c6..ab33f170 100644 --- a/cherab/tools/tests/test_spectroscopic_instruments.py +++ b/cherab/tools/tests/test_spectroscopic_instruments.py @@ -20,7 +20,7 @@ import numpy as np from raysect.optical.observer.pipeline import RadiancePipeline0D, SpectralRadiancePipeline0D -from cherab.tools.spectroscopy import PolychromatorFilter, Polychromator, CzernyTurnerSpectrometer, SurveySpectrometer +from cherab.tools.spectroscopy import TrapezoidalFilter, PolychromatorFilter, Polychromator, CzernyTurnerSpectrometer, SurveySpectrometer class TestPolychromatorFilter(unittest.TestCase): @@ -28,11 +28,26 @@ class TestPolychromatorFilter(unittest.TestCase): Test for PolychromatorFilter class. """ + def test_spectrum(self): + wavelengths = [658, 654, 656] # unsorted + samples = [0.5, 0.5, 1] # non-zero at the ends + poly_filter = PolychromatorFilter(wavelengths, samples, name='test_filter') + wavelengths = np.linspace(653., 659., 7) + spectrum_true = np.array([0, 0.5, 0.75, 1., 0.75, 0.5, 0]) + spectrum_test = np.array([poly_filter(wvl) for wvl in wavelengths]) + self.assertTrue(np.all(spectrum_true == spectrum_test)) + + +class TestTrapezoidalFilter(unittest.TestCase): + """ + Test for TrapezoidalFilter class. + """ + def test_spectrum(self): wavelength = 500. window = 6. flat_top = 2. - poly_filter = PolychromatorFilter(wavelength, window, flat_top, 'test_filter') + poly_filter = TrapezoidalFilter(wavelength, window, flat_top, 'test_filter') wavelengths = np.linspace(496., 504., 9) spectrum_true = np.array([0, 0, 0.5, 1., 1., 1., 0.5, 0, 0]) spectrum_test = np.array([poly_filter(wvl) for wvl in wavelengths]) @@ -44,10 +59,9 @@ class TestPolychromator(unittest.TestCase): Test cases for Polychromator class. """ - def setUp(self): - self.poly_filters_default = [PolychromatorFilter(400., 6., 2., 'filter 1'), - PolychromatorFilter(700., 8., 4., 'filter 2')] - self.min_bins_per_window_default = 10 + poly_filters_default = (TrapezoidalFilter(400., 6., 2., 'filter 1'), + TrapezoidalFilter(700., 8., 4., 'filter 2')) + min_bins_per_window_default = 10 def test_pipeline_properties(self): polychromator = Polychromator(self.poly_filters_default, self.min_bins_per_window_default, 'test polychromator') @@ -68,8 +82,8 @@ def test_filter_change(self): """ Checks if the spectral properties are updated correctly when the filters are replaced.""" polychromator = Polychromator(self.poly_filters_default, self.min_bins_per_window_default) polychromator.min_bins_per_window = 20 - polychromator.filters = [PolychromatorFilter(500., 5., 2., 'filter 1'), - PolychromatorFilter(600., 7., 4., 'filter 2')] + polychromator.filters = [TrapezoidalFilter(500., 5., 2., 'filter 1'), + TrapezoidalFilter(600., 7., 4., 'filter 2')] min_wavelength_true = 497.5 max_wavelength_true = 603.5 spectral_bins_true = 424 @@ -109,14 +123,13 @@ class TestCzernyTurnerSpectrometer(unittest.TestCase): Test cases for CzernyTurnerSpectrometer class. """ - def setUp(self): - self.diffraction_order = 1 - self.grating = 2.e-3 - self.focal_length = 1.e9 - self.pixel_spacing = 2.e4 - self.diffraction_angle = 10. - self.spectral_bins = 512 - self.reference_bin = 255 + diffraction_order = 1 + grating = 2.e-3 + focal_length = 1.e9 + pixel_spacing = 2.e4 + diffraction_angle = 10. + spectral_bins = 512 + reference_bin = 255 def test_resolution(self): wavelengths = [350., 550., 750.] From 7dc613d0cf75e6c985e8c043c7efe21f734dee0f Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Thu, 18 Nov 2021 14:18:00 +0300 Subject: [PATCH 10/14] Reimplemented spectrometers in response to reviewer's comments. --- cherab/tools/spectroscopy/__init__.py | 2 +- cherab/tools/spectroscopy/spectrometer.py | 319 ++++++++++-------- .../tests/test_spectroscopic_instruments.py | 67 ++-- docs/source/tools/spectroscopy.rst | 12 +- 4 files changed, 221 insertions(+), 179 deletions(-) diff --git a/cherab/tools/spectroscopy/__init__.py b/cherab/tools/spectroscopy/__init__.py index 74bf4d16..23bbbe1f 100644 --- a/cherab/tools/spectroscopy/__init__.py +++ b/cherab/tools/spectroscopy/__init__.py @@ -19,4 +19,4 @@ from .instrument import SpectroscopicInstrument from .polychromator import PolychromatorFilter, TrapezoidalFilter, Polychromator -from .spectrometer import Spectrometer, CzernyTurnerSpectrometer, SurveySpectrometer +from .spectrometer import Spectrometer, CzernyTurnerSpectrometer diff --git a/cherab/tools/spectroscopy/spectrometer.py b/cherab/tools/spectroscopy/spectrometer.py index de67e36a..c640179d 100644 --- a/cherab/tools/spectroscopy/spectrometer.py +++ b/cherab/tools/spectroscopy/spectrometer.py @@ -18,6 +18,7 @@ # under the Licence. import numpy as np +from raysect.optical import Spectrum from raysect.optical.observer import SpectralRadiancePipeline0D from .instrument import SpectroscopicInstrument @@ -25,161 +26,165 @@ class Spectrometer(SpectroscopicInstrument): """ - Spectrometer base class. - This is an abstract class. - - :param int spectral_bins: The number of spectral samples over the wavelength range. - :param float reference_wavelength: Wavelength (in nm) corresponding to - the centre of reference bin. - :param int reference_bin: Reference bin index. Can be negative to specify the offset. - Default is None (spectral_bins // 2). - :param str name: Spectrometer name. - """ - - def __init__(self, spectral_bins, reference_wavelength, reference_bin=None, name=''): - super().__init__(name) - self.spectral_bins = spectral_bins - if reference_bin is None: - self.reference_bin = self._spectral_bins // 2 - else: - self.reference_bin = reference_bin - self.reference_wavelength = reference_wavelength + Spectrometer that can accommodate multiple spectra. - @property - def spectral_bins(self): - # The number of spectral samples over the wavelength range. - return self._spectral_bins - - @spectral_bins.setter - def spectral_bins(self, value): - value = int(value) - if value <= 0: - raise ValueError("Attribute 'spectral_bins' must be > 0.") + Spectrometer is initialized with a sequence of calibration arrays (one array per accommodated + spectrum) containing the wavelengths of the pixel borders. Namely, the values + :math:`w_{k}^{i}` and :math:`w_{k}^{i+1}` define the spectral range of the pixel :math:`p_i` + of the `k`-th spectrum. After the spectrum is ray-traced, it can be recalibrated with + `spectrometer.calibrate(spectrum)`. - self._spectral_bins = value - self._clear_spectral_settings() - - @property - def reference_wavelength(self): - # Wavelength (in nm) corresponding to the centre of reference bin. - return self._reference_wavelength - - @reference_wavelength.setter - def reference_wavelength(self, value): - if value <= 0: - raise ValueError("Attribute 'reference_wavelength' must be > 0.") - - self._reference_wavelength = value - self._clear_spectral_settings() + Note that Raysect cannot raytrace the spectra with non-constant spectral resolution. + Thus, the actual number of spectral bins of raytraced spectrum is defined with + `min_bins_per_pixel` attribute. - @property - def reference_bin(self): - # Reference bin index. - return self._reference_bin - - @reference_bin.setter - def reference_bin(self, value): - value = int(value) - - self._reference_bin = value - self._clear_spectral_settings() - - def _update_pipeline_properties(self): - self._pipeline_properties = [(SpectralRadiancePipeline0D, self._name, None)] - - def _clear_spectral_settings(self): - self._min_wavelength = None - self._max_wavelength = None - - -class SurveySpectrometer(Spectrometer): - """ - Survey spectrometer with a constant spectral resolution. - - Note: survey spectrometers usually have non-constant spectral resolution - in the supported wavelength range. However, Raysect does not support - the observers with variable spectral resolution. - - :param float resolution: Spectral resolution in nm (can be negative). - :param int spectral_bins: The number of spectral samples over the wavelength range. - :param float reference_wavelength: Wavelength (in nm) corresponding to - the centre of reference bin. - :param int reference_bin: Reference bin index. Can be negative to specify the offset. - Default is None (spectral_bins // 2). + :param tuple wavelength_to_pixel: Wavelength-to-pixel calibration arrays. + :param int min_bins_per_pixel: Minimal number of spectral bins + per pixel. Default is 1. :param str name: Spectrometer name. - :ivar float resolution: Spectral resolution in nm (can be negative). + :ivar tuple wavelengths: Central wavelengths of the pixels. .. code-block:: pycon - >>> from numpy import ceil - >>> from raysect.optical import World + >>> from raysect.optical import World, Spectrum >>> from raysect.optical.observer import FibreOptic - >>> from cherab.tools.spectroscopy import SurveySpectrometer, Polychromator, PolychromatorFilter + >>> from cherab.tools.spectroscopy import Spectrometer + >>> from matplotlib import pyplot as plt + >>> + >>> wavelength_to_pixel = ([400., 400.5, 401.5, 402., 404.], + >>> [600., 600.5, 601.5, 602., 604., 607.]) + >>> spectrometer = Spectrometer(wavelength_to_pixel, min_bins_per_pixel=5, + >>> name='MySpectrometer') >>> >>> world = World() >>> fibreoptic = FibreOptic(name="MyFibreOptic", parent=world) - >>> # Here, the fibre optic is "connected" to both survey spectrometer and polychromator. + >>> fibreoptic.min_wavelength = spectrometer.min_wavelength + >>> fibreoptic.max_wavelength = spectrometer.max_wavelength + >>> fibreoptic.spectral_bins = spectrometer.spectral_bins + >>> fibreoptic.pipelines = spectrometer.new_pipelines() + >>> ... + >>> fibreoptic.observe() + >>> spectrum = Spectrum(fibreoptic.min_wavelength, fibreoptic.max_wavelength, fibreoptic.spectral_bins) + >>> spectrum.samples[:] = fibreoptic.pipelines[0].mean + >>> calibrated_spectra = spectrometer.calibrate(spectrum) + >>> wavelengths = spectrometer.wavelengths >>> - >>> # setting up the polychromator - >>> h_alpha_filter = PolychromatorFilter(656.1, name='H-alpha filter') - >>> ciii_465nm_filter = PolychromatorFilter(464.8, name='CIII 465 nm filter') - >>> polychromator = Polychromator([h_alpha_filter, ciii_465nm_filter], name='MyPolychromator') - >>> - >>> # setting up the survey spectrometer - >>> spectrometer = SurveySpectrometer(0.1, 1024, 500, name='MySpectrometer') - >>> - >>> fibreoptic.min_wavelength = min(spectrometer.min_wavelength, polychromator.min_wavelength) - >>> fibreoptic.max_wavelength = max(spectrometer.max_wavelength, polychromator.max_wavelength) - >>> bin_width = min((spectrometer.max_wavelength - spectrometer.min_wavelength) / spectrometer.spectral_bins, - >>> (polychromator.max_wavelength - polychromator.min_wavelength) / polychromator.spectral_bins) - >>> fibreoptic.spectral_bins = int(ceil((fibreoptic.max_wavelength - fibreoptic.min_wavelength) / bin_width)) - >>> fibreoptic.pipelines = spectrometer.pipelines() + polychromator.pipelines() + >>> plt.plot(wavelengths[0], calibrated_spectra[0]) + >>> plt.show() """ - def __init__(self, resolution, spectral_bins, reference_wavelength, reference_bin=None, name=''): - super().__init__(spectral_bins, reference_wavelength, reference_bin, name) - self.resolution = resolution + def __init__(self, wavelength_to_pixel, min_bins_per_pixel=1, name=''): + + self.min_bins_per_pixel = min_bins_per_pixel + self.wavelength_to_pixel = wavelength_to_pixel + super().__init__(name) + + @property + def wavelength_to_pixel(self): + # Wavelength-to-pixel calibration arrays. + return self._wavelength_to_pixel + + @wavelength_to_pixel.setter + def wavelength_to_pixel(self, value): + _wavelength_to_pixel = [] + _wavelengths = [] + for wl2pix in value: + wl2pix = np.array(wl2pix, dtype=float) + if wl2pix.ndim != 1: + raise ValueError('Attribute wavelength_to_pixel must only contain one-dimensional arrays.') + if wl2pix.size < 2: + raise ValueError('Attribute wavelength_to_pixel must only contain arrays of at least 2 elements.') + if np.any(np.diff(wl2pix) <= 0): + raise ValueError('Attribute wavelength_to_pixel must only contain monotonically increasing arrays.') + wl2pix.flags.writeable = False + _wavelength_to_pixel.append(wl2pix) + wl_center = 0.5 * (wl2pix[1:] + wl2pix[:-1]) + wl_center.flags.writeable = False + _wavelengths.append(wl_center) + self._wavelength_to_pixel = tuple(_wavelength_to_pixel) + self._wavelengths = tuple(_wavelengths) + self._clear_spectral_settings() + + @property + def wavelengths(self): + # Central wavelengths of the pixels. + return self._wavelengths @property - def resolution(self): - # Spectral resolution in nm (can be negative). - return self._resolution + def min_bins_per_pixel(self): + # Minimal number of spectral bins per pixel. + return self._min_bins_per_pixel - @resolution.setter - def resolution(self, value): - if value == 0: - raise ValueError("Attribute 'resolution' must be non-zero.") + @min_bins_per_pixel.setter + def min_bins_per_pixel(self, value): + value = int(value) + if value <= 0: + raise ValueError("Attribute 'min_bins_per_pixel' must be positive.") - self._resolution = value + self._min_bins_per_pixel = value self._clear_spectral_settings() - def _update_spectral_settings(self): + def _update_pipeline_properties(self): + self._pipeline_properties = [(SpectralRadiancePipeline0D, self._name, None)] - if self._resolution > 0: - self._min_wavelength = self._reference_wavelength - (self._reference_bin + 0.5) * self._resolution - self._max_wavelength = self._min_wavelength + self._spectral_bins * self._resolution - else: - self._min_wavelength = self._reference_wavelength + (self._spectral_bins - self._reference_bin - 0.5) * self._resolution - self._max_wavelength = self._min_wavelength - self._spectral_bins * self._resolution + def _update_spectral_settings(self): + self._min_wavelength = min(wl2pix[0] for wl2pix in self._wavelength_to_pixel) + self._max_wavelength = max(wl2pix[-1] for wl2pix in self._wavelength_to_pixel) + step = min(np.diff(wl2pix).min() for wl2pix in self._wavelength_to_pixel) / self._min_bins_per_pixel + self._spectral_bins = int(np.ceil((self._max_wavelength - self._min_wavelength) / step)) + + def calibrate(self, spectrum): + """ + Calibrates the spectrum according to the `wavelength_to_pixel` arrays + by averaging it over the pixel widths. + + :param Spectrum spectrum: Spectrum to calibrate. + + :returns: A tuple of calibrated spectra as ndarrays. + """ + if not isinstance(spectrum, Spectrum): + raise TypeError('Argument spectrum must be a Spectrum instance.') + if spectrum.min_wavelength > self.min_wavelength or spectrum.max_wavelength < self.max_wavelength: + raise ValueError('Unable to calibrate the spectrum. ' + 'The spectrum has narrower range ({}, {}) than the spectrometer ({}, {}).'.format(spectrum.min_wavelength, + spectrum.max_wavelength, + self.min_wavelength, + self.max_wavelength)) + calibrated_spectra = [] + for wl2pix in self.wavelength_to_pixel: + calibrated_spectrum = np.zeros(wl2pix.size - 1) + for i in range(wl2pix.size - 1): + calibrated_spectrum[i] = spectrum.integrate(wl2pix[i], wl2pix[i + 1]) / (wl2pix[i + 1] - wl2pix[i]) + calibrated_spectra.append(calibrated_spectrum) + + return calibrated_spectra class CzernyTurnerSpectrometer(Spectrometer): """ - Czerny-Turner high-resolution spectrometer. + Czerny-Turner spectrometer. + + The Czerny-Turner spectrometer is initialized with the parameters of the diffraction scheme + and a sequence of accommodated spectra, each of which is determined by the lower wavelength + bound and the number of pixels. + + This spectrometer automatically fills the wavelength-to-pixel calibration arrays + according to the parameters of the diffraction scheme. :param int diffraction_order: Diffraction order. :param float grating: Diffraction grating in nm-1. :param float focal_length: Focal length in nm. :param float pixel_spacing: Pixel to pixel spacing on CCD in nm. :param float diffraction_angle: Angle between incident and diffracted light in degrees. - :param int spectral_bins: The number of spectral samples over the wavelength range. - :param float reference_wavelength: Wavelength (in nm) corresponding to - the centre of reference bin. - :param int reference_bin: Reference bin index. Default is None (spectral_bins // 2). + :param tuple accommodated_spectra: A sequence of (`min_wavelength`, `pixels`) pairs, specifying + the lower wavelength bound and the number of pixels + of accommodated spectra. + :param int min_bins_per_pixel: Minimal number of spectral bins + per pixel. Default is 1. :param str name: Spectrometer name. - :ivar float resolution: Spectral resolution in nm (can be negative). + :ivar tuple wavelength_to_pixel: Wavelength-to-pixel calibration arrays. .. code-block:: pycon @@ -188,23 +193,26 @@ class CzernyTurnerSpectrometer(Spectrometer): >>> from cherab.tools.spectroscopy import CzernyTurnerSpectrometer >>> >>> world = World() - >>> hires_spectrometer = CzernyTurnerSpectrometer(1, 2.e-3, 1.e9, 2.e4, 10., 512, 600., + >>> hires_spectrometer = CzernyTurnerSpectrometer(1, 2.e-3, 1.e9, 2.e4, 10., + >>> ((600., 512), (700., 128)), >>> name='MySpectrometer') >>> fibreoptic = FibreOptic(name="MyFibreOptic", parent=world) >>> fibreoptic.min_wavelength = hires_spectrometer.min_wavelength >>> fibreoptic.max_wavelength = hires_spectrometer.max_wavelength >>> fibreoptic.spectral_bins = hires_spectrometer.spectral_bins - >>> fibreoptic.pipelines = hires_spectrometer.pipelines() + >>> fibreoptic.pipelines = hires_spectrometer.new_pipelines() """ - def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diffraction_angle, spectral_bins, - reference_wavelength, reference_bin=None, name=''): - super().__init__(spectral_bins, reference_wavelength, reference_bin, name) + def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diffraction_angle, + accommodated_spectra, min_bins_per_pixel=1, name=''): self.diffraction_order = diffraction_order self.grating = grating self.focal_length = focal_length self.pixel_spacing = pixel_spacing self.diffraction_angle = diffraction_angle + self.accommodated_spectra = accommodated_spectra + self.min_bins_per_pixel = min_bins_per_pixel + self.name = name @property def diffraction_order(self): @@ -253,8 +261,8 @@ def pixel_spacing(self): @pixel_spacing.setter def pixel_spacing(self, value): - if value == 0: - raise ValueError("Attribute 'pixel_spacing' must be non-zero.") + if value <= 0: + raise ValueError("Attribute 'pixel_spacing' must be positive.") self._pixel_spacing = value self._clear_spectral_settings() @@ -273,26 +281,53 @@ def diffraction_angle(self, value): self._clear_spectral_settings() @property - def resolution(self): - # Spectral resolution in nm (can be negative). + def accommodated_spectra(self): + return self._accommodated_spectra + + @accommodated_spectra.setter + def accommodated_spectra(self, value): + _wavelength_to_pixel = [] + _wavelengths = [] + for min_wavelength, pixels in value: + if min_wavelength <= 0: + raise ValueError('The value of min_wavelength in accommodated_spectra must be positive.') + if pixels <= 0: + raise ValueError('The value of pixels in accommodated_spectra must be positive.') + pixels = int(pixels) + wl2pix = np.zeros(pixels + 1) + wl2pix[0] = min_wavelength + for i in range(1, pixels + 1): + wl2pix[i] = wl2pix[i - 1] + self.resolution(wl2pix[i - 1]) + wl2pix.flags.writeable = False + _wavelength_to_pixel.append(wl2pix) + wl_center = 0.5 * (wl2pix[1:] + wl2pix[:-1]) + wl_center.flags.writeable = False + _wavelengths.append(wl_center) + self._accommodated_spectra = value + self._wavelength_to_pixel = tuple(_wavelength_to_pixel) + self._wavelengths = tuple(_wavelengths) + self._clear_spectral_settings() + + @property + def wavelength_to_pixel(self): + # Wavelength-to-pixel calibration arrays. + return self._wavelength_to_pixel + + def resolution(self, wavelength): + """ + Calculates spectral resolution in nm for a given wavelength. + + :param wavelength: Wavelength in nm. + + :returns: Resolution in nm. + """ grating = self._grating m = self._diffraction_order dxdp = self._pixel_spacing angle = self._diffraction_angle fl = self._focal_length - p = 0.5 * m * grating * self._reference_wavelength + p = 0.5 * m * grating * wavelength _resolution = dxdp * (np.sqrt(np.cos(angle)**2 - p * p) - p * np.tan(angle)) / (m * fl * grating) return _resolution - - def _update_spectral_settings(self): - - resolution = self.resolution - - if resolution > 0: - self._min_wavelength = self._reference_wavelength - (self._reference_bin + 0.5) * resolution - self._max_wavelength = self._min_wavelength + self._spectral_bins * resolution - else: - self._min_wavelength = self._reference_wavelength + (self._spectral_bins - self._reference_bin - 0.5) * resolution - self._max_wavelength = self._min_wavelength - self._spectral_bins * resolution diff --git a/cherab/tools/tests/test_spectroscopic_instruments.py b/cherab/tools/tests/test_spectroscopic_instruments.py index ab33f170..a8d843d6 100644 --- a/cherab/tools/tests/test_spectroscopic_instruments.py +++ b/cherab/tools/tests/test_spectroscopic_instruments.py @@ -19,8 +19,9 @@ import unittest import numpy as np +from raysect.optical import Spectrum from raysect.optical.observer.pipeline import RadiancePipeline0D, SpectralRadiancePipeline0D -from cherab.tools.spectroscopy import TrapezoidalFilter, PolychromatorFilter, Polychromator, CzernyTurnerSpectrometer, SurveySpectrometer +from cherab.tools.spectroscopy import TrapezoidalFilter, PolychromatorFilter, Polychromator, CzernyTurnerSpectrometer, Spectrometer class TestPolychromatorFilter(unittest.TestCase): @@ -92,30 +93,35 @@ def test_filter_change(self): polychromator.spectral_bins == spectral_bins_true) -class TestSurveySpectrometer(unittest.TestCase): +class TestSpectrometer(unittest.TestCase): """ - Test cases for SurveySpectrometer class. + Test cases for Spectrometer class. """ def test_pipeline_properties(self): - resolution = 0.1 - reference_wavelength = 500 - reference_bin = 50 - spectral_bins = 200 - spectrometer = SurveySpectrometer(resolution, spectral_bins, reference_wavelength, reference_bin, name='test spectrometer') + wavelength_to_pixel = ([400., 400.5],) + spectrometer = Spectrometer(wavelength_to_pixel, name='test spectrometer') pipeline_properties_true = [(SpectralRadiancePipeline0D, 'test spectrometer', None)] self.assertSequenceEqual(pipeline_properties_true, spectrometer.pipeline_properties) def test_spectral_properties(self): - resolution = 0.1 - reference_wavelength = 500 - reference_bin = 50 - spectral_bins = 200 - spectrometer = SurveySpectrometer(resolution, spectral_bins, reference_wavelength, reference_bin, name='test spectrometer') - min_wavelength_true = 494.95 - max_wavelength_true = 514.95 + wavelength_to_pixel = ([400., 400.5, 401.5, 402., 404.], [600., 600.5, 601.5, 602., 604., 607.]) + spectrometer = Spectrometer(wavelength_to_pixel, min_bins_per_pixel=2, name='test spectrometer') + min_wavelength_true = 400. + max_wavelength_true = 607. + spectra_bins_true = 828 self.assertTrue(spectrometer.min_wavelength == min_wavelength_true and - spectrometer.max_wavelength == max_wavelength_true) + spectrometer.max_wavelength == max_wavelength_true and + spectrometer.spectral_bins == spectra_bins_true) + + def test_calibration(self): + wavelength_to_pixel = ([400., 400.5, 401.5, 402., 404.],) + spectrometer = Spectrometer(wavelength_to_pixel, name='test spectrometer') + spectrum = Spectrum(399, 405, 12) + s, ds = np.linspace(0, 6., 13, retstep=True) + spectrum.samples[:] = s[:-1] + 0.5 * ds + calibrated_spectra = spectrometer.calibrate(spectrum) + self.assertTrue(np.all(calibrated_spectra[0] == np.array([1.25, 2., 2.75, 4.]))) class TestCzernyTurnerSpectrometer(unittest.TestCase): @@ -128,30 +134,27 @@ class TestCzernyTurnerSpectrometer(unittest.TestCase): focal_length = 1.e9 pixel_spacing = 2.e4 diffraction_angle = 10. - spectral_bins = 512 - reference_bin = 255 + accommodated_spectra = ((400., 64), (500., 32)) + min_bins_per_pixel = 2 def test_resolution(self): - wavelengths = [350., 550., 750.] + wavelengths = np.array([350., 550., 750.]) resolutions_true = np.array([8.587997e-3, 7.199328e-3, 5.0599164e-3]) spectrometer = CzernyTurnerSpectrometer(self.diffraction_order, self.grating, self.focal_length, self.pixel_spacing, - self.diffraction_angle, self.spectral_bins, 500., self.reference_bin, - name='test spectrometer') - resolutions = [] - for wvl in wavelengths: - spectrometer.reference_wavelength = wvl - resolutions.append(spectrometer.resolution) + self.diffraction_angle, self.accommodated_spectra, name='test spectrometer') + resolutions = spectrometer.resolution(wavelengths) self.assertTrue(np.all(np.abs(resolutions / resolutions_true - 1.) < 1.e-7)) def test_spectral_properties(self): - wavelength = 500. - min_wavelength_true = 498.0575 - max_wavelength_true = 501.9501 + min_wavelength_true = 400 + max_wavelength_true = 500.24326 + spectra_bins_true = 26377 spectrometer = CzernyTurnerSpectrometer(self.diffraction_order, self.grating, self.focal_length, self.pixel_spacing, - self.diffraction_angle, self.spectral_bins, wavelength, self.reference_bin, - name='test spectrometer') - self.assertTrue(abs(spectrometer.min_wavelength - min_wavelength_true) < 1.e-4 and - abs(spectrometer.max_wavelength - max_wavelength_true) < 1.e-4) + self.diffraction_angle, self.accommodated_spectra, + min_bins_per_pixel=self.min_bins_per_pixel, name='test spectrometer') + self.assertTrue(spectrometer.min_wavelength == min_wavelength_true and + spectrometer.spectral_bins == spectra_bins_true and + abs(spectrometer.max_wavelength - max_wavelength_true) < 1.e-5) if __name__ == '__main__': diff --git a/docs/source/tools/spectroscopy.rst b/docs/source/tools/spectroscopy.rst index 406222c4..a116dd77 100644 --- a/docs/source/tools/spectroscopy.rst +++ b/docs/source/tools/spectroscopy.rst @@ -9,7 +9,7 @@ The tools for plasma spectroscopy. Spectroscopic instruments ------------------------- -Spectroscopic instruments such as polychromators, survey and high-resolution spectrometers +Spectroscopic instruments such as polychromators and spectrometers simplify the setup of properties of the observers and rendering pipelines. The instruments are not connected to the scenegraph, so they cannot observe the world. However, the instruments have properties, such as `min_wavelength`, `max_wavelength`, `spectral_bins`, @@ -24,15 +24,19 @@ with spectral properties based on the actual experimental setup for a given shot .. autoclass:: cherab.tools.spectroscopy.PolychromatorFilter :members: -.. autoclass:: cherab.tools.spectroscopy.Polychromator +.. autoclass:: cherab.tools.spectroscopy.TrapezoidalFilter + :show-inheritance: :members: -.. autoclass:: cherab.tools.spectroscopy.Spectrometer +.. autoclass:: cherab.tools.spectroscopy.Polychromator + :show-inheritance: :members: -.. autoclass:: cherab.tools.spectroscopy.SurveySpectrometer +.. autoclass:: cherab.tools.spectroscopy.Spectrometer + :show-inheritance: :members: .. autoclass:: cherab.tools.spectroscopy.CzernyTurnerSpectrometer + :show-inheritance: :members: From 6e7c75ed4a367fd328113b98588aca877d21a0f3 Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Tue, 23 Nov 2021 20:43:59 +0300 Subject: [PATCH 11/14] Renamed SpectroscopicInstrument's new_pipelines() to create_pipeline(). --- cherab/tools/spectroscopy/instrument.py | 4 ++-- cherab/tools/spectroscopy/polychromator.py | 2 +- cherab/tools/spectroscopy/spectrometer.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cherab/tools/spectroscopy/instrument.py b/cherab/tools/spectroscopy/instrument.py index e593cafe..b2baf152 100644 --- a/cherab/tools/spectroscopy/instrument.py +++ b/cherab/tools/spectroscopy/instrument.py @@ -54,8 +54,8 @@ def pipeline_properties(self): return self._pipeline_properties - def new_pipelines(self): - """ Returns a list of new pipelines according to `pipeline_properties`.""" + def create_pipelines(self): + """ Returns a list of new pipelines created according to `pipeline_properties`.""" pl_list = [] for (pl_class, pl_name, pl_filter) in self.pipeline_properties: diff --git a/cherab/tools/spectroscopy/polychromator.py b/cherab/tools/spectroscopy/polychromator.py index 0a72c970..f81c6158 100644 --- a/cherab/tools/spectroscopy/polychromator.py +++ b/cherab/tools/spectroscopy/polychromator.py @@ -162,7 +162,7 @@ class Polychromator(SpectroscopicInstrument): >>> fibreoptic.min_wavelength = polychromator.min_wavelength >>> fibreoptic.max_wavelength = polychromator.max_wavelength >>> fibreoptic.spectral_bins = polychromator.spectral_bins - >>> fibreoptic.pipelines = polychromator.new_pipelines() + >>> fibreoptic.pipelines = polychromator.create_pipelines() """ def __init__(self, filters, min_bins_per_window=10, name=''): diff --git a/cherab/tools/spectroscopy/spectrometer.py b/cherab/tools/spectroscopy/spectrometer.py index c640179d..436f301a 100644 --- a/cherab/tools/spectroscopy/spectrometer.py +++ b/cherab/tools/spectroscopy/spectrometer.py @@ -62,7 +62,7 @@ class Spectrometer(SpectroscopicInstrument): >>> fibreoptic.min_wavelength = spectrometer.min_wavelength >>> fibreoptic.max_wavelength = spectrometer.max_wavelength >>> fibreoptic.spectral_bins = spectrometer.spectral_bins - >>> fibreoptic.pipelines = spectrometer.new_pipelines() + >>> fibreoptic.pipelines = spectrometer.create_pipelines() >>> ... >>> fibreoptic.observe() >>> spectrum = Spectrum(fibreoptic.min_wavelength, fibreoptic.max_wavelength, fibreoptic.spectral_bins) @@ -200,7 +200,7 @@ class CzernyTurnerSpectrometer(Spectrometer): >>> fibreoptic.min_wavelength = hires_spectrometer.min_wavelength >>> fibreoptic.max_wavelength = hires_spectrometer.max_wavelength >>> fibreoptic.spectral_bins = hires_spectrometer.spectral_bins - >>> fibreoptic.pipelines = hires_spectrometer.new_pipelines() + >>> fibreoptic.pipelines = hires_spectrometer.create_pipelines() """ def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diffraction_angle, From e11a6d510f9709eaa4104576e7b767a412a3c061 Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Tue, 14 Dec 2021 01:17:23 +0300 Subject: [PATCH 12/14] In CzernyTurnerSpectrometer invoke wavelength_to_pixel update when changing the property which affects the resolution. --- cherab/tools/spectroscopy/spectrometer.py | 31 +++++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/cherab/tools/spectroscopy/spectrometer.py b/cherab/tools/spectroscopy/spectrometer.py index 436f301a..5fbe3710 100644 --- a/cherab/tools/spectroscopy/spectrometer.py +++ b/cherab/tools/spectroscopy/spectrometer.py @@ -205,6 +205,7 @@ class CzernyTurnerSpectrometer(Spectrometer): def __init__(self, diffraction_order, grating, focal_length, pixel_spacing, diffraction_angle, accommodated_spectra, min_bins_per_pixel=1, name=''): + self._accommodated_spectra = None self.diffraction_order = diffraction_order self.grating = grating self.focal_length = focal_length @@ -226,7 +227,8 @@ def diffraction_order(self, value): raise ValueError("Attribute 'diffraction_order' must be positive.") self._diffraction_order = value - self._clear_spectral_settings() + # resolution has changed, recalculating wavelength_to_pixel + self._update_wavelength_to_pixel() @property def grating(self): @@ -239,7 +241,8 @@ def grating(self, value): raise ValueError("Attribute 'grating' must be positive.") self._grating = value - self._clear_spectral_settings() + # resolution has changed, recalculating wavelength_to_pixel + self._update_wavelength_to_pixel() @property def focal_length(self): @@ -252,7 +255,8 @@ def focal_length(self, value): raise ValueError("Attribute 'focal_length' must be positive.") self._focal_length = value - self._clear_spectral_settings() + # resolution has changed, recalculating wavelength_to_pixel + self._update_wavelength_to_pixel() @property def pixel_spacing(self): @@ -265,7 +269,8 @@ def pixel_spacing(self, value): raise ValueError("Attribute 'pixel_spacing' must be positive.") self._pixel_spacing = value - self._clear_spectral_settings() + # resolution has changed, recalculating wavelength_to_pixel + self._update_wavelength_to_pixel() @property def diffraction_angle(self): @@ -278,7 +283,8 @@ def diffraction_angle(self, value): raise ValueError("Attribute 'diffraction_angle' must be positive.") self._diffraction_angle = np.deg2rad(value) - self._clear_spectral_settings() + # resolution has changed, recalculating wavelength_to_pixel + self._update_wavelength_to_pixel() @property def accommodated_spectra(self): @@ -286,13 +292,22 @@ def accommodated_spectra(self): @accommodated_spectra.setter def accommodated_spectra(self, value): - _wavelength_to_pixel = [] - _wavelengths = [] for min_wavelength, pixels in value: if min_wavelength <= 0: raise ValueError('The value of min_wavelength in accommodated_spectra must be positive.') if pixels <= 0: raise ValueError('The value of pixels in accommodated_spectra must be positive.') + self._accommodated_spectra = value + self._update_wavelength_to_pixel() + + def _update_wavelength_to_pixel(self): + + if self._accommodated_spectra is None: + return + + _wavelength_to_pixel = [] + _wavelengths = [] + for min_wavelength, pixels in self._accommodated_spectra: pixels = int(pixels) wl2pix = np.zeros(pixels + 1) wl2pix[0] = min_wavelength @@ -303,9 +318,9 @@ def accommodated_spectra(self, value): wl_center = 0.5 * (wl2pix[1:] + wl2pix[:-1]) wl_center.flags.writeable = False _wavelengths.append(wl_center) - self._accommodated_spectra = value self._wavelength_to_pixel = tuple(_wavelength_to_pixel) self._wavelengths = tuple(_wavelengths) + self._clear_spectral_settings() @property From a8236300f40f345aee0923f2721a9310c119da67 Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Wed, 23 Mar 2022 22:21:08 +0300 Subject: [PATCH 13/14] Replace pipeline_properties with pipeline_classes and pipeline_kwargs to match the new connect_pipelines() interface of the group observers. --- cherab/tools/spectroscopy/instrument.py | 52 ++++++++++++------- cherab/tools/spectroscopy/polychromator.py | 10 ++-- cherab/tools/spectroscopy/spectrometer.py | 7 ++- .../tests/test_spectroscopic_instruments.py | 23 +++++--- 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/cherab/tools/spectroscopy/instrument.py b/cherab/tools/spectroscopy/instrument.py index b2baf152..0e0d666c 100644 --- a/cherab/tools/spectroscopy/instrument.py +++ b/cherab/tools/spectroscopy/instrument.py @@ -24,14 +24,16 @@ class SpectroscopicInstrument: :param str name: Instrument name. - :ivar list pipeline_properties: The list of properties (class, name, filter) of - the pipelines used with this instrument. + :ivar list pipeline_classes: The list of pipeline classes used with this instrument. + :ivar list pipeline_kwargs: The list of dicts with keywords passed to init methods of + pipeline classes used with this instrument. :ivar float min_wavelength: Lower wavelength bound for spectral range. :ivar float max_wavelength: Upper wavelength bound for spectral range. :ivar int spectral_bins: The number of spectral samples over the wavelength range. """ def __init__(self, name=''): + self._pipeline_classes = None self.name = name self._clear_spectral_settings() @@ -43,28 +45,39 @@ def name(self): @name.setter def name(self, value): self._name = str(value) - self._pipeline_properties = None + self._pipeline_kwargs = None @property - def pipeline_properties(self): - # The list of properties (class, name, filter) of the pipelines used with - # this instrument. - if self._pipeline_properties is None: - self._update_pipeline_properties() + def pipeline_classes(self): + # The list of pipeline classes used with this instrument. + if self._pipeline_classes is None: + self._update_pipeline_classes() - return self._pipeline_properties + return self._pipeline_classes + + @property + def pipeline_kwargs(self): + # The list of dicts with keywords passed to init methods of + # pipeline classes used with this instrument. + if self._pipeline_kwargs is None: + self._update_pipeline_kwargs() + + return self._pipeline_kwargs def create_pipelines(self): - """ Returns a list of new pipelines created according to `pipeline_properties`.""" + """ Returns a list of new pipelines created according to `pipeline_classes` + and keyword arguments.""" + if self._pipeline_classes is None: + self._update_pipeline_classes() + if self._pipeline_kwargs is None: + self._update_pipeline_kwargs() - pl_list = [] - for (pl_class, pl_name, pl_filter) in self.pipeline_properties: - if pl_filter is None: - pl_list.append(pl_class(name=pl_name)) - else: - pl_list.append(pl_class(name=pl_name, filter=pl_filter)) + pipelines = [] + for PipelineClass, kwargs in zip(self._pipeline_classes, self._pipeline_kwargs): + pipeline = PipelineClass(**kwargs) + pipelines.append(pipeline) - return pl_list + return pipelines @property def min_wavelength(self): @@ -98,5 +111,8 @@ def _clear_spectral_settings(self): def _update_spectral_settings(self): raise NotImplementedError("To be defined in subclass.") - def _update_pipeline_properties(self): + def _update_pipeline_classes(self): + raise NotImplementedError("To be defined in subclass.") + + def _update_pipeline_kwargs(self): raise NotImplementedError("To be defined in subclass.") diff --git a/cherab/tools/spectroscopy/polychromator.py b/cherab/tools/spectroscopy/polychromator.py index f81c6158..56fb6e18 100644 --- a/cherab/tools/spectroscopy/polychromator.py +++ b/cherab/tools/spectroscopy/polychromator.py @@ -197,10 +197,14 @@ def filters(self, value): self._filters = value self._clear_spectral_settings() - self._pipeline_properties = None + self._pipeline_classes = None + self._pipeline_kwargs = None - def _update_pipeline_properties(self): - self._pipeline_properties = [(RadiancePipeline0D, self._name + ': ' + poly_filter.name, poly_filter) for poly_filter in self._filters] + def _update_pipeline_classes(self): + self._pipeline_classes = [RadiancePipeline0D for poly_filter in self._filters] + + def _update_pipeline_kwargs(self): + self._pipeline_kwargs = [{'name': self._name + ': ' + poly_filter.name, 'filter': poly_filter} for poly_filter in self._filters] def _update_spectral_settings(self): diff --git a/cherab/tools/spectroscopy/spectrometer.py b/cherab/tools/spectroscopy/spectrometer.py index 5fbe3710..585ac3f4 100644 --- a/cherab/tools/spectroscopy/spectrometer.py +++ b/cherab/tools/spectroscopy/spectrometer.py @@ -125,8 +125,11 @@ def min_bins_per_pixel(self, value): self._min_bins_per_pixel = value self._clear_spectral_settings() - def _update_pipeline_properties(self): - self._pipeline_properties = [(SpectralRadiancePipeline0D, self._name, None)] + def _update_pipeline_classes(self): + self._pipeline_classes = [SpectralRadiancePipeline0D] + + def _update_pipeline_kwargs(self): + self._pipeline_kwargs = [{'name': self._name}] def _update_spectral_settings(self): self._min_wavelength = min(wl2pix[0] for wl2pix in self._wavelength_to_pixel) diff --git a/cherab/tools/tests/test_spectroscopic_instruments.py b/cherab/tools/tests/test_spectroscopic_instruments.py index a8d843d6..211f7830 100644 --- a/cherab/tools/tests/test_spectroscopic_instruments.py +++ b/cherab/tools/tests/test_spectroscopic_instruments.py @@ -64,12 +64,17 @@ class TestPolychromator(unittest.TestCase): TrapezoidalFilter(700., 8., 4., 'filter 2')) min_bins_per_window_default = 10 - def test_pipeline_properties(self): + def test_pipeline_classes(self): polychromator = Polychromator(self.poly_filters_default, self.min_bins_per_window_default, 'test polychromator') - pipeline_properties_true = [(RadiancePipeline0D, 'test polychromator: filter 1', self.poly_filters_default[0]), - (RadiancePipeline0D, 'test polychromator: filter 2', self.poly_filters_default[1])] - self.assertSequenceEqual(pipeline_properties_true, polychromator.pipeline_properties) + pipeline_classes_true = [RadiancePipeline0D, RadiancePipeline0D] + self.assertSequenceEqual(pipeline_classes_true, polychromator.pipeline_classes) + def test_pipeline_kwargs(self): + polychromator = Polychromator(self.poly_filters_default, self.min_bins_per_window_default, 'test polychromator') + pipeline_kwargs_true = [{'name': 'test polychromator: filter 1', 'filter': self.poly_filters_default[0]}, + {'name': 'test polychromator: filter 2', 'filter': self.poly_filters_default[1]}] + self.assertSequenceEqual(pipeline_kwargs_true, polychromator.pipeline_kwargs) + def test_spectral_properties(self): polychromator = Polychromator(self.poly_filters_default, self.min_bins_per_window_default) min_wavelength_true = 397. @@ -98,11 +103,15 @@ class TestSpectrometer(unittest.TestCase): Test cases for Spectrometer class. """ - def test_pipeline_properties(self): + def test_pipeline_classes(self): + wavelength_to_pixel = ([400., 400.5],) + spectrometer = Spectrometer(wavelength_to_pixel, name='test spectrometer') + self.assertSequenceEqual([SpectralRadiancePipeline0D], spectrometer.pipeline_classes) + + def test_pipeline_kwargs(self): wavelength_to_pixel = ([400., 400.5],) spectrometer = Spectrometer(wavelength_to_pixel, name='test spectrometer') - pipeline_properties_true = [(SpectralRadiancePipeline0D, 'test spectrometer', None)] - self.assertSequenceEqual(pipeline_properties_true, spectrometer.pipeline_properties) + self.assertSequenceEqual([{'name': 'test spectrometer'}], spectrometer.pipeline_kwargs) def test_spectral_properties(self): wavelength_to_pixel = ([400., 400.5, 401.5, 402., 404.], [600., 600.5, 601.5, 602., 604., 607.]) From 3b7764444432f98883961803ddbfd3dfb27e7e5d Mon Sep 17 00:00:00 2001 From: Vladislav Neverov Date: Wed, 23 Mar 2022 23:51:03 +0300 Subject: [PATCH 14/14] Update CHANGELOG.md. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5ca2d2..9c91c98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ New: * Add a demo for observer group handling and plotting. * Add verbose parameter to SartOpencl solver (default is False). (#358) * Add Generomak core plasma profiles. (#360) +* Add common spectroscopic instruments: Polychromator, SurveySpectrometer, CzernyTurnerSpectrometer. (#299) Bug Fixes: ----------