# Licensed under a 3-clause BSD style license - see LICNSE.rst
# This module includes files automatically generated from ply (these end in
# _lextab.py and _parsetab.py). To generate these files, remove them from this
# folder, then build astropy and run the tests in-place:
#
# python setup.py build_ext --inplace
# pytest astropy/units
#
# You can then commit the changes to the re-generated _lextab.py and
# _parsetab.py files.
"""
Handles units in `Office of Guest Investigator Programs (OGIP)
FITS files
<https://heasarc.gsfc.nasa.gov/docs/heasarc/ofwg/docs/general/ogip_93_001/>`__.
"""
import math
import warnings
from fractions import Fraction
from typing import ClassVar, Literal
import numpy as np
from ply.lex import Lexer
from astropy.units.core import CompositeUnit, UnitBase
from astropy.units.errors import UnitParserWarning, UnitsWarning
from astropy.units.typing import UnitScale
from astropy.utils import classproperty, parsing
from astropy.utils.parsing import ThreadSafeParser
from . import utils
from .base import Base, _ParsingFormatMixin
[docs]
class OGIP(Base, _ParsingFormatMixin):
"""
Support the units in `Office of Guest Investigator Programs (OGIP)
FITS files
<https://heasarc.gsfc.nasa.gov/docs/heasarc/ofwg/docs/general/ogip_93_001/>`__.
"""
_tokens: ClassVar[tuple[str, ...]] = (
"DIVISION",
"OPEN_PAREN",
"CLOSE_PAREN",
"WHITESPACE",
"POWER",
"STAR",
"SIGN",
"UFLOAT",
"LIT10",
"UINT",
"UNKNOWN",
"FUNCNAME",
"UNIT",
)
_deprecated_units: ClassVar[frozenset[str]] = frozenset(("Crab", "mCrab"))
@classproperty(lazy=True)
def _units(cls) -> dict[str, UnitBase]:
from astropy import units as u
bases = [
"A", "C", "cd", "eV", "F", "g", "H", "Hz", "J",
"Jy", "K", "lm", "lx", "m", "mol", "N", "ohm", "Pa",
"pc", "rad", "s", "S", "sr", "T", "V", "W", "Wb",
] # fmt: skip
prefixes = [
"y", "z", "a", "f", "p", "n", "u", "m", "c", "d",
"", "da", "h", "k", "M", "G", "T", "P", "E", "Z", "Y",
] # fmt: skip
names = {
unit: getattr(u, unit)
for unit, _ in utils.get_non_keyword_units(bases, prefixes)
}
simple_units = [
"angstrom", "arcmin", "arcsec", "AU", "barn", "bin",
"byte", "chan", "count", "d", "deg", "erg", "G",
"h", "lyr", "mag", "min", "photon", "pixel",
"voxel", "yr",
] # fmt: skip
names.update((unit, getattr(u, unit)) for unit in simple_units)
# Create a separate, disconnected unit for the special case of
# Crab and mCrab, since OGIP doesn't define their quantities.
names["Crab"] = u.def_unit(["Crab"], prefixes=False, doc="Crab (X-ray flux)")
names["mCrab"] = u.Unit(10**-3 * names["Crab"])
return names
@classproperty(lazy=True)
def _lexer(cls) -> Lexer:
tokens = cls._tokens
t_DIVISION = "[ \t]*/[ \t]*"
t_OPEN_PAREN = r"\("
t_CLOSE_PAREN = r"\)"
t_WHITESPACE = "[ \t]+"
t_POWER = r"\*\*"
t_STAR = r"\*"
# NOTE THE ORDERING OF THESE RULES IS IMPORTANT!!
# Regular expression rules for simple tokens
def t_UFLOAT(t):
r"(((\d+\.?\d*)|(\.\d+))([eE][+-]?\d+))|(((\d+\.\d*)|(\.\d+))([eE][+-]?\d+)?)"
t.value = float(t.value)
return t
def t_UINT(t):
r"\d+"
t.value = int(t.value)
return t
def t_SIGN(t):
r"[+-](?=\d)"
t.value = 1 if t.value == "+" else -1
return t
def t_LIT10(t):
r"10"
return 10
def t_UNKNOWN(t):
r"[Uu][Nn][Kk][Nn][Oo][Ww][Nn]"
return None
def t_FUNCNAME(t):
r"((sqrt)|(ln)|(exp)|(log)|(sin)|(cos)|(tan)|(asin)|(acos)|(atan)|(sinh)|(cosh)|(tanh))(?=\ *\()"
return t
def t_UNIT(t):
r"[a-zA-Z][a-zA-Z_]*"
t.value = cls._get_unit(t)
return t
# Don't ignore whitespace
t_ignore = ""
# Error handling rule
def t_error(t):
raise ValueError(f"Invalid character at col {t.lexpos}")
return parsing.lex(lextab="ogip_lextab", package="astropy/units")
@classproperty(lazy=True)
def _parser(cls) -> ThreadSafeParser:
"""
The grammar here is based on the description in the
`Specification of Physical Units within OGIP FITS files
<https://heasarc.gsfc.nasa.gov/docs/heasarc/ofwg/docs/general/ogip_93_001/>`__,
which is not terribly precise. The exact grammar is here is
based on the YACC grammar in the `unity library
<https://bitbucket.org/nxg/unity/>`_.
"""
tokens = cls._tokens
def p_main(p):
"""
main : UNKNOWN
| complete_expression
| scale_factor complete_expression
| scale_factor WHITESPACE complete_expression
"""
match p[1:]:
case (factor, unit) | (factor, _, unit):
p[0] = CompositeUnit(factor * unit.scale, unit.bases, unit.powers)
case _:
p[0] = p[1]
def p_complete_expression(p):
"""
complete_expression : unit_expression
| product_of_units
| division_of_units
"""
# product_of_units is not in unit_expression for performance
# division_of_units is separate to enforce the correct order of operations
p[0] = p[1]
def p_product_of_units(p):
"""
product_of_units : complete_expression product unit_expression
"""
p[0] = p[1] * p[3]
def p_division_of_units(p):
"""
division_of_units : DIVISION unit_expression
| complete_expression DIVISION unit_expression
"""
match p[1:]:
case _, unit:
p[0] = unit**-1
case num, _, denom:
p[0] = num / denom
def p_unit_expression(p):
"""
unit_expression : UNIT
| function
| UNIT POWER numeric_power
| UNIT OPEN_PAREN complete_expression CLOSE_PAREN
| OPEN_PAREN complete_expression CLOSE_PAREN
| UNIT OPEN_PAREN complete_expression CLOSE_PAREN POWER numeric_power
| OPEN_PAREN complete_expression CLOSE_PAREN POWER numeric_power
"""
bad_multiplication_message = (
"if '{0}{1}' was meant to be a multiplication, "
"it should have been written as '{0} {1}'."
)
match p[1:]:
case factor, _, unit, _, _, power:
warnings.warn(
bad_multiplication_message.format(factor, f"({unit})**{power}"),
UnitParserWarning,
)
p[0] = factor * unit**power
case (_, unit, _, _, power) | (unit, "**", power):
p[0] = unit**power
case left, _, right, _:
warnings.warn(
bad_multiplication_message.format(left, f"({right})"),
UnitParserWarning,
)
p[0] = left * right
case _, unit, _:
p[0] = unit
case _:
p[0] = p[1]
def p_function(p):
"""
function : FUNCNAME OPEN_PAREN complete_expression CLOSE_PAREN
| FUNCNAME OPEN_PAREN complete_expression CLOSE_PAREN POWER numeric_power
"""
match p[1:]:
case "sqrt", _, unit, _:
p[0] = unit**0.5
case "sqrt", _, unit, _, _, numeric_power:
p[0] = unit ** (0.5 * numeric_power)
case func, *_:
raise ValueError(
f"The function '{func}' is valid in OGIP, but not understood "
"by astropy.units."
)
def p_scale_factor(p):
"""
scale_factor : LIT10 POWER numeric_power
| LIT10
| signed_float
| signed_float POWER numeric_power
| signed_int POWER numeric_power
"""
if len(p) == 4:
p[0] = 10 ** p[3]
else:
p[0] = p[1]
# Can't use np.log10 here, because p[0] may be a Python long.
if math.log10(p[0]) % 1.0 != 0.0:
warnings.warn(
f"'{p[0]}' scale should be a power of 10 in OGIP format",
UnitsWarning,
)
def p_product(p):
"""
product : WHITESPACE
| STAR
| WHITESPACE STAR
| WHITESPACE STAR WHITESPACE
| STAR WHITESPACE
"""
def p_numeric_power(p):
"""
numeric_power : UINT
| signed_float
| OPEN_PAREN signed_int CLOSE_PAREN
| OPEN_PAREN signed_float CLOSE_PAREN
| OPEN_PAREN signed_float DIVISION UINT CLOSE_PAREN
"""
if len(p) == 6:
p[0] = Fraction(int(p[2]), int(p[4]))
elif len(p) == 4:
p[0] = p[2]
else:
p[0] = p[1]
if p[1] < 0:
warnings.warn(
UnitParserWarning(
"negative exponents must be enclosed in parenthesis. "
f"Expected '**({p[1]})' instead of '**{p[1]}'."
)
)
def p_sign(p):
"""
sign : SIGN
|
"""
if len(p) == 2:
p[0] = p[1]
else:
p[0] = 1.0
def p_signed_int(p):
"""
signed_int : SIGN UINT
"""
p[0] = p[1] * p[2]
def p_signed_float(p):
"""
signed_float : sign UINT
| sign UFLOAT
"""
p[0] = p[1] * p[2]
def p_error(p):
raise ValueError()
return parsing.yacc(tabmodule="ogip_parsetab", package="astropy/units")
[docs]
@classmethod
def parse(cls, s: str, debug: bool = False) -> UnitBase:
return cls._do_parse(s.strip(), debug)
@classmethod
def _format_superscript(cls, number: str) -> str:
return f"**({number})" if "/" in number else f"**{number}"
[docs]
@classmethod
def to_string(
cls, unit: UnitBase, fraction: bool | Literal["inline", "multiline"] = "inline"
) -> str:
# Remove units that aren't known to the format
unit = cls._decompose_to_known_units(unit)
if isinstance(unit, CompositeUnit):
# Can't use np.log10 here, because p[0] may be a Python long.
if math.log10(unit.scale) % 1.0 != 0.0:
warnings.warn(
f"'{unit.scale}' scale should be a power of 10 in OGIP format",
UnitsWarning,
)
return super().to_string(unit, fraction=fraction)
@classmethod
def _validate_unit(cls, unit: str, detailed_exception: bool = True) -> UnitBase:
if unit in cls._deprecated_units:
warnings.warn(
f"The unit '{unit}' has been deprecated in the OGIP standard.",
UnitsWarning,
)
return super()._validate_unit(unit, detailed_exception)