From 54f250b37ad4600aa944657e8ca34c05817ac7c8 Mon Sep 17 00:00:00 2001 From: Rebecca Fair Date: Tue, 1 Mar 2022 11:49:48 +0000 Subject: [PATCH] Enable saving as JSON from CL tools (#205) Partially addresses #130 * Add initial --save-json to euphonic-powder-map * Add --save-json to other CL tools * Check type of x_tick_labels In euphonic.cli.utils._get_tick_labels, the label indices are found with np.where, which returns an array of np.int32 NOT int. np.int32 is not JSON serialisable so this was causing an error on writing to JSON. This could have been solved in _get_tick_labels, but as setting the labels like this on a Spectrum object seems like a common thing to do, I thought it was better to instead validate x_tick_labels and convert to plain ints inside the class. This goes against the rest of Euphonic, where things are only validated on class creation, but given the use-case I feel it is necessary (and maybe this is a sign we should be stricter on validation elsewhere in Euphonic) * Increase coverage * Small Codacy updates * Update changelog and docs --- CHANGELOG.rst | 3 ++ doc/source/powder-map-script.rst | 13 ++++++++ euphonic/cli/dispersion.py | 2 ++ euphonic/cli/dos.py | 2 ++ euphonic/cli/intensity_map.py | 2 ++ euphonic/cli/powder_map.py | 2 ++ euphonic/cli/utils.py | 3 ++ euphonic/spectra.py | 24 ++++++++++++++ .../test/euphonic_test/test_spectrum1d.py | 31 +++++++++++++++++++ .../test/script_tests/test_dispersion.py | 10 ++++++ .../test/script_tests/test_dos.py | 9 ++++++ .../test/script_tests/test_intensity_map.py | 9 ++++++ .../test/script_tests/test_powder_map.py | 10 ++++++ 13 files changed, 120 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f85163359..bb6ac7249 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,9 @@ ``euphonic-intensity-map`` command-line tools can now read files that don't contain eigenvectors, if eigenvectors are not required for the chosen options. + - A new ``--save-json`` option is available for command-line tools + which produce plots, this will output the produced spectrum to + a Euphonic .json file. - There is now the option to use a fast, approximate variable-width broadening method when adaptively broadening dos: diff --git a/doc/source/powder-map-script.rst b/doc/source/powder-map-script.rst index 832200a56..b2db81bae 100644 --- a/doc/source/powder-map-script.rst +++ b/doc/source/powder-map-script.rst @@ -4,8 +4,13 @@ euphonic-powder-map ====================== +.. contents:: :local: + .. highlight:: bash +Overview +-------- + The ``euphonic-powder-map`` program can be used to sample spherically-averaged properties from force constants data over a range of :math:`|q|`. The results are plotted as a 2-dimensional map in :math:`(|q|, \omega)`. @@ -36,6 +41,7 @@ q range with denser sampling, in THz and with the intensity widget disabled:: and energy on the y axis, showing powder-averaged coherent inelastic neutron scattering intensities for NaCl. + To see all the command line options, run:: euphonic-powder-map -h @@ -73,6 +79,13 @@ Sampling many q-points can be computationally expensive, so a progress bar will automatically be displayed if `tqdm `_ is installed +Output to file +-------------- + +The ``--save-json`` option can be used to output the produced +:ref:`Spectrum2D` object as a Euphonic .json file with a specified +name for further use in Euphonic or other programs. + Command Line Options -------------------- diff --git a/euphonic/cli/dispersion.py b/euphonic/cli/dispersion.py index 909550923..1a03521d9 100644 --- a/euphonic/cli/dispersion.py +++ b/euphonic/cli/dispersion.py @@ -52,6 +52,8 @@ def main(params: Optional[List[str]] = None) -> None: style = _compose_style(user_args=args, base=[base_style]) + if args.save_json: + spectrum.to_json_file(args.save_json) with matplotlib.style.context(style): _ = plot_1d(spectra, ymin=args.e_min, diff --git a/euphonic/cli/dos.py b/euphonic/cli/dos.py index d6d1e743d..0116eb6b1 100644 --- a/euphonic/cli/dos.py +++ b/euphonic/cli/dos.py @@ -79,6 +79,8 @@ def main(params: Optional[List[str]] = None) -> None: plot_label_kwargs = _plot_label_kwargs( args, default_xlabel=f"Energy / {dos.x_data.units:~P}") + if args.save_json: + dos.to_json_file(args.save_json) style = _compose_style(user_args=args, base=[base_style]) with matplotlib.style.context(style): _ = plot_1d(dos, ymin=0, **plot_label_kwargs) diff --git a/euphonic/cli/intensity_map.py b/euphonic/cli/intensity_map.py index 2cc545b65..5e37c7b0f 100755 --- a/euphonic/cli/intensity_map.py +++ b/euphonic/cli/intensity_map.py @@ -89,6 +89,8 @@ def main(params: Optional[List[str]] = None) -> None: if len(spectra) > 1: print(f"Found {len(spectra)} regions in q-point path") + if args.save_json: + spectrum.to_json_file(args.save_json) style = _compose_style(user_args=args, base=[base_style]) with matplotlib.style.context(style): diff --git a/euphonic/cli/powder_map.py b/euphonic/cli/powder_map.py index 0ae1c62e7..f876e0d9d 100755 --- a/euphonic/cli/powder_map.py +++ b/euphonic/cli/powder_map.py @@ -161,6 +161,8 @@ def main(params: Optional[List[str]] = None) -> None: args, default_xlabel=f"|q| / {q_min.units:~P}", default_ylabel=f"Energy / {spectrum.y_data.units:~P}") + if args.save_json: + spectrum.to_json_file(args.save_json) if args.disable_widgets: base = [base_style] else: diff --git a/euphonic/cli/utils.py b/euphonic/cli/utils.py index 04b922a27..169cdad9b 100644 --- a/euphonic/cli/utils.py +++ b/euphonic/cli/utils.py @@ -664,6 +664,9 @@ def __call__(self, parser, args, values, option_string=None): '"golden" and "random-sphere".')) if 'plotting' in features: + sections['file'].add_argument( + '--save-json', dest='save_json', default=None, + help='Save spectrum to a .json file with this name') section = sections['plotting'] section.add_argument( '-s', '--save-to', dest='save_to', default=None, diff --git a/euphonic/spectra.py b/euphonic/spectra.py index 0b0198e23..e62bf9634 100644 --- a/euphonic/spectra.py +++ b/euphonic/spectra.py @@ -54,6 +54,30 @@ def y_data(self, value: Quantity) -> None: self.y_data_unit = str(value.units) self._y_data = value.to(self._internal_y_data_unit).magnitude + @property + def x_tick_labels(self) -> List[Tuple[int, str]]: + return self._x_tick_labels + + @x_tick_labels.setter + def x_tick_labels(self, value: Sequence[Tuple[int, str]]) -> None: + err_msg = ('x_tick_labels should be of type ' + 'Sequence[Tuple[int, str]] e.g. ' + '[(0, "label1"), (5, "label2")]') + if value is not None: + if isinstance(value, Sequence): + for elem in value: + if not (isinstance(elem, tuple) + and len(elem) == 2 + and isinstance(elem[0], Integral) + and isinstance(elem[1], str)): + raise TypeError(err_msg) + # Ensure indices in x_tick_labels are plain ints as + # np.int64/32 etc. are not JSON serializable + value = [(int(idx), label) for idx, label in value] + else: + raise TypeError(err_msg) + self._x_tick_labels = value + @abstractmethod def to_dict(self) -> Dict[str, Any]: """Write to dict using euphonic.io._obj_to_dict""" diff --git a/tests_and_analysis/test/euphonic_test/test_spectrum1d.py b/tests_and_analysis/test/euphonic_test/test_spectrum1d.py index f6c83bf7e..74756bb82 100644 --- a/tests_and_analysis/test/euphonic_test/test_spectrum1d.py +++ b/tests_and_analysis/test/euphonic_test/test_spectrum1d.py @@ -193,6 +193,15 @@ def test_correct_object_creation(self, spec1d_creator): ('x_tick_labels', get_expected_spectrum1d('xsq_spectrum1d.json').x_tick_labels[0], TypeError), + ('x_tick_labels', + [(0,), (1, 'one'), (2, 'two')], + TypeError), + ('x_tick_labels', + [(0, 'zero'), (1,), (2,)], + TypeError), + ('x_tick_labels', + [(0, 1), (2, 3), (4, 5)], + TypeError), ('metadata', ['Not', 'a', 'dictionary'], TypeError)]) @@ -297,6 +306,28 @@ def test_incorrect_unit_conversion(self, spectrum1d_file, attr, with pytest.raises(err): setattr(spec1d, attr, new_attr) + @pytest.mark.parametrize('value', [[(0, 'zero'), (1, 'one')]]) + def test_x_tick_labels_setter(self, value): + spec1d = get_spectrum1d('xsq_spectrum1d.json') + spec1d.x_tick_labels = value + assert spec1d.x_tick_labels == value + + @pytest.mark.parametrize('value', [ + [(0,), (1, 'one')], + [(0, 'zero'), ('one', 'one')], + 0]) + def test_x_tick_labels_incorrect_setter(self, value): + spec1d = get_spectrum1d('xsq_spectrum1d.json') + with pytest.raises(TypeError): + spec1d.x_tick_labels = value + + def test_x_tick_labels_converted_to_plain_int(self): + # np.arange returns an array of type np.int32 + x_tick_labels = [(idx, 'label') for idx in np.arange(5)] + spec1d = get_spectrum1d('xsq_spectrum1d.json') + spec1d.x_tick_labels = x_tick_labels + assert np.all([isinstance(x[0], int) for x in spec1d.x_tick_labels]) + class TestSpectrum1DMethods: @pytest.mark.parametrize( diff --git a/tests_and_analysis/test/script_tests/test_dispersion.py b/tests_and_analysis/test/script_tests/test_dispersion.py index 9941659d5..87584f1c5 100644 --- a/tests_and_analysis/test/script_tests/test_dispersion.py +++ b/tests_and_analysis/test/script_tests/test_dispersion.py @@ -9,6 +9,7 @@ from packaging import version from scipy import __version__ as scipy_ver +from euphonic import Spectrum1DCollection from tests_and_analysis.test.utils import ( get_data_path, get_castep_path, get_phonopy_path) from tests_and_analysis.test.script_tests.utils import ( @@ -126,6 +127,15 @@ def test_plot_save_to_file(self, inject_mocks, tmpdir, dispersion_args): euphonic.cli.dispersion.main(dispersion_args + [output_file]) assert os.path.exists(output_file) + @pytest.mark.parametrize('dispersion_args', [ + [quartz_json_file, '--save-json'], + [lzo_fc_file, '--save-json']]) + def test_plot_save_to_json(self, inject_mocks, tmpdir, dispersion_args): + output_file = str(tmpdir.join('test.json')) + euphonic.cli.dispersion.main(dispersion_args + [output_file]) + spec = Spectrum1DCollection.from_json_file(output_file) + assert isinstance(spec, Spectrum1DCollection) + @pytest.mark.parametrize('dispersion_args', [ [quartz_no_evec_json_file, '--reorder']]) def test_no_evecs_with_reorder_raises_type_error(self, dispersion_args): diff --git a/tests_and_analysis/test/script_tests/test_dos.py b/tests_and_analysis/test/script_tests/test_dos.py index 0de46fca9..6a242da6c 100644 --- a/tests_and_analysis/test/script_tests/test_dos.py +++ b/tests_and_analysis/test/script_tests/test_dos.py @@ -7,6 +7,7 @@ import numpy as np import numpy.testing as npt +from euphonic import Spectrum1D from tests_and_analysis.test.utils import ( get_data_path, get_castep_path, get_phonopy_path) from tests_and_analysis.test.script_tests.utils import ( @@ -97,6 +98,14 @@ def test_plot_save_to_file(self, inject_mocks, tmpdir, dos_args): euphonic.cli.dos.main(dos_args + [output_file]) assert os.path.exists(output_file) + @pytest.mark.parametrize('dos_args', [ + [nah_phonon_file, '--save-json']]) + def test_plot_save_to_json(self, inject_mocks, tmpdir, dos_args): + output_file = str(tmpdir.join('test.json')) + euphonic.cli.dos.main(dos_args + [output_file]) + spec = Spectrum1D.from_json_file(output_file) + assert isinstance(spec, Spectrum1D) + @pytest.mark.parametrize('dos_args', [ [get_data_path('crystal', 'crystal_LZO.json')]]) def test_invalid_file_raises_value_error(self, dos_args): diff --git a/tests_and_analysis/test/script_tests/test_intensity_map.py b/tests_and_analysis/test/script_tests/test_intensity_map.py index f3a9eef76..abe5f6553 100644 --- a/tests_and_analysis/test/script_tests/test_intensity_map.py +++ b/tests_and_analysis/test/script_tests/test_intensity_map.py @@ -8,6 +8,7 @@ from packaging import version from scipy import __version__ as scipy_ver +from euphonic import Spectrum2D from tests_and_analysis.test.utils import get_data_path, get_castep_path from tests_and_analysis.test.script_tests.utils import ( get_script_test_data_path, get_current_plot_image_data, args_to_key) @@ -118,6 +119,14 @@ def test_plot_save_to_file(self, inject_mocks, tmpdir, intensity_map_args): euphonic.cli.intensity_map.main(intensity_map_args + [output_file]) assert os.path.exists(output_file) + @pytest.mark.parametrize('intensity_map_args', [ + [quartz_json_file, '--save-json']]) + def test_plot_save_to_json(self, inject_mocks, tmpdir, intensity_map_args): + output_file = str(tmpdir.join('test.json')) + euphonic.cli.intensity_map.main(intensity_map_args + [output_file]) + spec = Spectrum2D.from_json_file(output_file) + assert isinstance(spec, Spectrum2D) + @pytest.mark.parametrize('intensity_map_args', [ [get_data_path('util', 'qgrid_444.txt')]]) def test_invalid_file_raises_value_error(self, intensity_map_args): diff --git a/tests_and_analysis/test/script_tests/test_powder_map.py b/tests_and_analysis/test/script_tests/test_powder_map.py index 5c5ed95cc..5e8c7e57a 100644 --- a/tests_and_analysis/test/script_tests/test_powder_map.py +++ b/tests_and_analysis/test/script_tests/test_powder_map.py @@ -8,6 +8,7 @@ from packaging import version from scipy import __version__ as scipy_ver +from euphonic import Spectrum2D from tests_and_analysis.test.utils import get_data_path, get_castep_path, get_phonopy_path from tests_and_analysis.test.script_tests.utils import ( get_script_test_data_path, get_current_plot_image_data, args_to_key) @@ -133,6 +134,15 @@ def test_plot_save_to_file(self, inject_mocks, tmpdir, powder_map_args): + quick_calc_params) assert os.path.exists(output_file) + @pytest.mark.parametrize('powder_map_args', [ + [graphite_fc_file, '--save-json']]) + def test_plot_save_to_json(self, inject_mocks, tmpdir, powder_map_args): + output_file = str(tmpdir.join('test.json')) + euphonic.cli.powder_map.main(powder_map_args + [output_file] + + quick_calc_params) + spec = Spectrum2D.from_json_file(output_file) + assert isinstance(spec, Spectrum2D) + @pytest.mark.parametrize('powder_map_args', [ [os.path.join(get_data_path(), 'util', 'qgrid_444.txt')]]) def test_invalid_file_raises_value_error(self, powder_map_args):