diff --git a/tests/test_formula_evaluate.py b/tests/test_formula_evaluate.py index 8c438ba..b1c2e9c 100644 --- a/tests/test_formula_evaluate.py +++ b/tests/test_formula_evaluate.py @@ -178,6 +178,53 @@ def evaluate_expr(source: str, columns: dict[str, Col]) -> object: columns=_COLUMNS, result_val=["NGC 123", "IC 456", "M 789"], ), + EvalCase( + name="to_deg_sexagesimal_hourangle", + expression='to_deg(col("ra"))', + columns={"ra": Col("00 02 08.4", "hourangle")}, + result_val=0.535, + result_unit=u.deg, + ), + EvalCase( + name="to_deg_sexagesimal_deg", + expression='to_deg(col("dec"))', + columns={"dec": Col("+16 35 13", "deg")}, + result_val=16.5869, + result_unit=u.deg, + ), + EvalCase( + name="to_deg_embedded_unit_string", + expression='to_deg(col("ra"))', + columns={"ra": Col("00h02m08.4s")}, + result_val=0.535, + result_unit=u.deg, + ), + EvalCase( + name="to_deg_quantity", + expression='to_deg(col("angle_col"))', + columns=_COLUMNS, + result_val=190.0, + result_unit=u.deg, + ), + EvalCase( + name="to_deg_hourangle_quantity", + expression='to_deg(col("ra"))', + columns={"ra": Col(1.0, "hourangle")}, + result_val=15.0, + result_unit=u.deg, + ), + EvalCase( + name="string_column_with_unit_concat", + expression='col("ra") + "x"', + columns={"ra": Col("00 02 08.4", "hourangle")}, + result_val="00 02 08.4x", + ), + EvalCase( + name="error_to_deg_bare_string", + expression='to_deg(col("ra"))', + columns={"ra": Col("00 02 08.4")}, + error=True, + ), ] diff --git a/tests/test_formula_parse.py b/tests/test_formula_parse.py index f5270e1..e4cab6c 100644 --- a/tests/test_formula_parse.py +++ b/tests/test_formula_parse.py @@ -6,6 +6,7 @@ ('col("a") + col("b")', {"a", "b"}), ('col("weird name")', {"weird name"}), ('sin(col("pa")) + pi', {"pa"}), + ('to_deg(col("RAJ2000"))', {"RAJ2000"}), ('3 * 10 ** col("logd25") * col("e_logd25") * arcsec', {"logd25", "e_logd25"}), ('"M " + col("id")', {"id"}), ("1 + 2", set()), diff --git a/uploader/app/lib/formula/__init__.py b/uploader/app/lib/formula/__init__.py index b5f3f85..a1ef213 100644 --- a/uploader/app/lib/formula/__init__.py +++ b/uploader/app/lib/formula/__init__.py @@ -5,13 +5,14 @@ ExpressionSyntaxError, ) from uploader.app.lib.formula.namespace import expression_syntax_help -from uploader.app.lib.formula.values import Value, column_quantity +from uploader.app.lib.formula.values import TextValue, Value, column_quantity __all__ = [ "Expression", "ExpressionError", "ExpressionEvaluationError", "ExpressionSyntaxError", + "TextValue", "Value", "column_quantity", "evaluate", diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index 0d469dc..ce5cee8 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -3,8 +3,9 @@ import astropy.constants as const import astropy.units as u import numpy as np +from astropy.coordinates import Angle -from uploader.app.lib.formula.values import Value +from uploader.app.lib.formula.values import TextValue, Value COL_FUNCTION = "col" @@ -27,6 +28,8 @@ def _scalar_to_str(value: float | int | np.number) -> str: def _formula_str(value: Value) -> str | np.ndarray: + if isinstance(value, TextValue): + return value.data if isinstance(value, str): return value if isinstance(value, u.Quantity): @@ -37,10 +40,24 @@ def _formula_str(value: Value) -> str | np.ndarray: return np.asarray([_scalar_to_str(x) for x in value]) +def _to_deg(value: object) -> u.Quantity: + if isinstance(value, TextValue): + angle = Angle(value.data, unit=u.Unit(value.unit)) if value.unit else Angle(value.data) + return angle.to(u.deg) + if isinstance(value, str): + return Angle(value).to(u.deg) + if isinstance(value, u.Quantity): + return value.to(u.deg) + if isinstance(value, np.ndarray): + return u.Quantity([_to_deg(item).value for item in value], unit=u.deg) + raise TypeError(f"to_deg() expected angle or coordinate string, got {type(value).__name__}") + + FUNCTIONS: dict[str, object] = { "sin": np.sin, "cos": np.cos, "str": _formula_str, + "to_deg": _to_deg, } @@ -63,7 +80,8 @@ def expression_syntax_help() -> str: Mathematical operations: - Operators: `+` `-` `*` `/` `**` `%` -- Functions: `sin(x)`, `cos(x)` (argument must be an angle), `str(x)` +- Functions: `sin(x)`, `cos(x)` (argument must be an angle), `str(x)`, `to_deg(x)` +- `to_deg(x)` parses coordinate strings or converts angle quantities to degrees - Numbers are dimensionless - String literals and `+` concatenation are supported - Modulo divisors must carry units (e.g. `col("pa") % (180 * deg)`) @@ -78,4 +96,5 @@ def expression_syntax_help() -> str: - `180 * deg` - `"G"` - fills the column with a text "G" - Copy another column: `{COL_FUNCTION}("ra")` +- Sexagesimal coordinates: `to_deg({COL_FUNCTION}("RAJ2000"))` - Mathematical expression: `3 * 10 ** {COL_FUNCTION}("logd25") * arcsec`""" diff --git a/uploader/app/lib/formula/values.py b/uploader/app/lib/formula/values.py index 102a4ba..c063bd6 100644 --- a/uploader/app/lib/formula/values.py +++ b/uploader/app/lib/formula/values.py @@ -1,10 +1,32 @@ from collections.abc import Sequence +from dataclasses import dataclass +from typing import final import astropy.units as u import numpy as np from astropy.units.function.core import FunctionUnitBase -type Value = u.Quantity | str | np.ndarray + +@final +@dataclass(frozen=True) +class TextValue: + data: str + unit: str = "" + + def __add__(self, other: object) -> str: + if isinstance(other, TextValue): + return self.data + other.data + if isinstance(other, str): + return self.data + other + return NotImplemented + + def __radd__(self, other: object) -> str: + if isinstance(other, str): + return other + self.data + return NotImplemented + + +type Value = u.Quantity | str | TextValue | np.ndarray def _is_logarithmic_column_unit(unit: u.Unit) -> bool: @@ -13,7 +35,7 @@ def _is_logarithmic_column_unit(unit: u.Unit) -> bool: def column_quantity(value: float | str | Sequence[float] | Sequence[str], unit: str) -> Value: if isinstance(value, str): - return value + return TextValue(value, unit) if unit else value if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): if all(isinstance(x, str) for x in value): return np.asarray(value) diff --git a/uploader/app/structured/designations/upload.py b/uploader/app/structured/designations/upload.py index 1cf265b..696eccf 100644 --- a/uploader/app/structured/designations/upload.py +++ b/uploader/app/structured/designations/upload.py @@ -15,6 +15,7 @@ from uploader.app.lib.formula import ( Expression, ExpressionEvaluationError, + TextValue, Value, column_quantity, evaluate, @@ -139,6 +140,8 @@ def _build_column_values( def _designation_string(value: Value) -> str: if isinstance(value, str): return value.strip() + if isinstance(value, TextValue): + return value.data.strip() if isinstance(value, u.Quantity): scalar = value.value if isinstance(scalar, np.ndarray) and scalar.shape != (): diff --git a/uploader/app/structured/generic/upload.py b/uploader/app/structured/generic/upload.py index 899ac1b..d7d4a19 100644 --- a/uploader/app/structured/generic/upload.py +++ b/uploader/app/structured/generic/upload.py @@ -11,6 +11,7 @@ from uploader.app.lib.formula import ( Expression, ExpressionEvaluationError, + TextValue, Value, column_quantity, evaluate, @@ -100,6 +101,8 @@ def _scalar_to_str(value: float | int | np.number) -> str: def _value_to_str(value: Value) -> str: if isinstance(value, str): return value + if isinstance(value, TextValue): + return value.data if isinstance(value, u.Quantity): scalar = value.value if isinstance(scalar, np.ndarray):