diff --git a/.travis.yml b/.travis.yml index 05a17507..c390a196 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,6 @@ # languages language: python python: - - "3.5" - "3.6" # Enable 3.7 without globally enabling sudo and dist: xenial for other # build jobs diff --git a/README.md b/README.md index 0b96900c..74852bba 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![h5py](https://img.shields.io/badge/powered%20by-h5py-%235e9ffa.svg)](https://www.h5py.org/) -The **bapsflib** package is developed on Python 3.5+ and is intend to +The **bapsflib** package is developed on Python 3.6+ and is intend to be a toolkit for reading, manipulating, and analyzing data collected at the Basic Plasma Science Facility ([BaPSF](http://plasma.physics.ucla.edu/)) at UCLA. diff --git a/appveyor.yml b/appveyor.yml index 70498eb7..d0ce13a6 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -3,10 +3,8 @@ environment: matrix: - - PYTHON: "C:\\Python35" - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" - - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37-x64" diff --git a/bapsflib/_hdf/utils/hdfreaddata.py b/bapsflib/_hdf/utils/hdfreaddata.py index cd8f0f19..3c096d4c 100644 --- a/bapsflib/_hdf/utils/hdfreaddata.py +++ b/bapsflib/_hdf/utils/hdfreaddata.py @@ -15,7 +15,6 @@ import os import time -from bapsflib.plasma import core from typing import Union from warnings import warn @@ -592,21 +591,6 @@ def __new__(cls, else: obj._info['controls'] = {} - # plasma parameter dict - obj._plasma = { - 'Bo': None, - 'kT': None, - 'kTe': None, - 'kTi': None, - 'gamma': core.FloatUnit(1.0, 'arb'), - 'm_e': core.ME, - 'm_i': None, - 'n': None, - 'n_e': None, - 'n_i': None, - 'Z': None - } # pragma: no cover - # convert to voltage # - 'signal' dtype is assigned based on keep_bit # @@ -661,21 +645,6 @@ def __array_finalize__(self, obj): 'controls': {}, }) - # Define plasma attribute - self._plasma = getattr(obj, '_plasma', { - 'Bo': None, - 'kT': None, - 'kTe': None, - 'kTi': None, - 'gamma': core.FloatUnit(1.0, 'arb'), - 'm_e': core.ME, - 'm_i': None, - 'n': None, - 'n_e': None, - 'n_i': None, - 'Z': None - }) # pragma: no cover - def convert_signal(self, to_volt=False, to_bits=False, force=False): """converts signal from volts (bits) to bits (volts)""" # @@ -800,336 +769,8 @@ def dv(self) -> Union[u.Quantity, None]: (2. ** self.info['bit'] - 1.)) return dv - @property - def plasma(self): # pragma: no cover - """ - Dictionary of plasma parameters. (All quantities are in cgs - units except temperature is in eV) - - +----------------+---------------------------------------------+ - | Base Values | - +================+=============================================+ - | :const:`Bo` | magnetic field | - +----------------+---------------------------------------------+ - | :const:`kT` | temperature (generic) | - +----------------+---------------------------------------------+ - | :const:`kTe` | electron temperature | - +----------------+---------------------------------------------+ - | :const:`kTi` | ion temperature | - +----------------+---------------------------------------------+ - | :const:`gamma` | adiabatic index | - +----------------+---------------------------------------------+ - | :const:`m_e` | electron mass | - +----------------+---------------------------------------------+ - | :const:`m_i` | ion mass | - +----------------+---------------------------------------------+ - | :const:`n` | plasma number density | - +----------------+---------------------------------------------+ - | :const:`n_e` | electron number density | - +----------------+---------------------------------------------+ - | :const:`n_i` | ion number density | - +----------------+---------------------------------------------+ - | :const:`Z` | ion charge number | - +----------------+---------------------------------------------+ - | Calculated Values | - +----------------+---------------------------------------------+ - | :const:`fce` | electron cyclotron frequency | - +----------------+---------------------------------------------+ - | :const:`fci` | ion cyclotron frequency | - +----------------+---------------------------------------------+ - | :const:`fpe` | electron plasma frequency | - +----------------+---------------------------------------------+ - | :const:`fpi` | ion plasma frequency | - +----------------+---------------------------------------------+ - | :const:`fUH` | Upper-Hybrid Resonance frequency | - +----------------+---------------------------------------------+ - | :const:`lD` | Debye Length | - +----------------+---------------------------------------------+ - | :const:`lpe` | electron-inertial length | - +----------------+---------------------------------------------+ - | :const:`lpi` | ion-inertial length | - +----------------+---------------------------------------------+ - | :const:`rce` | electron gyroradius | - +----------------+---------------------------------------------+ - | :const:`rci` | ion gyroradius | - +----------------+---------------------------------------------+ - | :const:`cs` | ion sound speed | - +----------------+---------------------------------------------+ - | :const:`VA` | Alfven speed | - +----------------+---------------------------------------------+ - | :const:`vTe` | electron thermal velocity | - +----------------+---------------------------------------------+ - | :const:`vTi` | ion thermal velocity | - +----------------+---------------------------------------------+ - """ - return self._plasma - - def set_plasma(self, Bo, kTe, kTi, m_i, n_e, Z, gamma=None, - **kwargs): # pragma: no cover - """ - Set :attr:`plasma` and add key frequency, length, and velocity - parameters. (all quantities in cgs except temperature is in eV) - - :param float Bo: magnetic field (in Gauss) - :param float kTe: electron temperature (in eV) - :param float kTi: ion temperature (in eV) - :param float m_i: ion mass (in g) - :param float n_e: electron number density (in cm^-3) - :param int Z: ion charge number - :param float gamma: adiabatic index (arb.) - """ - # define base values - self._plasma['Bo'] = core.FloatUnit(Bo, 'G') - self._plasma['kTe'] = core.FloatUnit(kTe, 'eV') - self._plasma['kTi'] = core.FloatUnit(kTi, 'eV') - self._plasma['m_i'] = core.FloatUnit(m_i, 'g') - self._plasma['n_e'] = core.FloatUnit(n_e, 'cm^-3') - self._plasma['Z'] = core.IntUnit(Z, 'arb') - - # define ion number density - self._plasma['n_i'] = core.FloatUnit( - self._plasma['n_e'] / self._plasma['Z'], 'cm^-3') - - # define gamma (adiabatic index) - # - default = 1.0 - if gamma is not None: - self._plasma['gamma'] = core.FloatUnit(gamma, 'arb') - - # define plasma temperature - # - if omitted then assumed kTe - # TODO: double check assumption - if 'kT' in kwargs: - self._plasma['kT'] = core.FloatUnit(kwargs['kT'], 'eV') - else: - self._plasma['kT'] = core.FloatUnit(kTe, 'eV') - - # define plasma number density - # - if omitted then assumed n_e - if 'n' in kwargs: - self._plasma['n'] = core.FloatUnit(kwargs['n'], 'cm^-3') - else: - self._plasma['n'] = core.FloatUnit(n_e, 'cm^-3') - - # add key plasma constants - self._update_plasma_constants() - - def set_plasma_value(self, key, value): # pragma: no cover - """ - Re-define one of the base plasma values (Bo, gamma, kT, kTe, - kTi, m_i, n, n_e, or Z) in the :attr:`plasma` dictionary. - - :param str key: one of the base plasma values - :param value: value for key - """ - # set plasma value - if key == 'Bo': - self._plasma['Bo'] = core.FloatUnit(value, 'G') - elif key == 'gamma': - self._plasma['gamma'] = core.FloatUnit(value, 'arb') - elif key in ['kT', 'kTe', 'kTi']: - self._plasma[key] = core.FloatUnit(value, 'eV') - - if key == 'kTe' and self._plasma['kt'] is None: - self._plasma['kT'] = self._plasma[key] - elif key == 'm_i': - self._plasma[key] = core.FloatUnit(value, 'g') - elif key in ['n', 'n_e']: - self._plasma[key] = core.FloatUnit(value, 'cm^-3') - - # re-calc n_i and n - if key == 'n_e': - self._plasma['n_i'] = core.FloatUnit( - self._plasma['n_e'] / self._plasma['Z'], 'cm^-3') - - if self._plasma['n'] is None: - self._plasma['n'] = self._plasma['n_e'] - elif key == 'Z': - self._plasma[key] = core.IntUnit(value, 'arb') - - # re-calc n_i - self._plasma['n_i'] = \ - core.FloatUnit(self._plasma['n_e'] / self._plasma['Z'], - 'cm^-3') - - # update key plasma constants - self._update_plasma_constants() - - def _update_plasma_constants(self): # pragma: no cover - """ - Updates the calculated plasma constants (fci, fce, fpe, etc.) in - :attr:`plasma`. - """ - # add key frequencies - self._plasma['fce'] = core.fce(**self._plasma) - self._plasma['fci'] = core.fci(**self._plasma) - self._plasma['fpe'] = core.fpe(**self._plasma) - self._plasma['fpi'] = core.fpi(**self._plasma) - self._plasma['fUH'] = core.fUH(**self._plasma) - self._plasma['fLH'] = core.fLH(**self._plasma) - - # add key lengths - self._plasma['lD'] = core.lD(**self._plasma) - self._plasma['lpe'] = core.lpe(**self._plasma) - self._plasma['lpi'] = core.lpi(**self._plasma) - self._plasma['rce'] = core.rce(**self._plasma) - self._plasma['rci'] = core.rci(**self._plasma) - - # add key velocities - self._plasma['cs'] = core.cs(**self._plasma) - self._plasma['VA'] = core.VA(**self._plasma) - self._plasma['vTe'] = core.vTe(**self._plasma) - self._plasma['vTi'] = core.vTi(**self._plasma) - # add example to __new__ docstring HDFReadData.__new__.__doc__ += "\n" for line in HDFReadData.__example_doc__.splitlines(): HDFReadData.__new__.__doc__ += " " + line + "\n" - - -''' -def condition_shotnum(shotnum, dheader, shotnumkey, - intersection_set): - """ - Conditions **shotnum** against the digitizer header dataset. - - :param shotnum: desired HDF5 shot number(s) - :type shotnum: list(int) - :param dheader: digitizer header dataset - :param dheader: :class:`h5py.Dataset` - :param str shotnumkey: field name in **dheader** that contains the - shot numbers - :param bool intersection_set: Set :code:`True` to intersect - **shotnum** with the shot numbers in :code:`dheader[shotnumkey]` - :return: index, shotnum, sni - - .. note:: - - The returned :class:`numpy.ndarray`'s (:const:`index`, - :const:`shotnum`, and :const:`sni`) follow the rule:: - - shotnum[sni] = dheader[index, shotnumkey] - """ - # Inputs: - # shotnum list(int) - the desired shot number(s) - # cheader h5py.Dataset - the digi header dataset - # shotnumkey str - field name for the shot number - # column in dheader - # intersection_set bool - intersect shotnum with - # dheader[shotnumkey] - # - # Returns: - # index np.array(dtype=uint32) - cdset row index for the - # specified shotnum - # shotnum np.array(dtype=uint32) - shot numbers - # sni np.array(dtype=bool) - shotnum mask such that: - # shotnum[sni] = cdset[index, shotnumkey] - # - # remove shot numbers less-than or equal to 0 - shotnum.sort() - shotnum = list(set(shotnum)) - shotnum.sort() - if min(shotnum) <= 0: - # remove values less-than or equal to 0 - new_sn = [sn for sn in shotnum if sn > 0] - shotnum = new_sn - - # remove shot numbers greater-than largest shot number - # in dataset - if intersection_set: - last_sn = dheader[-1, shotnumkey] - if max(shotnum) > last_sn: - new_sn = [sn for sn in shotnum if sn <= last_sn] - shotnum = new_sn - - # ensure shotnum is not empty - if len(shotnum) == 0: - raise ValueError('Valid shotnum not passed.') - - # convert shotnum to np.array - # - shotnum is always a list up to this point - shotnum = np.array(shotnum).view() - - # Calc. corresponding `index` and `sni` for shotnum - # - intersection will after initial calculation - # - if dheader.shape[0] == 1: - # only one possible shot number - only_sn = dheader[0, shotnumkey] - sni = np.where(shotnum == only_sn, True, False) - index = np.array([0]) \ - if True in sni else np.empty(shape=0, dtype=np.uint32) - else: - # get 1st and last shot number for further - # conditioning - first_sn, last_sn = dheader[[-1, 0], shotnumkey] - - if last_sn - first_sn + 1 == dheader.shape[0]: - # shot numbers are sequential - index = shotnum - first_sn - - # build sni - # - this will also mask index s.t. index has - # no values outside dheader.shape - sni = np.where(index < dheader.shape[0], True, False) - index = index[sni] - else: - # shot numbers are NOT sequential - # TODO: check for more efficient read in methods - # - step_front_read = shotnum[-1] - first_sn - step_end_read = last_sn - shotnum[0] - - if dheader.shape[0] <= 1 + min(step_front_read, - step_end_read): - # dheader.shape is smaller than the - # theoretical sequential reads from either - # end of the array - dset_sn = dheader[shotnumkey].view() - sni = np.isin(shotnum, dset_sn) - - # define index - index = np.where(np.isin(dset_sn, shotnum))[0] - elif step_front_read <= step_end_read: - # extracting from the beginning of the array is the - # smallest - some_dset_sn = dheader[0:step_front_read + 1, - shotnumkey] - sni = np.isin(shotnum, some_dset_sn) - - # define index - index = np.where(np.isin(some_dset_sn, shotnum))[0] - else: - # extracting from the end of the array is the smallest - start, stop, step = \ - slice(-step_end_read - 1, - None, - None).indices(dheader.shape[0]) - some_dset_sn = dheader[start::, shotnumkey] - sni = np.isin(shotnum, some_dset_sn) - - # define index - # NOTE: if index is empty (i.e. index.shape[0] == 0) - # then adding an int still returns an empty array - index = np.where(np.isin(some_dset_sn, shotnum))[0] - index += start - - # filter shotnum and ensure obj will not be empty - if intersection_set: - # check for empty shotnum - if True not in sni: - raise ValueError( - 'Input shotnum would result in a null array') - - # filter - shotnum = shotnum[sni] - sni = np.ones(shotnum.shape, dtype=bool) - else: - # empty shotnum - if shotnum.shape[0] == 0: - raise ValueError( - 'Input shotnum would result in a null array') - - # return calculated arrays - return index.view(), shotnum.view(), sni.view() -''' diff --git a/bapsflib/lapd/constants/constants.py b/bapsflib/lapd/constants/constants.py index 7f8bf456..99e68e59 100644 --- a/bapsflib/lapd/constants/constants.py +++ b/bapsflib/lapd/constants/constants.py @@ -18,26 +18,21 @@ from typing import Tuple -class BaPSFConstant(Constant): - """BaPSF Constant""" - default_reference = 'Basic Plasma Science Facility' +class LaPDConstant(Constant): + """LaPD Constant""" + default_reference = 'Large Plasma Device' _registry = {} _has_incompatible_units = set() - def __new__(cls, abbrev, name, value, unit, uncertainty, - reference=default_reference, system=None): - return super().__new__(cls, abbrev, name, value, unit, - uncertainty, reference, system) - -#: BaPSF Constant: nominal distance between LaPD ports -port_spacing = BaPSFConstant('port_spacing', 'LaPD port spacing', - 31.95, 'cm', 1.0, system='cgs') +#: LaPD Constant: nominal distance between LaPD ports +port_spacing = LaPDConstant('port_spacing', 'LaPD port spacing', + 31.95, 'cm', 1.0, system='cgs') port_spacing.__doc__ += ': nominal distance between LaPD ports' -#: BaPSF Constant: LaPD :math:`z = 0` reference port (most Northern +#: LaPD Constant: LaPD :math:`z = 0` reference port (most Northern #: port and :math:`+z` points South towards south cathode) -ref_port = BaPSFConstant('ref_port', 'LaPD reference port number', 53, +ref_port = LaPDConstant('ref_port', 'LaPD reference port number', 53, u.dimensionless_unscaled, 0, system=None) ref_port.__doc__ += (": LaPD :math:`z = 0` reference port (most " "Northern port and :math:`+z` points South " @@ -81,19 +76,19 @@ def operation_date(self, val: datetime.datetime): warnings.simplefilter('ignore', AstropyWarning) if val.year <= val.year: - self._diameter = BaPSFConstant( + self._diameter = LaPDConstant( 'diameter', "Diameter of LaPD's south 'main' cathode", 60.0, 'cm', 1.0, system='cgs' ) - self._z = BaPSFConstant( + self._z = LaPDConstant( 'z', "Axial location of LaPD's south 'main' cathode", 1700.0, 'cm', 1.0, system='cgs' ) - self._anode_z = BaPSFConstant( + self._anode_z = LaPDConstant( 'anode_z', "Axial location of LaPD's south 'main' anode", 1650.0, 'cm', @@ -103,17 +98,17 @@ def operation_date(self, val: datetime.datetime): self._lifespan = (NotImplemented, NotImplemented) @property - def anode_z(self) -> BaPSFConstant: + def anode_z(self) -> LaPDConstant: """LaPD z location of the anode""" return self._anode_z @property - def diameter(self) -> BaPSFConstant: + def diameter(self) -> LaPDConstant: """Diameter of LaPD's south 'main' cathode""" return self._diameter @property - def z(self) -> BaPSFConstant: + def z(self) -> LaPDConstant: """LaPD z location of the cathode""" return self._z diff --git a/bapsflib/lapd/constants/tests/test_constants.py b/bapsflib/lapd/constants/tests/test_constants.py index 9876f74e..1987bb4c 100644 --- a/bapsflib/lapd/constants/tests/test_constants.py +++ b/bapsflib/lapd/constants/tests/test_constants.py @@ -16,7 +16,7 @@ from astropy.constants import Constant from bapsflib.lapd.constants import (port_spacing, ref_port) -from bapsflib.lapd.constants.constants import BaPSFConstant +from bapsflib.lapd.constants.constants import LaPDConstant class TestConstants(ut.TestCase): @@ -24,19 +24,19 @@ class TestConstants(ut.TestCase): Test LaPD constants. (:mod:`bapsflib.lapd.constants.constants`) """ - def test_BaPSFConstant(self): - self.assertTrue(issubclass(BaPSFConstant, Constant)) - self.assertEqual(BaPSFConstant.default_reference, - "Basic Plasma Science Facility") + def test_LaPDConstant(self): + self.assertTrue(issubclass(LaPDConstant, Constant)) + self.assertEqual(LaPDConstant.default_reference, + "Large Plasma Device") def test_port_spacing(self): - self.assertIsInstance(port_spacing, BaPSFConstant) + self.assertIsInstance(port_spacing, LaPDConstant) self.assertEqual(port_spacing.value, 31.95) self.assertEqual(port_spacing.unit, u.cm) self.assertEqual(port_spacing.uncertainty, 1.0) def test_ref_port(self): - self.assertIsInstance(ref_port, BaPSFConstant) + self.assertIsInstance(ref_port, LaPDConstant) self.assertEqual(ref_port.value, 53) self.assertEqual(ref_port.unit, u.dimensionless_unscaled) diff --git a/bapsflib/plasma/__init__.py b/bapsflib/plasma/__init__.py index f249af05..ea620562 100644 --- a/bapsflib/plasma/__init__.py +++ b/bapsflib/plasma/__init__.py @@ -8,6 +8,7 @@ # License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full # license terms and contributor agreement. # -__all__ = ['core'] +__all__ = ['constants', 'parameters'] -from . import core +from . import constants +from . import parameters diff --git a/bapsflib/plasma/constants.py b/bapsflib/plasma/constants.py new file mode 100644 index 00000000..d82552e0 --- /dev/null +++ b/bapsflib/plasma/constants.py @@ -0,0 +1,101 @@ +# This file is part of the bapsflib package, a Python toolkit for the +# BaPSF group at UCLA. +# +# http://plasma.physics.ucla.edu/ +# +# Copyright 2017-2019 Erik T. Everson and contributors +# +# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full +# license terms and contributor agreement. +# +"""Useful Physical Constants + +Constants are imported from :mod:`astropy.constants.codata2014`. +""" +__all__ = ['c', + 'e', 'e_gauss', + 'eps0', + 'g0', + 'k_B', + 'm_e', 'm_n', 'm_p', 'u', + 'mu0', + 'pi'] + +from astropy.constants.codata2014 import ( + c, + e, e_gauss, + eps0, + g0, + k_B, + m_e, m_n, m_p, u, + mu0, +) +from bapsflib.utils import BaPSFConstant +from numpy import pi + +# rename some attributes for clarity +e = BaPSFConstant(**{ + 'abbrev': 'e', + 'name': 'Elementary charge', + 'value': e.value, + 'unit': e.unit.name, + 'uncertainty': e.uncertainty, + 'system': e.system, +}) +e_gauss = BaPSFConstant(**{ + 'abbrev': 'e_gauss', + 'name': 'Elementary charge', + 'value': e_gauss.value, + 'unit': e_gauss.unit.name, + 'uncertainty': e_gauss.uncertainty, + 'system': e_gauss.system, +}) +u = BaPSFConstant(**{ + 'abbrev': 'u', + 'name': 'Atomic mass unit', + 'value': u.value, + 'unit': u.unit.name, + 'uncertainty': u.uncertainty, + 'system': u.system, +}) + +# clean up +del BaPSFConstant + +# The following code is modified from astropy.constants to produce a +# table containing information on the constants. + +# initialize table +_tb_div = ((8 * '=') + ' ' + + (17 * '=') + ' ' + + (10 * '=') + ' ' + + (7 * '=') + ' ' + + (44 * '=')) +_lines = [ + 'The following constants are available:\n', + _tb_div, + '{0:^8} {1:^17} {2:^10} {3:^7} {4}'.format('Name', 'Value', 'Units', + 'System', 'Description'), + _tb_div, + '{0:^8} {1:^17.12f} {2:^10} {3:^7} {4}'.format( + 'pi', pi, '', '', + 'Ratio of circumference to diameter of circle'), +] + +# add lines to table +_constants = [eval(item) for item in dir() if item[0] != '_' and item != 'pi'] +_const = None +for _const in _constants: + _lines.append('{0:^8} {1:^17.12g} {2:^10} {3:^7} {4}' + .format(_const.abbrev, _const.value, + _const._unit_string, _const.system, + _const.name)) + +_lines.append(_lines[1]) + +# add table to docstrings +__doc__ += '\n' +__doc__ += '\n'.join(_lines) + +# remove clutter from namespace +del _const, _constants, _lines, _tb_div, diff --git a/bapsflib/plasma/core.py b/bapsflib/plasma/core.py index 782b52ff..e69de29b 100644 --- a/bapsflib/plasma/core.py +++ b/bapsflib/plasma/core.py @@ -1,463 +0,0 @@ -# This file is part of the bapsflib package, a Python toolkit for the -# BaPSF group at UCLA. -# -# http://plasma.physics.ucla.edu/ -# -# Copyright 2017-2018 Erik T. Everson and contributors -# -# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full -# license terms and contributor agreement. -# -# TODO: add plasma betas (electron, ion, and total) -# TODO: add Coulomb Logarithm -# TODO: add collision frequencies -# TODO: add mean-free-paths -# -"""Core plasma parameters in (cgs).""" - -import math - -from scipy import constants - -pconst = constants.physical_constants - - -class FloatUnit(float): - """Template class for floats with a unit attribute.""" - - def __new__(cls, value, cgs_unit): - """ - :param float value: value of constant - :param str cgs_unit: string representation of of cgs unit - :return: value of constant - :rtype: float - """ - obj = super().__new__(cls, value) - obj._unit = cgs_unit - return obj - - def __init__(self, value, cgs_unit): - super().__init__() - - @property - def unit(self): - """units of constant""" - return self._unit - - -class IntUnit(int): - """Template class for ints with a unit attribute.""" - - def __new__(cls, value, cgs_unit): - """ - :param int value: value of constant - :param str cgs_unit: string representation of of cgs unit - :return: value of constant - :rtype: int - """ - obj = super().__new__(cls, value) - obj._unit = cgs_unit - return obj - - def __init__(self, value, cgs_unit): - super().__init__() - - @property - def unit(self): - """units of constant""" - return self._unit - - -#: atomic mass unit (g) -AMU = FloatUnit(1000.0 * constants.m_u, 'g') - -#: speed of light (cm/s) -C = FloatUnit(100.0 * constants.c, 'cm s^-1') - -#: fundamental charge (statcoul) -E = FloatUnit(4.8032e-10, 'statcoul') - -#: Boltzmann constant (erg/K) -KB = FloatUnit(1.3807e-16, 'erg k^-1') - -#: electron mass (g) -ME = FloatUnit(1000.0 * constants.m_e, 'g') - -#: proton mass (g) -MP = FloatUnit(1000.0 * constants.m_p, 'g') - - -# ---- frequency constants ---- -def fce(Bo, **kwargs): - """ - electron-cyclotron frequency (Hz) - - .. math:: - - f_{ce} = \\frac{\Omega_{ce}}{2 \pi} - = -\\frac{|e| B_{o}}{2 \pi m_{e} c} - - :param float Bo: magnetic field (in Gauss) - - .. note:: see function :func:`oce` - """ - _fce = oce(Bo) / (2.0 * math.pi) - return FloatUnit(_fce, 'Hz') - - -def fci(Bo, m_i, Z, **kwargs): - """ - ion-cyclotron frequency (Hz) - - .. math:: - - f_{ci} = \\frac{\Omega_{ci}}{2 \pi} - = \\frac{Z |e| B_{o}}{2 \pi m_{i} c} - - :param float Bo: magnetic-field (in Gauss) - :param float m_i: ion-mass (in g) - :param int Z: charge number - - .. note:: see function :func:`oci` - """ - _fci = oci(Bo, m_i, Z) / (2.0 * constants.pi) - return FloatUnit(_fci, 'Hz') - - -def fLH(Bo, m_i, n_i, Z, **kwargs): - """ - Lower-Hybrid Resonance frequency (Hz) - - .. math:: - f_{LH} = \\frac{\omega_{LH}}{2 \pi} - - :param float Bo: magnetic field (in Gauss) - :param float m_i: ion mass (in g) - :param float n_i: ion number density (in :math:`cm^{-3}`) - :param int Z: ion charge number - - .. note:: for details see function :func:`oLH` - """ - _fLH = oLH(Bo, m_i, n_i, Z) / (2.0 * math.pi) - return FloatUnit(_fLH, 'Hz') - - -def fpe(n_e, **kwargs): - """ - electron-plasma frequency (Hz) - - .. math:: - - f_{pe} = \\frac{\omega_{pe}}{2 \pi} - = \sqrt{\\frac{n_{e} e^{2}}{\pi m_{e}}} - - :param float n_e: electron number density (in :math:`cm^{-3}`) - - .. note:: see function :func:`ope` - """ - _fpe = ope(n_e) / (2.0 * math.pi) - return FloatUnit(_fpe, 'Hz') - - -def fpi(m_i, n_i, Z, **kwargs): - """ - ion-plasma frequency (Hz) - - .. math:: - - f_{pi} = \\frac{\omega_{pi}}{2 \pi} - = \sqrt{\\frac{n_{i} (Z e)^{2}}{\pi m_{i}}} - - :param float m_i: ion mass (in g) - :param float n_i: ion number density (in :math:`cm^{-3}`) - :param int Z: ion charge number - - .. note:: see function :func:`opi` - """ - _fpi = opi(m_i, n_i, Z) / (2.0 * math.pi) - return FloatUnit(_fpi, 'Hz') - - -def fUH(Bo, n_e, **kwargs): - """ - Upper-Hybrid Resonance frequency (Hz) - - .. math:: - - f_{UH} = \\frac{\omega_{UH}}{2 \pi} - - :param float Bo: magnetic field (in Gauss) - :param float n_e: electron number density (in :math:`cm^{-3}`) - - .. note:: see function :func:`oUH` - """ - _fUH = oUH(Bo, n_e) / (2.0 * math.pi) - return FloatUnit(_fUH, 'Hz') - - -def oce(Bo, **kwargs): - """ - electron-cyclotron frequency (rad/s) - - .. math:: - - \Omega_{ce} = -\\frac{|e| B_{o}}{m_{e} c} - - :param float Bo: magnetic-field (in Gauss) - """ - _oce = (-E * Bo) / (ME * C) - return FloatUnit(_oce, 'rad s^-1') - - -def oci(Bo, m_i, Z, **kwargs): - """ - ion-cyclotron frequency (rads / s) - - .. math:: - - \Omega_{ci} = \\frac{Z |e| B_{o}}{m_{i} c} - - :param float Bo: magnetic-field (in Gauss) - :param float m_i: ion-mass (in g) - :param int Z: charge number - """ - _oci = (Z * E * Bo) / (m_i * C) - return FloatUnit(_oci, 'rad s^-1') - - -def oLH(Bo, m_i, n_i, Z, **kwargs): - """ - Lower-Hybrid Resonance frequency (rad/s) - - .. math:: - \\frac{1}{\omega_{LH}^{2}}= - \\frac{1}{\Omega_{i}^{2}+\omega_{pi}^{2}} - + \\frac{1}{\\lvert \Omega_{e}\Omega_{i} \\rvert} - - .. note:: - - This form is for a quasi-neutral plasma that satisfies - - .. math:: - \\frac{Z m_{e}}{m_{i}} \ll - 1 - \\left(\\frac{V_{A}}{c}\\right)^{2} - - :param float Bo: magnetic field (in Gauss) - :param float m_i: ion mass (in g) - :param float n_i: ion number density (in :math:`cm^{-3}`) - :param int Z: ion charge number - """ - _args = {'Bo': Bo, 'm_i': m_i, 'n_i': n_i, 'Z': Z} - _opi = opi(**_args) - _oce = oce(**_args) - _oci = oci(**_args) - first_term = 1.0 / ((_oci ** 2) + (_opi ** 2)) - second_term = 1.0 / math.fabs(_oce * _oci) - _olh = math.sqrt(1.0 / (first_term + second_term)) - return FloatUnit(_olh, 'rad s^-1') - - -def ope(n_e, **kwargs): - """ - electron-plasma frequency (in rad/s) - - .. math:: - - \omega_{pe}^{2} = \\frac{4 \pi n_{e} e^2}{m_{e}} - - :param float n_e: electron number density (in :math:`cm^{-3}`) - """ - _ope = math.sqrt(4 * math.pi * n_e * E * E / ME) - return FloatUnit(_ope, 'rad s^-1') - - -def opi(m_i, n_i, Z, **kwargs): - """ - ion-plasma frequency (in rad/s) - - .. math:: - - \omega_{pi}^{2} = \\frac{4 \pi n_{i} (Z e)^{2}}{m_{i}} - - :param float m_i: ion mass (in g) - :param float n_i: ion number density (in :math:`cm^{-3}`) - :param int Z: ion charge number - """ - _opi = math.sqrt(4 * math.pi * n_i * (Z * E) * (Z * E) / m_i) - return FloatUnit(_opi, 'rad s^-1') - - -def oUH(Bo, n_e, **kwargs): - """ - Upper-Hybrid Resonance frequency (rad/s) - - .. math:: - - \omega_{UH}^{2} =\omega_{pe}^{2} + \Omega_{ce}^{2} - - :param float Bo: magnetic field (in Gauss) - :param float n_e: electron number density (in :math:`cm^{-3}`) - """ - _ope = ope(n_e) - _oce = oce(Bo) - _ouh = math.sqrt((_ope ** 2) + (_oce ** 2)) - return FloatUnit(_ouh, 'rad s^-1') - - -# ---- length constants ---- -def lD(kT, n, **kwargs): - """ - Debye Length (in cm) - - .. math:: - - \lambda_{D} = \sqrt{\\frac{k_{B} T}{4 \pi n e^{2}}} - - :param float kT: temperature (in eV) - :param float n: number density (in :math:`cm^{-3}`) - """ - kT = kT * constants.e * 1.e7 # eV to ergs - _lD = math.sqrt(kT / (4.0 * math.pi * n)) / E - return FloatUnit(_lD, 'cm') - - -def lpe(n_e, **kwargs): - """ - electron-inertial length (cm) - - .. math:: - - l_{pe} = \\frac{c}{\omega_{pe}} - - :param float n_e: electron number density (in :math:`cm^{-3}`) - - .. note:: see function :func:`ope` - """ - _lpe = C / ope(n_e) - return FloatUnit(_lpe, 'cm') - - -def lpi(m_i, n_i, Z, **kwargs): - """ - ion-inertial length (cm) - - .. math:: - - l_{pi} = \\frac{c}{\omega_{pi}} - - :param float m_i: ion mass (in g) - :param float n_i: ion number density (in :math:`cm^{-3}`) - :param int Z: ion charge number - - .. note:: see function :func:`opi` - """ - _lpi = C / opi(m_i, n_i, Z) - return FloatUnit(_lpi, 'cm') - - -def rce(Bo, kTe, **kwargs): - """ - electron gyroradius (cm) - - .. math:: - - r_{ce} = \\frac{v_{T_{e}}}{\Omega_{ce}} - - :param float Bo: magnetic field (in Gauss) - :param float kTe: electron temperature (in eV) - - .. note:: see functions :func:`vTe` and :func:`oce` - """ - _rce = vTe(kTe) / abs(oce(Bo)) - return FloatUnit(_rce, 'cm') - - -def rci(Bo, kTi, m_i, Z, **kwargs): - """ - ion gyroradius (cm) - - .. math:: - - r_{ci} = \\frac{v_{T_{i}}}{\Omega_{ci}} - - :param float Bo: magnetic field (in Gauss) - :param float kTi: ion temperature (in eV) - :param float m_i: ion mass (in g) - :param int Z: ion charge number - - .. note:: see functions :func:`vTi` and :func:`oci` - """ - _rci = vTi(kTi, m_i) / oci(Bo, m_i, Z) - return FloatUnit(_rci, 'cm') - - -# ---- velocity constants ---- -def cs(kTe, m_i, Z, gamma=1.0, **kwargs): - """ - ion sound speed (cm/s) - - .. math:: - - C_{s} = \sqrt{\\frac{\gamma Z k T_{e}}{m_{i}}} - - .. note:: - - :math:`\gamma=1` for the case of :math:`T_{i}\ll T_{e}` - (DEFAULT) - - :param float kTe: electron temperature (in eV) - :param float m_i: ion mass (in g) - :param int Z: charge number - :param float gamma: adiabatic index - """ - # TODO: double check adiabatic index default value - kTe = kTe * constants.e * 1.e7 # eV to ergs - _cs = math.sqrt(gamma * Z * kTe / m_i) - return FloatUnit(_cs, 'cm s^-1') - - -def VA(Bo, m_i, n_i, **kwargs): - """ - Alfvén Velocity (in cm/s) - - .. math:: - - V_{A} = \\frac{B_{o}}{\sqrt{4 \pi n_{i} m_{i}}} - - :param float Bo: magnetic field (in Gauss) - :param float m_i: ion mass (in g) - :param float n_i: ion number density (in :math:`cm^{-3}`) - """ - _VA = Bo / math.sqrt(4.0 * math.pi * n_i * m_i) - return FloatUnit(_VA, 'cm s^-1') - - -def vTe(kTe, **kwargs): - """ - electron thermal velocity (in cm/s) - - .. math:: - - v_{T_{e}} = \sqrt{\\frac{k T_{e}}{m_{e}}} - - :param float kTe: electron temperature (in eV) - """ - kTe = kTe * constants.e * 1.e7 # eV to erg - _vTe = math.sqrt(kTe / ME) - return FloatUnit(_vTe, 'cm s^-1') - - -def vTi(kTi, m_i, **kwargs): - """ - ion thermal velocity (in cm/s) - - .. math:: - - v_{T_{i}} = \sqrt{\\frac{k T_{i}}{m_{i}}} - - :param float kTi: ion temperature (in eV) - :param float m_i: ion mass (in g) - """ - kTi = kTi * constants.e * 1.e7 # eV to erg - _vTi = math.sqrt(kTi / m_i) - return FloatUnit(_vTi, 'cm s^-1') \ No newline at end of file diff --git a/bapsflib/plasma/parameters.py b/bapsflib/plasma/parameters.py new file mode 100644 index 00000000..f84873e7 --- /dev/null +++ b/bapsflib/plasma/parameters.py @@ -0,0 +1,679 @@ +# This file is part of the bapsflib package, a Python toolkit for the +# BaPSF group at UCLA. +# +# http://plasma.physics.ucla.edu/ +# +# Copyright 2017-2018 Erik T. Everson and contributors +# +# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full +# license terms and contributor agreement. +# +# TODO: add plasma betas (electron, ion, and total) +# TODO: add Coulomb Logarithm +# TODO: add collision frequencies +# TODO: add mean-free-paths +# TODO: add examples to docstrings +# +""" +Plasma parameters + +All units are in Gaussian cgs except for temperature, which is +expressed in eV. (same as the NRL Plasma Formulary) +""" + +__all__ = ['cyclotron_frequency', 'oce', 'oci', + 'lower_hybrid_frequency', 'oLH', + 'plasma_frequency', 'ope', 'opi', + 'upper_hybrid_frequency', 'oUH', + 'cyclotron_radius', 'rce', 'rci', + 'Debye_length', 'lD', + 'inertial_length', 'lpe', 'lpi', + 'Alfven_speed', 'VA', + 'ion_sound_speed', 'cs', + 'thermal_speed', 'vTe', 'vTi', + 'beta'] + +import astropy.units as u +import numpy as np + +from . import constants as const +from bapsflib.utils.decorators import (check_quantity, + check_relativistic) +from bapsflib.utils.errors import PhysicsError +from typing import Union + + +# ---- Frequencies ---- +@check_quantity(**{'q': {'units': u.statcoulomb, + 'can_be_negative': True}, + 'B': {'units': u.Gauss, + 'can_be_negative': False}, + 'm': {'units': u.g, + 'can_be_negative': False}}) +def cyclotron_frequency(q: u.Quantity, B: u.Quantity, m: u.Quantity, + to_Hz=False, **kwargs) -> u.Quantity: + """ + generalized cyclotron frequency (rad/s) + + .. math:: + + \\Omega_{c} = \\frac{q B}{m c} + + :param q: particle charge (in statcoulomb) + :param B: magnetic-field (in Gauss) + :param m: particle mass (in grams) + :param to_Hz: :code:`False` (DEFAULT). Set to :code:`True` to + return frequency in Hz (i.e. divide by :math:`2 * \\pi`) + """ + # ensure args have correct units + q = q.to(u.Fr) + B = B.to(u.gauss) + m = m.to(u.g) + + # calculate + _oc = ((q.value * B.value) / (m.value * const.c.cgs.value)) + if to_Hz: + _oc = (_oc / (2.0 * const.pi)) * u.Hz + else: + _oc = _oc * (u.rad / u.s) + return _oc + + +@check_quantity(**{'B': {'units': u.gauss, + 'can_be_negative': False}, + 'n_i': {'units': u.cm ** -3, + 'can_be_negative': False}, + 'm_i': {'units': u.g, + 'can_be_negative': False}}) +def lower_hybrid_frequency(B: u.Quantity, n_i: u.Quantity, + m_i: u.Quantity, Z: Union[int, float] = 1, + to_Hz=False, **kwargs) -> u.Quantity: + """ + Lower-Hybrid resonance Frequency (rad/s) + + .. math:: + \\frac{1}{\\omega_{LH}^{2}}= + \\frac{1}{\\Omega_{ci}^{2}+\\omega_{pi}^{2}} + + \\frac{1}{\\lvert \\Omega_{ce}\\Omega_{ci} \\rvert} + + .. note:: + + This form is for a quasi-neutral plasma that satisfies + + .. math:: + \\frac{Z m_{e}}{m_{i}} \\ll + 1 - \\left(\\frac{V_{A}}{c}\\right)^{2} + + :param B: magnetic field (in Gauss) + :param m_i: ion mass (in g) + :param n_i: ion number density (in :math:`cm^{-3}`) + :param Z: ion charge number (:math:`Z=1` DEFAULT) + :param to_Hz: :code:`False` (DEFAULT). Set to :code:`True` to + return frequency in Hz (i.e. divide by :math:`2 * \\pi`) + """ + # condition Z + if not isinstance(Z, (int, np.integer, float, np.floating)): + raise TypeError('Z must be of type int or float') + elif Z <= 0: + raise PhysicsError( + 'The ion charge number Z must be greater than 0.') + + # calculate + _oci = oci(B, m_i, Z=Z, to_Hz=to_Hz, **kwargs) + _opi = opi(n_i, m_i, Z=Z, to_Hz=to_Hz, **kwargs) + _oce = oce(B, to_Hz=to_Hz, **kwargs) + first_term = 1.0 / ((_oci ** 2) + (_opi ** 2)) + second_term = 1.0 / np.abs(_oce * _oci) + _olh = np.sqrt(1.0 / (first_term + second_term)) + return _olh + + +def oce(B: u.Quantity, **kwargs) -> u.Quantity: + """ + electron-cyclotron frequency (rad/s) + + .. math:: + + \\Omega_{ce} = -\\frac{|e| B}{m_{e} c} + + :param B: magnetic-field (in Gauss) + :param kwargs: supports any keywords used by + :func:`cyclotron_frequency` + """ + return cyclotron_frequency(-const.e_gauss, B, const.m_e.cgs, + **kwargs['kwargs']) + + +def oci(B: u.Quantity, m_i: u.Quantity, Z: Union[int, float] = 1, + **kwargs) -> u.Quantity: + """ + ion-cyclotron frequency (rad/s) + + .. math:: + + \\Omega_{ci} = \\frac{Z |e| B}{m_{i} c} + + :param B: magnetic-field (in Gauss) + :param m_i: ion mass (in grams) + :param Z: ion charge number (:math:`Z=1` DEFAULT) + :param kwargs: supports any keywords used by + :func:`cyclotron_frequency` + """ + # condition Z + if not isinstance(Z, (int, np.integer, float, np.floating)): + raise TypeError('Z must be of type int or float') + elif Z <= 0: + raise PhysicsError( + 'The ion charge number Z must be greater than 0.') + + return cyclotron_frequency(Z * const.e_gauss, B, m_i, + **kwargs['kwargs']) + + +def oLH(B: u.Quantity, n_i: u.Quantity, m_i: u.Quantity, + Z: Union[int, float] = None, to_Hz: bool = False, + **kwargs) -> u.Quantity: + """ + Lower-Hybrid resonance frequency (rad/s) -- + [alias for :func:`lower_hybrid_frequency`] + """ + # add specified keywords to kwargs + for name, val in zip(('Z', 'to_Hz'), (Z, to_Hz)): + if val is not None: + kwargs[name] = val + + return lower_hybrid_frequency(B, m_i, n_i, **kwargs) + + +def ope(n_e: u.Quantity, **kwargs) -> u.Quantity: + """ + electron-plasma frequency (in rad/s) + + .. math:: + + \\omega_{pe}^{2} = \\frac{4 \\pi n_{e} e^{2}}{m_e} + + :param n_e: electron number density (in :math:`cm^{-3}`) + :param kwargs: supports any keywords used by + :func:`plasma_frequency` + """ + return plasma_frequency(n_e, const.e_gauss, const.m_e.cgs, + **kwargs['kwargs']) + + +def opi(n_i: u.Quantity, m_i: u.Quantity, + Z: Union[int, float] = 1, **kwargs) -> u.Quantity: + """ + ion-plasma frequency (in rad/s) + + .. math:: + + \\omega_{pi}^{2} = \\frac{4 \\pi n_{i} (Z e)^{2}}{m_i} + + :param n_i: ion number density (in :math:`cm^{-3}`) + :param m_i: ion mass (in g) + :param Z: ion charge number (:math:`Z=1` DEFAULT) + :param kwargs: supports any keywords used by + :func:`plasma_frequency` + """ + # condition Z + if not isinstance(Z, (int, np.integer, float, np.floating)): + raise TypeError('Z must be of type int or float') + elif Z <= 0: + raise PhysicsError( + 'The ion charge number Z must be greater than 0.') + + return plasma_frequency(n_i, Z * const.e_gauss, m_i, + **kwargs['kwargs']) + + +def oUH(B: u.Quantity, n_e: u.Quantity, + to_Hz: bool = False, **kwargs) -> u.Quantity: + """ + Upper-Hybrid resonance frequency (rad/s) -- + [alias for :func:`upper_hybrid_frequency`] + """ + # add specified keywords to kwargs + for name, val in zip(('to_Hz', ), (to_Hz, )): + if val is not None: + kwargs[name] = val + + return upper_hybrid_frequency(B, n_e, **kwargs) + + +@check_quantity(**{'n': {'units': u.cm ** -3, + 'can_be_negative': False}, + 'q': {'units': u.statcoulomb, + 'can_be_negative': True}, + 'm': {'units': u.g, + 'can_be_negative': False}}) +def plasma_frequency( + n: u.Quantity, q: u.Quantity, m: u.Quantity, + to_Hz=False, **kwargs) -> u.Quantity: + """ + generalized plasma frequency (rad/s) + + .. math:: + + \\omega_{p}^{2} = \\frac{4 \\pi n q^{2}}{m} + + :param n: particle number density (in :math:`cm^{-3}`) + :param q: particle charge (in statcoulombs) + :param m: particle mass (in g) + :param to_Hz: :code:`False` (DEFAULT). Set to :code:`True` to + return frequency in Hz (i.e. divide by :math:`2 * \\pi`) + """ + # ensure args have correct units + n = n.to(u.cm ** -3) + q = q.to(u.Fr) + m = m.to(u.g) + + # calculate + _op = np.sqrt((4.0 * const.pi * n.value * (q.value * q.value)) + / m.value) + if to_Hz: + _op = (_op / (2.0 * const.pi)) * u.Hz + else: + _op = _op * (u.rad / u.s) + return _op + + +@check_quantity(**{'B': {'units': u.gauss, + 'can_be_negative': False}, + 'n_e': {'units': u.cm ** -3, + 'can_be_negative': False}}) +def upper_hybrid_frequency(B: u.Quantity, n_e: u.Quantity, + to_Hz=False, **kwargs) -> u.Quantity: + """ + Upper-Hybrid resonance frequency (rad/s) + + .. math:: + + \\omega_{UH}^{2} =\\omega_{pe}^{2} + \\Omega_{ce}^{2} + + :param B: magnetic field (in Gauss) + :param n_e: electron number density (in :math:`cm^{-3}`) + :param to_Hz: :code:`False` (DEFAULT). Set to :code:`True` to + return frequency in Hz (i.e. divide by :math:`2 * \\pi`) + """ + _oce = oce(B, to_Hz=to_Hz, **kwargs) + _ope = ope(n_e, to_Hz=to_Hz, **kwargs) + _ouh = np.sqrt((_ope ** 2) + (_oce ** 2)) + return _ouh + + +# ---- Lengths ---- +@check_quantity(**{'kTe': {'units': u.eV, + 'can_be_negative': False}, + 'n': {'units': u.cm ** -3, + 'can_be_negative': False}}) +def Debye_length(kTe: u.Quantity, n: u.Quantity, + **kwargs) -> u.Quantity: + """ + Debye length (in cm) + + .. math:: + + \\lambda_{D} = \\sqrt{\\frac{k_{B} T_{e}}{4 \\pi n e^{2}}} + + :param kTe: electron temperature (in eV) + :param n: number density (in :math:`cm^{-3}`) + """ + # ensure args have correct units + kTe = kTe.to(u.erg) + n = n.to(u.cm ** -3) + + # calculate + _lD = np.sqrt(kTe / (4.0 * const.pi * n * (const.e_gauss ** 2))) + return _lD.cgs + + +@check_quantity(**{'vperp': {'units': u.cm / u.s, + 'can_be_negative': True}}) +def cyclotron_radius(vperp: u.Quantity, q: u.Quantity, B: u.Quantity, + m: u.Quantity, **kwargs) -> u.Quantity: + """ + generalized cyclotron radius (in cm) + + .. math:: + + r_{c} = \\frac{m c v_{\\perp}}{|q| B} + = \\frac{v_{\\perp}}{\\Omega_{c}} + + :param vperp: velocity component perpendicular to B (in cm/s) + :param q: particle charge (in statcoulombs) + :param B: magnetic field (in Gauss) + :param m: particle mass (in g) + """ + # ensure correct units + vperp = np.abs(vperp.to(u.cm / u.s)) + + # calculate + _oc = np.abs(cyclotron_frequency(q, B, m)) # type: u.Quantity + _r = vperp / (_oc.value * (1 / u.s)) + return _r.to(u.cm) + + +@check_quantity(**{'n': {'units': u.cm ** -3, + 'can_be_negative': False}, + 'q': {'units': u.statcoulomb, + 'can_be_negative': True}, + 'm': {'units': u.g, + 'can_be_negative': False}}) +def inertial_length(n: u.Quantity, q: u.Quantity, m: u.Quantity, + **kwargs) -> u.Quantity: + """ + generalized inertial length (cm) + + .. math:: + + l =\\frac{c}{\\omega_{p}} + + :param n: particle number density (in :math:`cm^{-3}`) + :param q: particle charge (in statcoulomb) + :param m: particle mass (in g) + """ + _op = plasma_frequency(n, q, m, **kwargs) + _l = (const.c.cgs.value / _op.value) * u.cm + return _l + + +def lD(kTe: u.Quantity, n: u.Quantity, **kwargs) -> u.Quantity: + """ + Debye length (in cm) -- [alias for :func:`Debye_length`] + """ + return Debye_length(kTe, n, **kwargs) + + +def lpe(n_e: u.Quantity, **kwargs) -> u.Quantity: + """ + electron-inertial length (cm) + + .. math:: + + l_{pe} =\\frac{c}{\\omega_{pe}} + + :param n_e: electron number density (in :math:`cm^{-3}`) + """ + return inertial_length(n_e, -const.e_gauss, const.m_e, **kwargs) + + +def lpi(n_i: u.Quantity, m_i: u.Quantity, + Z: Union[int, float] = 1, **kwargs) -> u.Quantity: + """ + ion-inertial length (cm) + + .. math:: + + l_{pi} =\\frac{c}{\\omega_{pi}} + + :param n_i: ion number density (in :math:`cm^{-3}`) + :param Z: charge number + :param m_i: ion mass (in g) + """ + # condition Z + if not isinstance(Z, (int, np.integer, float, np.floating)): + raise TypeError('Z must be of type int or float') + elif Z <= 0: + raise PhysicsError( + 'The ion charge number Z must be greater than 0.') + + return inertial_length(n_i, Z * const.e_gauss, m_i, **kwargs) + + +def rce(kTe: u.Quantity, B: u.Quantity, **kwargs) -> u.Quantity: + """ + electron-cyclotron radius (cm) + + .. math:: + + r_{ce} = \\frac{v_{Te}}{|\\Omega_{ce}|} + = \\sqrt{\\frac{k_{B} T_{e}}{m_{e}}} + \\left( \\frac{m_{e} c}{|e| B} \\right) + + :param kTe: electron temperature (in eV) + :param B: magnetic field (in Gauss) + """ + _v = vTe(kTe) + return cyclotron_radius(_v, const.e_gauss, B, const.m_e) + + +def rci(kTi: u.Quantity, B: u.Quantity, m_i: u.Quantity, + Z: Union[int, float] = 1, **kwargs) -> u.Quantity: + """ + ion-cyclotron radius (cm) + + .. math:: + + r_{ci} = \\frac{v_{Ti}}{|\\Omega_{ci}|} + = \\sqrt{\\frac{k_{B} T_{i}}{m_{i}}} + \\left( \\frac{m_{i} c}{Z |e| B} \\right) + + :param kTi: ion temperature (in eV) + :param B: magnetic field (in Gauss) + :param m_i: ion mass (in g) + :param Z: ion charge number (:math:`Z=1` DEFAULT) + """ + # condition Z + if not isinstance(Z, (int, np.integer, float, np.floating)): + raise TypeError('Z must be of type int or float') + elif Z <= 0: + raise PhysicsError( + 'The ion charge number Z must be greater than 0.') + + # calculate + _v = vTi(kTi, m_i) + return cyclotron_radius(_v, Z * const.e_gauss, B, m_i) + + +# ---- Velocities ---- +@check_relativistic +@check_quantity(**{'B': {'units': u.gauss, + 'can_be_negative': False}, + 'n_e': {'units': u.cm ** -3, + 'can_be_negative': False}, + 'm_i': {'units': u.g, + 'can_be_negative': False}}) +def Alfven_speed(B: u.Quantity, n_e: u.Quantity, m_i: u.Quantity, + Z: Union[int, float] = 1, **kwargs) -> u.Quantity: + """ + Alfvén speed (in cm/s) + + .. math:: + + V_{A} &= \\frac{B} + {\\sqrt{4 \\pi (n_{i} m_{i} + n_{e} m_{e})}} \\ + + &= \\frac{B} + {\\sqrt{4 \\pi n_{e} (\\frac{1}{Z}m_{i} + m_{e})}} + + :param B: magnetic field (in Gauss) + :param n_e: electron number density (in :math:`cm^{3}`) + :param m_i: ion mass (in g) + :param Z: ion charge number (:math:`Z=1` DEFAULT) + """ + # condition Z + if not isinstance(Z, (int, np.integer, float, np.floating)): + raise TypeError('Z must be of type int or float') + elif Z <= 0: + raise PhysicsError( + 'The ion charge number Z must be greater than 0.') + + # ensure correct units + B = B.to(u.gauss) + n_e = n_e.to(u.cm ** -3) + m_i = m_i.to(u.g) + + # calculated + _va = B / np.sqrt(4.0 * const.pi * n_e * ((m_i / Z) + + const.m_e.cgs)) + return _va.value * (u.cm / u.s) + + +def cs(kTe: u.Quantity, m_i: u.Quantity, + kTi: u.Quantity = None, + gamma_e: Union[int, float] = None, + gamma_i: Union[int, float] = None, + Z: Union[int, float] = None, **kwargs) -> u.Quantity: + """ + ion-sound speed (cm/s) -- [alias for :func:`ion_sound_speed` + """ + # add specified keywords to kwargs + for name, val in zip(('kTi', 'gamma_e', 'gamma_i', 'Z'), + (kTi, gamma_e, gamma_i, Z)): + if val is not None: + kwargs[name] = val + + return ion_sound_speed(kTe, m_i, **kwargs) + + +# @utils.check_relativistic +@check_relativistic +@check_quantity(**{'kTe': {'units': u.eV, + 'can_be_negative': False}, + 'kTi': {'units': u.eV, + 'can_be_negative': False}, + 'm_i': {'units': u.g, + 'can_be_negative': False}}) +def ion_sound_speed(kTe: u.Quantity, m_i: u.Quantity, + kTi: u.Quantity = (0.0 * u.eV), + gamma_e: Union[int, float] = 1, + gamma_i: Union[int, float] = 3, + Z: Union[int, float] = 1, **kwargs) -> u.Quantity: + """ + ion sound speed (cm/s) + + .. math:: + + c_{s}^{2} = \\frac{\\gamma_{e} Z k_{B} T_{e} + + \\gamma_i k_{B} T_{i}}{m_{i}} + + :param kTe: electron temperature (in eV) + :param kTi: ion temperature (in eV) + :param m_i: ion mass (in g) + :param gamma_e: adiabatic index for electrons + (:math:`\\gamma_e=3` DEFAULT) + :param gamma_i: adiabatic index for ions + (:math:`\\gamma_i=3` DEFAULT) + :param Z: ion charge number (:math:`Z=1` DEFAULT) + """ + # condition keywords + for name, val in zip(('gamma_i', 'gamma_e', 'Z'), + (gamma_i, gamma_e, Z)): + if not isinstance(val, (int, np.integer, float, np.floating)): + raise TypeError(name + ' must be of type int or float') + elif name == 'Z' and val <= 0: + raise PhysicsError( + 'The ion charge number Z must be greater than 0.') + elif val < 1: + raise PhysicsError('The adiabatic index ' + name + + ' must be greater than or equal to 1.') + + # convert temperature to required units + kTe = kTe.to(u.erg) + kTi = kTi.to(u.erg) + m_i = m_i.cgs + + # calculate + _cs = gamma_e * Z * kTe + if kTi != 0: + _cs += gamma_i * kTi + _cs = np.sqrt(_cs / m_i).to(u.cm / u.s) + return _cs + + +# @utils.check_relativistic +@check_relativistic +@check_quantity(**{'kT': {'units': u.eV, + 'can_be_negative': False}, + 'm': {'units': u.g, + 'can_be_negative': False}}) +def thermal_speed(kT: u.Quantity, m: u.Quantity, + **kwargs) -> u.Quantity: + """ + generalized thermal speed (in cm/s) + + .. math:: + + v_{th} = \\sqrt{\\frac{k_{B} T}{m}} + + :param kT: temperature (in eV) + :param m: particle mass (in g) + """ + # ensure proper units + kT = kT.to(u.erg) + m = m.to(u.g) + + # calculate + _v = np.sqrt(kT / m) + return _v.to(u.cm / u.s) + + +def VA(B: u.Quantity, n_e: u.Quantity, m_i: u.Quantity, + Z: Union[int, float] = None, **kwargs) -> u.Quantity: + """ + Alfvén speed (in cm/s) -- [alias for :func:`Alfven_speed`] + """ + # add specified keywords to kwargs + for name, val in zip(('Z',), (Z,)): + if val is not None: + kwargs[name] = val + + return Alfven_speed(B, n_e, m_i, **kwargs) + + +def vTe(kTe: u.Quantity, **kwargs) -> u.Quantity: + """ + electron thermal speed (in cm/s) + + .. math:: + + v_{T_{e}} = \\sqrt{\\frac{k_{B} T_{e}}{m_{e}}} + + :param kTe: electron temperature (in eV) + """ + return thermal_speed(kTe, const.m_e, **kwargs) + + +def vTi(kTi: u.Quantity, m_i: u.Quantity, **kwargs) -> u.Quantity: + """ + ion thermal speed (in cm/s) + + .. math:: + + v_{T_{i}} = \\sqrt{\\frac{k_{B} T_{i}}{m_{i}}} + + :param kTi: ion temperature (in eV) + :param m_i: ion mass (in g) + """ + return thermal_speed(kTi, m_i, **kwargs) + + +# ---- Dimensionless ---- +@check_quantity(**{'n': {'units': u.cm ** -3, + 'can_be_negative': False}, + 'kT': {'units': u.eV, + 'can_be_negative': False}, + 'B': {'units': u.Gauss, + 'can_be_negative': False}}) +def beta(n: u.Quantity, kT: u.Quantity, B: u.Quantity, + **kwargs) -> u.Quantity: + """ + plasma beta of particle species :math:`s` + + .. math:: + + \\beta_{s} = \\frac{n_{s} k_{B} T_{s}}{B^{2} / (8 \\pi)} + + :param n: particle number density (in :math:`cm^{-3}`) + :param kT: particle temperature (in eV) + :param B: magnetic field (in Gauss) + """ + # ensure proper units + n = n.to(u.cm ** -3) + kT = kT.to(u.erg) + B = B.to(u.Gauss) + + _TP = n * kT + _MP = B ** 2 / (8.0 * const.pi) + return (_TP.value / _MP.value) * u.dimensionless_unscaled diff --git a/bapsflib/utils/__init__.py b/bapsflib/utils/__init__.py index 151562fb..3dbea4c9 100644 --- a/bapsflib/utils/__init__.py +++ b/bapsflib/utils/__init__.py @@ -12,5 +12,18 @@ Package of developer utilities. """ __all__ = ['decorators', 'errors', 'warnings'] +__all__ += ['BaPSFConstant', 'check_quantity', 'check_relativistic', + 'temperature_and_energy'] + +from astropy.constants import Constant from . import (decorators, errors, warnings) +from .decorators import (check_quantity, check_relativistic) +from .units import temperature_and_energy + + +class BaPSFConstant(Constant): + """Factory Class for BaPSF Constants""" + default_reference = 'Basic Plasma Facility' + _registry = {} + _has_incompatible_units = set() diff --git a/bapsflib/utils/decorators.py b/bapsflib/utils/decorators.py index bac41aaf..d4826ba0 100644 --- a/bapsflib/utils/decorators.py +++ b/bapsflib/utils/decorators.py @@ -11,13 +11,16 @@ """ Decorators for the :mod:`bapsflib` package. """ -__all__ = ['with_bf', 'with_lapdf'] +__all__ = ['check_quantity', 'check_relativistic', + 'with_bf', 'with_lapdf'] import functools import inspect from typing import Union +from .units.decorators import (check_quantity, check_relativistic) + def with_bf(wfunc=None, *, filename: Union[str, None] = None, diff --git a/bapsflib/utils/errors.py b/bapsflib/utils/errors.py index 3c2370b2..73f7996a 100644 --- a/bapsflib/utils/errors.py +++ b/bapsflib/utils/errors.py @@ -34,3 +34,8 @@ class HDFReadControlError(HDFReadError): class HDFReadMSIError(HDFReadError): """Exception for failed HDF5 reading of digitizer.""" pass + + +class PhysicsError(Exception): + """Exception for physics errors.""" + pass diff --git a/bapsflib/utils/helpers.py b/bapsflib/utils/helpers.py new file mode 100644 index 00000000..fb0e658c --- /dev/null +++ b/bapsflib/utils/helpers.py @@ -0,0 +1,13 @@ +# This file is part of the bapsflib package, a Python toolkit for the +# BaPSF group at UCLA. +# +# http://plasma.physics.ucla.edu/ +# +# Copyright 2017-2019 Erik T. Everson and contributors +# +# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full +# license terms and contributor agreement. +# +""" +Helper functions for the :mod:`bapsflib` package. +""" diff --git a/bapsflib/utils/units/__init__.py b/bapsflib/utils/units/__init__.py new file mode 100644 index 00000000..62478751 --- /dev/null +++ b/bapsflib/utils/units/__init__.py @@ -0,0 +1,20 @@ +# This file is part of the bapsflib package, a Python toolkit for the +# BaPSF group at UCLA. +# +# http://plasma.physics.ucla.edu/ +# +# Copyright 2017-2019 Erik T. Everson and contributors +# +# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full +# license terms and contributor agreement. +# +""" +Package to help facilitate the use of :mod:`astropy.units` in the +:mod:`bapsflib` package. +""" + +__all__ = ['check_quantity', 'check_relativistic', + 'temperature_and_energy'] + +from .decorators import (check_quantity, check_relativistic) +from .equivalencies import temperature_and_energy diff --git a/bapsflib/utils/units/decorators.py b/bapsflib/utils/units/decorators.py new file mode 100644 index 00000000..afb5bf1f --- /dev/null +++ b/bapsflib/utils/units/decorators.py @@ -0,0 +1,446 @@ +# This file is part of the bapsflib package, a Python toolkit for the +# BaPSF group at UCLA. +# +# http://plasma.physics.ucla.edu/ +# +# Copyright 2017-2018 Erik T. Everson and contributors +# +# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full +# license terms and contributor agreement. +# +""" +Decorators associated with assisting in the usage of +:mod:`astropy.units`. +""" +__all__ = ['check_quantity', 'check_relativistic'] + +import astropy.units as u +import functools +import inspect +import numpy as np +import warnings + +from astropy.units import UnitsWarning +from plasmapy.utils import check_relativistic +from typing import (Dict, List, Union) + + +# this is modified from plasmapy.utils.checks.check_quantity +# TODO: replace with PlasmaPy version when PlasmaPy v0.2.0 is released +def check_quantity(**validations: + Dict[str, Union[bool, List, None, u.Unit]]): + """ + Verify that the function's arguments have correct units. + This decorator raises an exception if an annotated argument in the + decorated function is an :class:`~astropy.units.Quantity` + with incorrect units or of incorrect kind. You can prevent + arguments from being input as Nones, NaNs, negatives, + infinities, and complex numbers. + + If a number (non-Quantity) value is inserted in place of a value + with units, then coerce it to the expected unit (if only one unit + type is expected) or raise a TypeError. + + This is probably best illustrated with an example: + + Examples + -------- + >>> from astropy import units as u + >>> @check_quantity(x={"units": u.m, + ... "can_be_negative": False, + ... "can_be_complex": True, + ... "can_be_inf": True}) + ... def func(x): + ... return x + + >>> func(1 * u.m) + + + >>> func(1 * u.s) + Traceback (most recent call last): + ... + astropy.units.core.UnitConversionError: The argument x to func should be a Quantity with the following units: m + + >>> import pytest # to show the UnitsWarning + >>> with pytest.warns(u.UnitsWarning, match="Assuming units of m."): + ... func(1) + + + >>> func(-1 * u.m) + Traceback (most recent call last): + ... + ValueError: The argument x to function func cannot contain negative numbers. + + >>> func(np.inf * u.m) + + + >>> func(None) + Traceback (most recent call last): + ... + ValueError: The argument x to function func cannot contain Nones. + + Parameters + ---------- + dict + Arguments to be validated passed in as keyword arguments, + with values as validation dictionaries, with structure as + in the example. Valid keys for each argument are: + 'units': :class:`astropy.units.Unit`, + 'equivalencies': :mod:`astropy.units.equivalencies`, + 'enforce': `bool`, + 'can_be_negative': `bool`, + 'can_be_complex': `bool`, + 'can_be_inf': `bool`, + 'can_be_nan': `bool`, + 'none_shall_pass': `bool` + + Raises + ------ + :class:`TypeError` + If the argument is not a :class:`~astropy.units.Quantity`, + units is not entirely units or `argname` does not have a type + annotation. + + :class:`~astropy.units.UnitConversionError` + If the argument is not in acceptable units. + + :class:`~astropy.units.UnitsError` + If after the assumption checks, the argument is still not in + acceptable units. + + :class:`ValueError` + If the argument contains :attr:`~numpy.nan` or other invalid + values as determined by the keywords. + + Warns + ----- + :class:`~astropy.units.UnitsWarning` + If a :class:`~astropy.units.Quantity` is not provided and + unique units are provided, a `UnitsWarning` will be raised + and the inputted units will be assumed. + + Notes + ----- + This functionality may end up becoming deprecated due to + noncompliance with the `IEEE 754 standard + `_ + and in favor of `~astropy.units.quantity_input`. + + Returns + ------- + function + Decorated function. + + See also + -------- + :func:`_check_quantity` + """ + def decorator(f): + wrapped_sign = inspect.signature(f) + fname = f.__name__ + + # add '__signature__' to methods that are copied from + # wrapped_function onto wrapper + assigned = list(functools.WRAPPER_ASSIGNMENTS) + assigned.append('__signature__') + @functools.wraps(f, assigned=assigned) + def wrapper(*args, **kwargs): + # combine args and kwargs into dictionary + bound_args = wrapped_sign.bind(*args, **kwargs) + bound_args.apply_defaults() + given_params_values = bound_args.arguments + given_params = set(given_params_values.keys()) + + # names of params to check + validated_params = set(validations.keys()) + + missing_params = [ + param for param in (validated_params - given_params) + ] + + if len(missing_params) > 0: + params_str = ", ".join(missing_params) + raise TypeError( + f"Call to {fname} is missing " + f"validated params {params_str}") + + for param_to_check, validation_settings \ + in validations.items(): + value_to_check = given_params_values[param_to_check] + + enforce = \ + validation_settings.get('enforce', True) + equivalencies = \ + validation_settings.get('equivalencies', None) + can_be_negative = \ + validation_settings.get('can_be_negative', True) + can_be_complex = \ + validation_settings.get('can_be_complex', False) + can_be_inf = \ + validation_settings.get('can_be_inf', True) + can_be_nan = \ + validation_settings.get('can_be_nan', True) + none_shall_pass = \ + validation_settings.get('none_shall_pass', False) + + validated_value = _check_quantity( + value_to_check, + param_to_check, + fname, + validation_settings['units'], + enforce=enforce, + equivalencies=equivalencies, + can_be_negative=can_be_negative, + can_be_complex=can_be_complex, + can_be_inf=can_be_inf, + can_be_nan=can_be_nan, + none_shall_pass=none_shall_pass, + ) + given_params_values[param_to_check] = validated_value + + return f(**given_params_values) + + # add '__signature__' if it does not exist + # - this will preserve parameter hints in IDE's + if not hasattr(wrapper, '__signature__'): + wrapper.__signature__ = inspect.signature(f) + + return wrapper + return decorator + + +# this is modified from plasmapy.utils.checks._check_quantity +# TODO: replace with PlasmaPy version when PlasmaPy v0.2.0 is released +def _check_quantity(val: Union[u.Quantity, None, float, np.ndarray], + val_name: str, + funcname: str, + units: Union[u.UnitBase, List[u.UnitBase]], + equivalencies: Union[List, None] = None, + enforce: bool = True, + can_be_negative: bool = True, + can_be_complex: bool = False, + can_be_inf: bool = True, + can_be_nan: bool = True, + none_shall_pass: bool = False) -> u.Quantity: + """ + To be used with decorator :func:`check_quantity`. + + Raise an exception if an object is not a + :class:`~astropy.units.Quantity` with correct units and + valid numerical values. + + Parameters + ---------- + val : :class:`~astropy.units.Quantity` + The object to be tested. + + val_name: str + The name of the variable passed in as :data:`val`. + (used for error messages) + + funcname : str + The name of the decorated function. (used for error messages) + + equivalencies : Union[None, List[Tuple]] + An :mod:`astropy.units.equivalencies` used to help with + unit conversion. + + enforce : bool + (:code:`True` DEFAULT) Force the input :data:`val` to be + converted into the desired :data:`units` + + units : :class:`~astropy.units.Unit` or list of :class:`~astropy.unit.Unit` + Acceptable units for :data:`val`. + + can_be_negative : bool, optional + `True` if the `~astropy.units.Quantity` can be negative, + `False` otherwise. Defaults to `True`. + + can_be_complex : bool, optional + `True` if the `~astropy.units.Quantity` can be a complex number, + `False` otherwise. Defaults to `False`. + + can_be_inf : bool, optional + `True` if the `~astropy.units.Quantity` can contain infinite + values, `False` otherwise. Defaults to `True`. + + can_be_nan : bool, optional + `True` if the `~astropy.units.Quantity` can contain NaN + values, `False` otherwise. Defaults to `True`. + + none_shall_pass : bool, optional + `True` if the `~astropy.units.Quantity` can contain None + values, `False` otherwise. Defaults to `True`. + + Raises + ------ + TypeError + If the argument is not a `~astropy.units.Quantity` or units is + not entirely units. + + ~astropy.units.UnitConversionError + If the argument is not in acceptable units. + + ~astropy.units.UnitsError + If after the assumption checks, the argument is still not in + acceptable units. + + ValueError + If the argument contains any `~numpy.nan` or other invalid + values as determined by the keywords. + + Warns + ----- + ~astropy.units.UnitsWarning + If a `~astropy.units.Quantity` is not provided and unique units + are provided, a `UnitsWarning` will be raised and the inputted + units will be assumed. + + Examples + -------- + >>> from astropy import units as u + >>> import pytest + >>> _check_quantity(4*u.T, 'B', 'f', u.T) + + >>> with pytest.warns(u.UnitsWarning, match="No units are specified"): + ... assert _check_quantity(4, 'B', 'f', u.T) == 4 * u.T + """ + # -- condition `units` argument -- + if not isinstance(units, list): + units = [units] + for unit in units: + if not isinstance(unit, + (u.Unit, u.CompositeUnit, u.IrreducibleUnit)): + raise TypeError( + "The `units` arg must be a list of astropy units." + ) + + # -- condition `equivalencies` keyword -- + if equivalencies is None: + equivalencies = [None] * len(units) + elif isinstance(equivalencies, list): + if all(isinstance(el, tuple) for el in equivalencies): + equivalencies = [equivalencies] + + # ensure passed equivalencies list is structured properly + # [[(), ...], ...] + for equivs in equivalencies: + for equiv in equivs: + err_str = ( + "All equivalencies must be a list of 2 or 4 " + "element tuples structured like (unit1, unit2) " + "or (unit1, unit2, " + "func_unit1_to_unit2, func_unit2_to_unit1)" + ) + if not isinstance(equiv, tuple): + raise TypeError(err_str) + elif len(equiv) not in (2, 4): + raise TypeError(err_str) + + # ensure number of equivalencies lists match the number of + # equivalent units to check + if len(equivalencies) == 1: + equivalencies = equivalencies * len(units) + elif len(equivalencies) != len(units): + raise ValueError( + f"The length of the specified equivalencies list " + f"({len(equivalencies)}) must be 1 or equal to the " + f"number of specified units ({len(units)})") + else: + raise ValueError( + f"The specified equivalencies {equivalencies} is not" + f"valid for astropy.unit.to()") + + # -- condition `val` argument -- + # create a TypeError message + typeerror_message = ( + f"The argument {val_name} to {funcname} should be a " + f"Quantity with " + ) + if len(units) == 1: + typeerror_message += f"the following units: {str(units[0])}" + else: + typeerror_message += "one of the following units: " + for unit in units: + typeerror_message += str(unit) + if unit != units[-1]: + typeerror_message += ", " + if none_shall_pass: + typeerror_message += "or None " + + # initialize a ValueError meassage + valueerror_message = ( + f"The argument {val_name} to function {funcname} can " + f"not contain" + ) + + # ensure `val` is astropy.units.Quantity or return None (if allowed) + if val is None and none_shall_pass: + return val + elif val is None: + raise ValueError(f"{valueerror_message} Nones.") + elif not isinstance(val, u.Quantity): + if len(units) != 1: + raise TypeError(typeerror_message) + else: + try: + val = val * units[0] + except (ValueError, TypeError): + raise TypeError(typeerror_message) + else: + if not isinstance(val, u.Quantity): # pragma: no cover + # this should never be reached...if so, except + # is not setup correctly + # + raise TypeError(typeerror_message) + + warnings.warn(UnitsWarning( + f"No units are specified for {val_name} = {val} " + f"in {funcname}. Assuming units of " + f"{str(units[0])}. To silence this warning, " + f"explicitly pass in an Astropy Quantity " + f"(e.g. 5. * astropy.units.cm) " + f"(see http://docs.astropy.org/en/stable/units/)" + )) + + # Look for unit equivalencies for `val` + in_acceptable_units = [] + for unit, equiv in zip(units, equivalencies): + try: + val.unit.to(unit, equivalencies=equiv) + except u.UnitConversionError: + in_acceptable_units.append(False) + else: + in_acceptable_units.append(True) + + # convert `val` to desired units if enforce=True + nacceptable = np.count_nonzero(in_acceptable_units) + if nacceptable == 0: + # NO equivalent units + raise u.UnitConversionError(typeerror_message) + elif nacceptable == 1 and enforce: + # convert to desired unit + unit = np.array(units)[in_acceptable_units][0] + equiv = np.array(equivalencies)[in_acceptable_units][0] + val = val.to(unit, equivalencies=equiv) + elif nacceptable >= 1 and enforce: + # too many equivalent units + raise u.UnitConversionError( + "`val`'s units is equivalent to too many units in " + "`units`, `val` can not be coerced into one") + + # ensure `val` has valid numerical values + if np.any(np.isnan(val.value)) and not can_be_nan: + raise ValueError(f"{valueerror_message} NaNs.") + elif np.any(np.iscomplexobj(val.value)) and not can_be_complex: + raise ValueError(f"{valueerror_message} complex numbers.") + elif not can_be_negative: + # Allow NaNs through without raising a warning + with np.errstate(invalid='ignore'): + isneg = np.any(val.value < 0) + if isneg: + raise ValueError(f"{valueerror_message} negative numbers.") + elif not can_be_inf and np.any(np.isinf(val.value)): + raise ValueError(f"{valueerror_message} infs.") + + return val diff --git a/bapsflib/utils/units/equivalencies.py b/bapsflib/utils/units/equivalencies.py new file mode 100644 index 00000000..ac4de5a9 --- /dev/null +++ b/bapsflib/utils/units/equivalencies.py @@ -0,0 +1,57 @@ +# This file is part of the bapsflib package, a Python toolkit for the +# BaPSF group at UCLA. +# +# http://plasma.physics.ucla.edu/ +# +# Copyright 2017-2019 Erik T. Everson and contributors +# +# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full +# license terms and contributor agreement. +# +""" +:mod:`bapsflib` defined equivalencies for :mod:`astropy.units` +""" + +__all__ = ['temperature_and_energy'] + +import astropy.units as u + +from typing import (List, Tuple) + + +def temperature_and_energy(): + """ + Convert between Kelvin, Celsius, Fahrenheit, and eV. (An + :mod:`astropy.units.equivalencies`) + """ + # combine standard temperature and energy equivalencies + equiv = u.temperature() # type: List[Tuple] + equiv.extend(u.temperature_energy()) + + # construct functions for non-Kelvin to energy (and visa-versa) + # conversions + def convert_C_to_eV(x): + # convert Celsius to eV + x = (x * u.deg_C).to(u.K, equivalencies=equiv) + return x.to_value(u.eV, equivalencies=equiv) + + def convert_eV_to_C(x): + # convert eV to Celsius + x = (x * u.eV).to(u.K, equivalencies=equiv) + return x.to_value(u.deg_C, equivalencies=equiv) + + def convert_F_to_eV(x): + # convert Fahrenheit to eV + x = (x * u.imperial.deg_F).to(u.K, equivalencies=equiv) + return x.to_value(u.eV, equivalencies=equiv) + + def convert_eV_to_F(x): + # convert eV to Fahrenheit to eV + x = (x * u.eV).to(u.K, equivalencies=equiv) + return x.to_value(u.imperial.deg_F, equivalencies=equiv) + + # add new equivalencies to the complete list + equiv.append((u.deg_C, u.eV, convert_C_to_eV, convert_eV_to_C)) + equiv.append((u.imperial.deg_F, u.eV, + convert_F_to_eV, convert_eV_to_F)) + return equiv diff --git a/bapsflib/utils/units/tests/__init__.py b/bapsflib/utils/units/tests/__init__.py new file mode 100644 index 00000000..9ce148ee --- /dev/null +++ b/bapsflib/utils/units/tests/__init__.py @@ -0,0 +1,10 @@ +# This file is part of the bapsflib package, a Python toolkit for the +# BaPSF group at UCLA. +# +# http://plasma.physics.ucla.edu/ +# +# Copyright 2017-2019 Erik T. Everson and contributors +# +# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full +# license terms and contributor agreement. +# diff --git a/bapsflib/utils/units/tests/test_decorators.py b/bapsflib/utils/units/tests/test_decorators.py new file mode 100644 index 00000000..20b9680e --- /dev/null +++ b/bapsflib/utils/units/tests/test_decorators.py @@ -0,0 +1,367 @@ +# This file is part of the bapsflib package, a Python toolkit for the +# BaPSF group at UCLA. +# +# http://plasma.physics.ucla.edu/ +# +# Copyright 2017-2019 Erik T. Everson and contributors +# +# License: Standard 3-clause BSD; see "LICENSES/LICENSE.txt" for full +# license terms and contributor agreement. +# +import astropy.units as u +import inspect +import numpy as np +import unittest as ut + +from unittest import mock + +from ..decorators import (_check_quantity, check_quantity) + + +class TestCheckQuantity(ut.TestCase): + """ + Test case for function :func:`_check_quantity` and decorator + :func:`check_quantity` + """ + # What to cover: + # 1. Errors raised + # * TypeError if all elements of the `units` arg is + # not and instance of u.Unit, u.CompositeUnit, or + # u.IrreducibleUnit + # * TypeError if equivalencies element is not a tuple + # * TypeError if equivalencies element is a tuple, but + # not length 4 + # * ValueError if len(equivalencies) != 1 and != len(units) + # * ValueError if val is None and none_shall_pass=False + # * ValueError if len(units) != 1 and `val` is not u.Quantity + # * TypeError if len(units) == 1 and `val` is not u.Quantity + # and `val` can not be cast into a u.Quantity + # * u.UnitConversionError if no unit equivalencies are found + # * u.UnitConversionError if multiple equivalences are found + # and enforce=True + # * ValueError if val is np.nan and can_be_nan=False + # * ValueError if val is complex and can_be_complex=False + # * ValueError if val is negative and can_be_negative=False + # * ValueError if val is inf and can_be_inf=False + # 2. UserWarnings + # * if len(units) == 1 and `val` is not u.Quantity and + # `val` can be cast into a u.Quantity + # + + def setUp(self) -> None: + super().setUp() + + def tearDown(self) -> None: + super().tearDown() + + def test__check_quantity_default(self): + """Test default values :func:`_check_quantity`.""" + sig = inspect.signature(_check_quantity) + + # _check_quantity is a function + self.assertTrue(inspect.isfunction(_check_quantity)) + + # check defaults for keywords + pairs = [ + ('equivalencies', None), + ('enforce', True), + ('can_be_negative', True), + ('can_be_complex', False), + ('can_be_inf', True), + ('can_be_nan', True), + ('none_shall_pass', False), + ] + for pair in pairs: + self.assertIn(pair[0], sig.parameters.keys()) + if pair[0] is None: + self.assertIsNone(sig.parameters[pair[0]].default) + else: + self.assertEqual(sig.parameters[pair[0]].default, + pair[1]) + + def test__check_quantity_errors(self): + """ + Test scenarios the cause :func:`_check_quantity` to raise + errors. + """ + # Errors to check: + # X 1. TypeError if all elements of the `units` arg is + # not and instance of u.Unit, u.CompositeUnit, or + # u.IrreducibleUnit + # X 2. TypeError if equivalencies element is not a tuple + # X 3. TypeError if equivalencies element is a tuple, but + # not length 4 + # X 4. ValueError if len(equivalencies) != 1 and != len(units) + # X 5. ValueError if val is None and none_shall_pass=False + # X 6. TypeError if len(units) != 1 and `val` is not + # u.Quantity + # X 7. TypeError if len(units) == 1 and `val` is not u.Quantity + # and `val` can not be cast into a u.Quantity + # X 8. u.UnitConversionError if no unit equivalencies are found + # X 9. u.UnitConversionError if multiple equivalences are found + # and enforce=True + # X 10. ValueError if val is np.nan and can_be_nan=False + # X 11. ValueError if val is complex and can_be_complex=False + # X 12. ValueError if val is negative and can_be_negative=False + # X 13. ValueError if val is inf and can_be_inf=False + # 14. ValueError if equivalencies is not a list or None + # + # TypeError if all elements of the `units` arg is (1) + # not and instance of u.Unit, u.CompositeUnit, or + # u.IrreducibleUnit + self.assertRaises(TypeError, _check_quantity, + 5. * u.cm, 'a', 'foo', 'not a unit') + self.assertRaises(TypeError, _check_quantity, + 5. * u.cm, 'a', 'foo', [u.cm, 'not a unit']) + + # TypeError if equivalencies element is not a tuple (2) + self.assertRaises(TypeError, _check_quantity, + 5. * u.cm, 'a', 'foo', u.cm, + equivalencies=[{'not a tuple': 1}]) + + # TypeError if equivalencies element is a tuple, but (3) + # not length 4 + self.assertRaises(TypeError, _check_quantity, + 5. * u.cm, 'a', 'foo', u.cm, + equivalencies=[(1, 2, 3, )]) + + # ValueError if len(equivalencies) != 1 and != len(units) (4) + self.assertRaises(ValueError, _check_quantity, + 5. * u.cm, 'a', 'foo', [u.cm, u.g, u.s], + equivalencies=[[(None, None, None, None)], + [(None, None, None, None)]]) + + # ValueError if val is None and none_shall_pass=False (5) + self.assertRaises(ValueError, _check_quantity, + None, 'a', 'foo', u.cm, none_shall_pass=False) + + # TypeError if len(units) != 1 and `val` is not (6) + # u.Quantity + self.assertRaises(TypeError, _check_quantity, + 5., 'a', 'foo', [u.cm, u.g]) + + # TypeError if len(units) == 1 and `val` is not (7) + # u.Quantity and `val` can not be cast into a u.Quantity + # + # 'five' * u.cm raise a ValueError caught by try-except + self.assertRaises(TypeError, _check_quantity, + 'five', 'a', 'foo', u.cm) + # {'five': 5} * u.cm raise a TypeError caught by try-except + self.assertRaises(TypeError, _check_quantity, + {'five': 5}, 'a', 'foo', u.cm) + + # u.UnitConversionError if no unit equivalencies are found (8) + self.assertRaises(u.UnitConversionError, _check_quantity, + 5. * u.cm, 'a', 'foo', u.g) + self.assertRaises(u.UnitConversionError, _check_quantity, + 5. * u.cm, 'a', 'foo', [u.g, u.s]) + + # u.UnitConversionError if multiple equivalences are (9) + # found and enforce=True + self.assertRaises(u.UnitConversionError, _check_quantity, + 5. * u.cm, 'a', 'foo', [u.cm, u.km, u.g], + enforce=True) + + # ValueError if val is np.nan and can_be_nan=False (10) + self.assertRaises(ValueError, _check_quantity, + np.nan * u.cm, 'a', 'foo', u.cm, + can_be_nan=False) + + # ValueError if val is complex and can_be_complex=False (11) + self.assertRaises(ValueError, _check_quantity, + np.complex(5.) * u.cm, 'a', 'foo', u.cm, + can_be_complex=False) + + # ValueError if val is negative and can_be_negative=False (12) + self.assertRaises(ValueError, _check_quantity, + -5. * u.g, 'a', 'foo', u.g, + can_be_negative=False) + + # ValueError if val is inf and can_be_inf = False (13) + self.assertRaises(ValueError, _check_quantity, + np.inf * u.cm, 'a', 'foo', u.cm, + can_be_inf=False) + + # ValueError if equivalencies is not a list or None (14) + self.assertRaises(ValueError, _check_quantity, + np.inf * u.cm, 'a', 'foo', u.cm, + equivalencies=('not', 'correct')) + + def test__check_quantity_behavior(self): + """Test behavior of :func:`_check_quantity` keywords.""" + # -- keyword `can_be_complex` -- + vals = [np.complex(5., 2.) * u.cm, + np.array([1, 4, 6], dtype=np.complex) * u.cm] + for val in vals: + check_val = _check_quantity(val, 'val', 'foo', u.m, + can_be_complex=True) + self.assertTrue( + np.all((val.value / 100.0) * u.m == check_val)) + self.assertTrue(np.all(np.iscomplexobj(check_val.value))) + + # -- keyword `can_be_inf` -- + vals = [np.inf * u.cm, + np.array([1, np.inf, 6], dtype=np.float16) * u.cm] + for val in vals: + check_val = _check_quantity(val, 'val', 'foo', u.m, + can_be_inf=True) + self.assertTrue( + np.all((val.value / 100.0) * u.m == check_val)) + self.assertEqual(np.count_nonzero(np.isinf(val)), + np.count_nonzero(np.isinf(check_val))) + + # -- keyword `can_be_nan` -- + vals = [np.nan * u.cm, + np.array([1, np.nan, 6], dtype=np.float16) * u.cm] + for val in vals: + check_val = _check_quantity(val, 'val', 'foo', u.m, + can_be_nan=True) + self.assertEqual(u.m, check_val.unit) + if isinstance(val, np.ndarray) and val.size != 1: + val_mask = np.isnan(val) + check_val_mask = np.isnan(check_val) + self.assertEqual(np.count_nonzero(val_mask), + np.count_nonzero(check_val_mask)) + self.assertTrue(np.array_equal( + val[np.logical_not(val_mask)] / 100.0, + check_val[np.logical_not(check_val_mask)] + )) + else: + self.assertTrue(np.isnan(check_val)) + + # -- keyword `can_be_negative` -- + vals = [-5. * u.cm, + np.array([-1., 3., -22.], dtype=np.float16) * u.cm] + for val in vals: + check_val = _check_quantity( + val, 'val', 'foo', u.m, can_be_negative=True) + self.assertTrue(np.array_equal( + (val.value / 100.0) * u.m, + check_val)) + + # val is positive and negative are not allowed + val = 5.0 * u.cm + check_val = _check_quantity( + val, 'val', 'foo', u.m, can_be_negative=False) + self.assertEqual((val.value / 100.0) * u.m, check_val) + + # -- keyword `enforce` -- + vals = [5. * u.cm, + np.array([1., 3., 22.], dtype=np.float16) * u.cm] + for val in vals: + check_val = _check_quantity( + val, 'val', 'foo', u.cm, enforce=True) + self.assertTrue(np.array_equal(val, check_val)) + + check_val = _check_quantity( + val, 'val', 'foo', u.km, enforce=True) + self.assertTrue(np.array_equal(val.to(u.km), check_val)) + + check_val = _check_quantity( + val, 'val', 'foo', u.km, enforce=False) + self.assertTrue(np.array_equal(val, check_val)) + + # -- keyword `equivalencies` -- + # equivalencies is one list but multiple units are specific + val = 5. * u.K + check_val = _check_quantity( + val, 'val', 'foo', [u.cm, u.deg_C], + equivalencies=u.temperature()) + self.assertEqual( + val.to(u.deg_C, equivalencies=u.temperature()), check_val) + + # number of equivalencies and units are equal but more that 1 + check_val = _check_quantity( + val, 'val', 'foo', [u.deg_C, u.g], + equivalencies=[u.temperature(), u.molar_mass_amu()]) + self.assertEqual( + val.to(u.deg_C, equivalencies=u.temperature()), check_val) + + # -- keyword `non_shall_pass` -- + val = None + check_val = _check_quantity( + val, 'val', 'foo', u.cm, none_shall_pass=True) + self.assertIsNone(check_val) + + def test__check_quantity_warnings(self): + """ + Test behavior that cause :func:`_check_quantity` to issue + a UserWarning + """ + # value does not have units and only one unit is specified + val = 5 + with self.assertWarns(u.UnitsWarning): + check_val = _check_quantity(val, 'val', 'foo', u.cm) + self.assertEqual(val * u.cm, check_val) + + @mock.patch(_check_quantity.__module__ + '._check_quantity', + side_effect=_check_quantity, autospec=True) + def test_check_quantity_decorator(self, mock_cq): + + # create mock function (mock_func) and function to mock (foo) + def foo(x): + return x + mock_func = mock.Mock(side_effect=foo, name='mock_func', + autospec=True) + mock_func.__name__ = 'mock_func' + mock_func.__signature__ = inspect.signature(foo) + + # -- Examine various validations and pass-through -- + validations = [ + {'units': u.cm, + 'enforce': True, + 'equivalencies': None, + 'can_be_negative': True, + 'can_be_complex': False, + 'can_be_inf': True, + 'can_be_nan': True, + 'none_shall_pass': False}, + {'units': u.cm, + 'enforce': False, + 'equivalencies': u.temperature(), + 'can_be_negative': False, + 'can_be_complex': True, + 'can_be_inf': False, + 'can_be_nan': False, + 'none_shall_pass': True}, + ] + for validation in validations: + func = check_quantity(**{'x': validation})(mock_func) + self.assertTrue(hasattr(func, '__signature__')) + + val_in = 5. * u.km + val = func(val_in) + self.assertEqual(val, 500000. * u.cm) + self.assertTrue(mock_cq.called) + self.assertTrue(mock_func.called) + + # correct args passed to _check_quantity + self.assertEqual(len(mock_cq.call_args[0]), 4) + self.assertEqual(mock_cq.call_args[0][0], val_in) + self.assertEqual(mock_cq.call_args[0][1], 'x') + self.assertEqual(mock_cq.call_args[0][2], 'mock_func') + self.assertEqual(mock_cq.call_args[0][3], + validation['units']) + + # correct kwargs passed to _check_quantity + for key, val in validation.items(): + if key == 'units': + continue + self.assertIn(key, mock_cq.call_args[1]) + self.assertEqual(val, mock_cq.call_args[1][key]) + + # reset mocks + mock_cq.reset_mock() + mock_func.reset_mock() + + # -- TypeError if function is missing validation parameters -- + func = check_quantity(**{'y': {'units': u.cm}})(foo) + self.assertTrue(hasattr(func, '__signature__')) + self.assertRaises(TypeError, func, 5.*u.cm) + + + + + +if __name__ == '__main__': + ut.main() diff --git a/docs/Makefile b/docs/Makefile index e93c1894..d58bbf41 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = python3 -msphinx +SPHINXBUILD = python3 -m sphinx SPHINXPROJ = bapsflib SOURCEDIR = . BUILDDIR = _build diff --git a/docs/conf.py b/docs/conf.py index 67cd556a..b46a0e93 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,9 +42,16 @@ extensions = ['sphinx.ext.autodoc', 'sphinx.ext.githubpages', 'sphinx.ext.mathjax', - 'sphinx.ext.autosummary'] + 'sphinx.ext.autosummary', + 'sphinx.ext.intersphinx'] numfig = True # enable figure and table numbering autosummary_generate = True +intersphinx_mapping = { + 'python': ('https://docs.python.org/3/', None), + 'astropy': ('http://docs.astropy.org/en/stable/', None), + 'h5py': ('http://docs.h5py.org/en/stable/', None), + 'numpy': ('https://docs.scipy.org/doc/numpy', None), +} # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/make.bat b/docs/make.bat index 8f2ab92f..4ef23c18 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -5,7 +5,7 @@ pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=python -msphinx + set SPHINXBUILD=python3 -m sphinx ) set SOURCEDIR=. set BUILDDIR=_build diff --git a/docs/src/bapsflib.lapd.constants.constants.rst b/docs/src/bapsflib.lapd.constants.constants.rst index d91003de..5357b57c 100644 --- a/docs/src/bapsflib.lapd.constants.constants.rst +++ b/docs/src/bapsflib.lapd.constants.constants.rst @@ -11,7 +11,7 @@ bapsflib\.lapd\.constants\.constants .. autosummary:: :nosignatures: - BaPSFConstant + LaPDConstant SouthCathode .. rubric:: Constants diff --git a/docs/src/bapsflib.plasma.constants.rst b/docs/src/bapsflib.plasma.constants.rst new file mode 100644 index 00000000..ad9f1261 --- /dev/null +++ b/docs/src/bapsflib.plasma.constants.rst @@ -0,0 +1,16 @@ +bapsflib\.plasma\.\constants +============================ +.. py:currentmodule:: bapsflib.plasma.constants + +.. automodule:: bapsflib.plasma.constants + +.. .. rubric:: Constants + +.. .. autosummary:: + :nosignatures: + +.. port_spacing + ref_port + +.. .. autodata:: port_spacing +.. .. autodata:: ref_port \ No newline at end of file diff --git a/docs/src/bapsflib.plasma.parameters.rst b/docs/src/bapsflib.plasma.parameters.rst new file mode 100644 index 00000000..0158e13b --- /dev/null +++ b/docs/src/bapsflib.plasma.parameters.rst @@ -0,0 +1,57 @@ +bapsflib\.plasma\.parameters +============================ + +.. automodule:: bapsflib.plasma.parameters + :show-inheritance: + :members: + :undoc-members: + + .. rubric:: Frequencies + + .. autosummary:: + :nosignatures: + + cyclotron_frequency + oce + oci + lower_hybrid_frequency + oLH + plasma_frequency + ope + opi + upper_hybrid_frequency + oUH + + .. rubric:: Lengths + + .. autosummary:: + :nosignatures: + + cyclotron_radius + rce + rci + Debye_length + lD + inertial_length + lpe + lpi + + .. rubric:: Velocities + + .. autosummary:: + :nosignatures: + + Alfven_speed + VA + ion_sound_speed + cs + thermal_speed + vTe + vTi + + .. rubric:: Dimensionless + + .. autosummary:: + :nosignatures: + + beta diff --git a/docs/src/bapsflib.plasma.rst b/docs/src/bapsflib.plasma.rst index 8cc509ea..ce37c448 100644 --- a/docs/src/bapsflib.plasma.rst +++ b/docs/src/bapsflib.plasma.rst @@ -4,30 +4,25 @@ bapsflib\.plasma .. warning:: Package Currently Under Development .. automodule:: bapsflib.plasma - :members: - :undoc-members: - :show-inheritance: - -Subpackages ------------ .. toctree:: - :maxdepth: 1 + :maxdepth: 2 + :titlesonly: + :caption: Sub-Packages & Modules + bapsflib.plasma.constants + bapsflib.plasma.parameters -Modules -------- +.. bapsflib.plasma.plasma -.. contents:: - :depth: 2 - :local: +.. .. rubric:: Classes -bapsflib\.plasma\.core -^^^^^^^^^^^^^^^^^^^^^^ +.. .. autosummary:: bapsflib.Plasma.SimplePlasma + :nosignatures: -.. automodule:: bapsflib.plasma.core +.. .. autoclass:: bapsflib.lapd.SimplePlasma :members: :undoc-members: - :special-members: - :exclude-members: __dict__, __init__, __module__, __weakref__ :show-inheritance: + :inherited-members: + :exclude-members: diff --git a/docs/src/bapsflib.rst b/docs/src/bapsflib.rst index 07c6dcc1..51e2b1f1 100644 --- a/docs/src/bapsflib.rst +++ b/docs/src/bapsflib.rst @@ -13,6 +13,5 @@ bapsflib ./bapsflib._hdf ./bapsflib.lapd + ./bapsflib.plasma ./bapsflib.utils - -.. ./bapsflib.plasma diff --git a/requirements.txt b/requirements.txt index 7236ac35..77212abd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ codecov >= 2.0.15 coverage >= 4.5.1 h5py >= 2.6 numpy >= 1.12 +plasmapy >= 0.1.0 scipy >= 1.0.0 # # Add Sphinx/Documentation requirements diff --git a/setup.py b/setup.py index 76d8f633..2f13cc62 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,6 @@ "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Education", @@ -81,7 +80,7 @@ def find_version(*file_paths): 'h5py>=2.6', 'numpy>=1.7', 'scipy>=1.0.0'], - python_requires='>=3.5', + python_requires='>=3.6', author='Erik T. Everson', author_email='eteveson@gmail.com', license='3-clause BSD',