From cfff75e00c3f2e99c60861c6e51f8c42490a803c Mon Sep 17 00:00:00 2001 From: kraysent Date: Wed, 19 Aug 2026 23:10:31 +0100 Subject: [PATCH 1/6] add more functions to expression engine --- tests/test_formula_evaluate.py | 91 +++++++++++++++++++++++++++ tests/test_formula_parse.py | 1 + tests/test_formula_validate.py | 1 + uploader/app/lib/formula/namespace.py | 59 ++++++++++++++++- 4 files changed, 149 insertions(+), 3 deletions(-) diff --git a/tests/test_formula_evaluate.py b/tests/test_formula_evaluate.py index 0ba84c3..2ccf6c5 100644 --- a/tests/test_formula_evaluate.py +++ b/tests/test_formula_evaluate.py @@ -124,6 +124,97 @@ def evaluate_expr(source: str, columns: dict[str, Col]) -> object: result_val=1.5, result_unit=u.dimensionless_unscaled, ), + EvalCase( + name="tan", + expression="tan(45 * deg)", + columns={}, + result_val=1.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="asin", + expression='asin(col("float_col_dimless"))', + columns=_COLUMNS, + result_val=0.5236, + result_unit=u.rad, + ), + EvalCase( + name="acos", + expression="acos(1)", + columns={}, + result_val=0.0, + result_unit=u.rad, + ), + EvalCase( + name="atan", + expression="atan(1)", + columns={}, + result_val=0.7854, + result_unit=u.rad, + ), + EvalCase( + name="atan2", + expression="atan2(0, 1)", + columns={}, + result_val=0.0, + result_unit=u.rad, + ), + EvalCase( + name="deg2rad", + expression="deg2rad(180)", + columns={}, + result_val=np.pi, + result_unit=u.rad, + ), + EvalCase( + name="rad2deg", + expression="rad2deg(pi)", + columns={}, + result_val=180.0, + result_unit=u.deg, + ), + EvalCase( + name="wrap360", + expression="wrap360(370 * deg)", + columns={}, + result_val=10.0, + result_unit=u.deg, + ), + EvalCase( + name="wrap360_negative", + expression="wrap360(-10 * deg)", + columns={}, + result_val=350.0, + result_unit=u.deg, + ), + EvalCase( + name="exp", + expression="exp(0)", + columns={}, + result_val=1.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="log10", + expression="log10(100)", + columns={}, + result_val=2.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="ln", + expression="ln(1)", + columns={}, + result_val=0.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="pow", + expression="pow(2, 3)", + columns={}, + result_val=8.0, + result_unit=u.dimensionless_unscaled, + ), EvalCase( name="vector_sqrt", expression='sqrt(col("vec_col"))', diff --git a/tests/test_formula_parse.py b/tests/test_formula_parse.py index dd9b717..b106575 100644 --- a/tests/test_formula_parse.py +++ b/tests/test_formula_parse.py @@ -24,6 +24,7 @@ ("e_logd25 + logd25", ExpressionSyntaxError), ("logd25 + logd25", ExpressionSyntaxError), ('3 * 10 ** col("logd25") * e_logd25 * arcsec', ExpressionSyntaxError), + ("atan2(1, 0) + wrap360(10 * deg)", set()), ("unknown(1)", ExpressionSyntaxError), ("col", ExpressionSyntaxError), ] diff --git a/tests/test_formula_validate.py b/tests/test_formula_validate.py index a9c2582..d62f08e 100644 --- a/tests/test_formula_validate.py +++ b/tests/test_formula_validate.py @@ -9,6 +9,7 @@ def test_validate_expression_accepts_valid() -> None: assert validate_expression('to_deg(col("RAJ2000"))') == [] assert validate_expression("sin(pi) + 1.5 * deg") == [] assert validate_expression("sqrt(4)") == [] + assert validate_expression("atan2(1, 0) + pow(2, 3) + wrap360(10 * deg)") == [] assert validate_expression('col("dist") * unit("Mpc")') == [] assert validate_expression("") == [] diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index 8805dd5..b8704ee 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -1,4 +1,5 @@ -from collections.abc import Mapping +import re +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any, Literal, TypedDict, final @@ -24,7 +25,7 @@ def insert(self) -> str: @property def signature(self) -> str: - args = self.placeholder.replace("${1:", "").replace("}", "") + args = re.sub(r"\$\{\d+:([^}]+)\}", r"\1", self.placeholder) return f"{self.name}({args})" @@ -107,12 +108,64 @@ def _unit(name: object) -> u.Quantity: return 1 * u.Unit(name) +def _to_quantity(value: object) -> u.Quantity: + if isinstance(value, u.Quantity): + return value + return np.asarray(value, dtype=float) * u.dimensionless_unscaled + + +def _math(fn: Callable[..., u.Quantity]) -> Callable[..., u.Quantity]: + def impl(*args: object) -> u.Quantity: + return fn(*(_to_quantity(arg) for arg in args)) + + return impl + + +def _as_angle(value: object, default_unit: u.Unit) -> u.Quantity: + if isinstance(value, u.Quantity): + if value.unit.is_equivalent(u.rad): + return value + if value.unit.is_equivalent(u.dimensionless_unscaled): + return value.to_value(u.dimensionless_unscaled) * default_unit + raise TypeError(f"expected an angle or dimensionless value, got unit {value.unit}") + return np.asarray(value, dtype=float) * default_unit + + +def _deg2rad(value: object) -> u.Quantity: + return _as_angle(value, u.deg).to(u.rad) + + +def _rad2deg(value: object) -> u.Quantity: + return _as_angle(value, u.rad).to(u.deg) + + +def _wrap360(value: object) -> u.Quantity: + return _as_angle(value, u.deg).to(u.deg) % (360 * u.deg) + + COL_FUNCTION = FunctionDef("col", "Rawdata column", placeholder='"${1:name}"') FUNCTIONS: tuple[FunctionDef, ...] = ( FunctionDef("sin", "Sine (argument must be an angle)", np.sin), FunctionDef("cos", "Cosine (argument must be an angle)", np.cos), - FunctionDef("sqrt", "Square root", np.sqrt), + FunctionDef("tan", "Tangent (argument must be an angle)", np.tan), + FunctionDef("asin", "Arcsine (returns radians)", _math(np.arcsin)), + FunctionDef("acos", "Arccosine (returns radians)", _math(np.arccos)), + FunctionDef("atan", "Arctangent (returns radians)", _math(np.arctan)), + FunctionDef( + "atan2", + "Two-argument arctangent (returns radians)", + _math(np.arctan2), + placeholder="${1:y}, ${2:x}", + ), + FunctionDef("deg2rad", "Convert degrees to radians", _deg2rad, placeholder="${1:deg}"), + FunctionDef("rad2deg", "Convert radians to degrees", _rad2deg, placeholder="${1:rad}"), + FunctionDef("wrap360", "Wrap angle to [0, 360) degrees", _wrap360, placeholder="${1:deg}"), + FunctionDef("sqrt", "Square root", _math(np.sqrt)), + FunctionDef("exp", "Exponential", _math(np.exp)), + FunctionDef("log10", "Base-10 logarithm", _math(np.log10)), + FunctionDef("ln", "Natural logarithm", _math(np.log)), + FunctionDef("pow", "Raise x to the power y", _math(np.power), placeholder="${1:x}, ${2:y}"), FunctionDef("str", "Convert to text", _formula_str), FunctionDef( "to_deg", From b7f098b471244a3196bf676844272c964119035c Mon Sep 17 00:00:00 2001 From: kraysent Date: Wed, 19 Aug 2026 23:13:52 +0100 Subject: [PATCH 2/6] add more constants --- tests/test_formula_evaluate.py | 29 +++++++++++++++++++++++++++ tests/test_formula_parse.py | 1 + tests/test_formula_validate.py | 1 + uploader/app/lib/formula/namespace.py | 16 +++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/tests/test_formula_evaluate.py b/tests/test_formula_evaluate.py index 2ccf6c5..207b2f1 100644 --- a/tests/test_formula_evaluate.py +++ b/tests/test_formula_evaluate.py @@ -1,5 +1,6 @@ from dataclasses import dataclass +import astropy.constants as const import astropy.units as u import numpy as np import pytest @@ -229,6 +230,34 @@ def evaluate_expr(source: str, columns: dict[str, Col]) -> object: result_val=3.1416, result_unit=u.dimensionless_unscaled, ), + EvalCase( + name="speed_of_light", + expression="c", + columns={}, + result_val=299792458.0, + result_unit=u.m / u.s, + ), + EvalCase( + name="solar_mass", + expression="M_sun", + columns={}, + result_val=const.M_sun.value, + result_unit=u.kg, + ), + EvalCase( + name="parsec", + expression="pc", + columns={}, + result_val=1.0, + result_unit=u.pc, + ), + EvalCase( + name="jansky", + expression="Jy", + columns={}, + result_val=1.0, + result_unit=u.Jy, + ), EvalCase(name="error_missing_column_call", expression='col("missing")', columns={}, error=True), EvalCase(name="error_incompatible_units", expression="arcsec + mag", columns={}, error=True), EvalCase( diff --git a/tests/test_formula_parse.py b/tests/test_formula_parse.py index b106575..1f4b0c5 100644 --- a/tests/test_formula_parse.py +++ b/tests/test_formula_parse.py @@ -12,6 +12,7 @@ ('"M " + col("id")', {"id"}), ("1 + 2", set()), ("pi + deg", set()), + ("M_sun * G / c ** 2", set()), ("", ExpressionSyntaxError), ("1 +", ExpressionSyntaxError), ("col(", ExpressionSyntaxError), diff --git a/tests/test_formula_validate.py b/tests/test_formula_validate.py index d62f08e..dc9b8f0 100644 --- a/tests/test_formula_validate.py +++ b/tests/test_formula_validate.py @@ -11,6 +11,7 @@ def test_validate_expression_accepts_valid() -> None: assert validate_expression("sqrt(4)") == [] assert validate_expression("atan2(1, 0) + pow(2, 3) + wrap360(10 * deg)") == [] assert validate_expression('col("dist") * unit("Mpc")') == [] + assert validate_expression("M_sun / L_sun * sigma") == [] assert validate_expression("") == [] diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index b8704ee..f8c55ed 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -51,6 +51,22 @@ class OperatorDef: NAMED_CONSTANTS: tuple[ConstantDef, ...] = ( ConstantDef("pi", np.pi * u.dimensionless_unscaled, "Pi"), ConstantDef("c", const.c, "Speed of light"), + ConstantDef("G", const.G, "Gravitational constant"), + ConstantDef("h", const.h, "Planck constant"), + ConstantDef("k_B", const.k_B, "Boltzmann constant"), + ConstantDef("sigma", const.sigma_sb, "Stefan-Boltzmann constant"), + ConstantDef("m_e", const.m_e, "Electron mass"), + ConstantDef("m_p", const.m_p, "Proton mass"), + ConstantDef("au", 1 * u.au, "Astronomical unit"), + ConstantDef("pc", 1 * u.pc, "Parsec"), + ConstantDef("ly", 1 * u.lyr, "Light year"), + ConstantDef("eV", 1 * u.eV, "Electronvolt"), + ConstantDef("Jy", 1 * u.Jy, "Jansky"), + ConstantDef("M_sun", const.M_sun, "Solar mass"), + ConstantDef("R_sun", const.R_sun, "Nominal solar radius"), + ConstantDef("L_sun", const.L_sun, "Nominal solar luminosity"), + ConstantDef("M_earth", const.M_earth, "Earth mass"), + ConstantDef("M_jup", const.M_jup, "Jupiter mass"), ConstantDef("deg", 1 * u.deg, "Degree"), ConstantDef("rad", 1 * u.rad, "Radian"), ConstantDef("arcmin", 1 * u.arcmin, "Arcminute"), From 8e6700333484caa67bf7af12b22da8824189108d Mon Sep 17 00:00:00 2001 From: kraysent Date: Wed, 19 Aug 2026 23:21:42 +0100 Subject: [PATCH 3/6] add where function --- tests/test_formula_evaluate.py | 53 +++++++++++++++++++++++++++ tests/test_formula_parse.py | 2 + tests/test_formula_validate.py | 2 + uploader/app/lib/formula/namespace.py | 35 +++++++++++++++++- 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/tests/test_formula_evaluate.py b/tests/test_formula_evaluate.py index 207b2f1..44db4d5 100644 --- a/tests/test_formula_evaluate.py +++ b/tests/test_formula_evaluate.py @@ -380,6 +380,59 @@ def evaluate_expr(source: str, columns: dict[str, Col]) -> object: result_val=1.5, result_unit=u.km / u.s, ), + EvalCase( + name="where_true", + expression='where(col("float_col") > 1, col("float_col"), 0)', + columns=_COLUMNS, + result_val=1.5, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="where_false", + expression='where(col("float_col") > 2, 10, col("float_col"))', + columns=_COLUMNS, + result_val=1.5, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="where_nested", + expression='where(col("float_col") > 2, 1, where(col("float_col") > 1, 2, 3))', + columns=_COLUMNS, + result_val=2, + ), + EvalCase( + name="where_angle", + expression='where(col("angle_col") > 180 * deg, col("angle_col") - 360 * deg, col("angle_col"))', + columns=_COLUMNS, + result_val=-170.0, + result_unit=u.deg, + ), + EvalCase( + name="where_string", + expression='where(col("string_col_2") == "M", col("string_col_1"), "x")', + columns=_COLUMNS, + result_val="NGC 123", + ), + EvalCase( + name="where_vector", + expression='where(col("vec_col") > 1.5, col("vec_col"), 0)', + columns=_COLUMNS, + result_val=[0.0, 2.0, 3.0], + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="where_vector_angle", + expression='where(col("vec_angle_col") > 90 * deg, col("vec_angle_col"), 0 * deg)', + columns=_COLUMNS, + result_val=[0.0, 0.0, 180.0], + result_unit=u.deg, + ), + EvalCase( + name="where_vector_string", + expression='where(col("vec_string_a") == "IC", col("vec_string_b"), "x")', + columns=_COLUMNS, + result_val=["x", "456", "x"], + ), EvalCase(name="error_unknown_unit", expression='unit("not_a_unit")', columns={}, error=True), EvalCase(name="error_unit_non_string", expression="unit(1)", columns={}, error=True), ] diff --git a/tests/test_formula_parse.py b/tests/test_formula_parse.py index 1f4b0c5..a78aa56 100644 --- a/tests/test_formula_parse.py +++ b/tests/test_formula_parse.py @@ -26,6 +26,8 @@ ("logd25 + logd25", ExpressionSyntaxError), ('3 * 10 ** col("logd25") * e_logd25 * arcsec', ExpressionSyntaxError), ("atan2(1, 0) + wrap360(10 * deg)", set()), + ('where(col("a") > 0, col("a"), 0)', {"a"}), + ('where(col("a") == "M", col("b"), where(col("a") == "N", col("c"), "x"))', {"a", "b", "c"}), ("unknown(1)", ExpressionSyntaxError), ("col", ExpressionSyntaxError), ] diff --git a/tests/test_formula_validate.py b/tests/test_formula_validate.py index dc9b8f0..92d786d 100644 --- a/tests/test_formula_validate.py +++ b/tests/test_formula_validate.py @@ -12,6 +12,8 @@ def test_validate_expression_accepts_valid() -> None: assert validate_expression("atan2(1, 0) + pow(2, 3) + wrap360(10 * deg)") == [] assert validate_expression('col("dist") * unit("Mpc")') == [] assert validate_expression("M_sun / L_sun * sigma") == [] + assert validate_expression('where(col("v") > 0, col("v"), 0)') == [] + assert validate_expression('where(col("a") == "M", 1, where(col("a") != "N", 2, 3))') == [] assert validate_expression("") == [] diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index f8c55ed..e60faf8 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -82,6 +82,12 @@ class OperatorDef: OperatorDef("/", "Division"), OperatorDef("**", "Exponentiation"), OperatorDef("%", 'Modulo; divisor must carry units (e.g. col("pa") % (180 * deg))'), + OperatorDef("==", "Equal"), + OperatorDef("!=", "Not equal"), + OperatorDef("<", "Less than"), + OperatorDef("<=", "Less than or equal"), + OperatorDef(">", "Greater than"), + OperatorDef(">=", "Greater than or equal"), ) @@ -159,6 +165,26 @@ def _wrap360(value: object) -> u.Quantity: return _as_angle(value, u.deg).to(u.deg) % (360 * u.deg) +def _mask(cond: object) -> np.ndarray: + if isinstance(cond, u.Quantity): + return np.asarray(cond.value) + return np.asarray(cond) + + +def _where(cond: object, then: object, otherwise: object) -> object: + mask = _mask(cond) + if mask.shape == (): + return then if bool(mask) else otherwise + if isinstance(then, u.Quantity) or isinstance(otherwise, u.Quantity): + then_q = then if isinstance(then, u.Quantity) else _to_quantity(then) + else_q = otherwise if isinstance(otherwise, u.Quantity) else _to_quantity(otherwise) + aligned = else_q.to(then_q.unit) + return np.where(mask, np.asarray(then_q.value), np.asarray(aligned.value)) * then_q.unit + then_v = then.data if isinstance(then, TextValue) else then + else_v = otherwise.data if isinstance(otherwise, TextValue) else otherwise + return np.where(mask, np.asarray(then_v), np.asarray(else_v)) + + COL_FUNCTION = FunctionDef("col", "Rawdata column", placeholder='"${1:name}"') FUNCTIONS: tuple[FunctionDef, ...] = ( @@ -183,6 +209,12 @@ def _wrap360(value: object) -> u.Quantity: FunctionDef("ln", "Natural logarithm", _math(np.log)), FunctionDef("pow", "Raise x to the power y", _math(np.power), placeholder="${1:x}, ${2:y}"), FunctionDef("str", "Convert to text", _formula_str), + FunctionDef( + "where", + "Pick then if cond is true, otherwise the third argument; nest for extra branches", + _where, + placeholder="${1:cond}, ${2:then}, ${3:else}", + ), FunctionDef( "to_deg", 'Convert to degrees; e.g. "00 02 08.4" (hourangle), "+16 35 13" (deg), "00h02m08.4s"', @@ -267,4 +299,5 @@ def expression_syntax_help() -> str: - `180 * deg` - `"G"` - fills the column with a text "G" - Copy another column: `col("ra")` -- Mathematical expression: `3 * 10 ** col("logd25") * arcsec`""" +- Mathematical expression: `3 * 10 ** col("logd25") * arcsec` +- Conditional: `where(col("v") > 0, col("v"), 0)`""" From 4213d5a725295beb46fe9afca4e4c1ac582e400f Mon Sep 17 00:00:00 2001 From: kraysent Date: Wed, 19 Aug 2026 23:45:06 +0100 Subject: [PATCH 4/6] add min and max functions --- tests/test_formula_evaluate.py | 34 +++++++++++++++++++++++++++ tests/test_formula_parse.py | 1 + tests/test_formula_validate.py | 1 + uploader/app/lib/formula/namespace.py | 18 +++++++++++--- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/tests/test_formula_evaluate.py b/tests/test_formula_evaluate.py index 44db4d5..777affd 100644 --- a/tests/test_formula_evaluate.py +++ b/tests/test_formula_evaluate.py @@ -216,6 +216,40 @@ def evaluate_expr(source: str, columns: dict[str, Col]) -> object: result_val=8.0, result_unit=u.dimensionless_unscaled, ), + EvalCase( + name="max", + expression="max(2, 3)", + columns={}, + result_val=3.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="min", + expression="min(2, 3)", + columns={}, + result_val=2.0, + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="max_equivalent_units", + expression="max(10 * deg, 1 * rad)", + columns={}, + result_val=57.2958, + result_unit=u.deg, + ), + EvalCase( + name="vector_max", + expression='max(col("vec_col"), 2)', + columns=_COLUMNS, + result_val=[2.0, 2.0, 3.0], + result_unit=u.dimensionless_unscaled, + ), + EvalCase( + name="error_max_incompatible_units", + expression="max(1 * deg, 1)", + columns={}, + error=True, + ), EvalCase( name="vector_sqrt", expression='sqrt(col("vec_col"))', diff --git a/tests/test_formula_parse.py b/tests/test_formula_parse.py index a78aa56..1d51249 100644 --- a/tests/test_formula_parse.py +++ b/tests/test_formula_parse.py @@ -13,6 +13,7 @@ ("1 + 2", set()), ("pi + deg", set()), ("M_sun * G / c ** 2", set()), + ("max(1, min(2, 3))", set()), ("", ExpressionSyntaxError), ("1 +", ExpressionSyntaxError), ("col(", ExpressionSyntaxError), diff --git a/tests/test_formula_validate.py b/tests/test_formula_validate.py index 92d786d..457f2df 100644 --- a/tests/test_formula_validate.py +++ b/tests/test_formula_validate.py @@ -14,6 +14,7 @@ def test_validate_expression_accepts_valid() -> None: assert validate_expression("M_sun / L_sun * sigma") == [] assert validate_expression('where(col("v") > 0, col("v"), 0)') == [] assert validate_expression('where(col("a") == "M", 1, where(col("a") != "N", 2, 3))') == [] + assert validate_expression("max(1, min(2, 3))") == [] assert validate_expression("") == [] diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index e60faf8..45dbff6 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -165,6 +165,16 @@ def _wrap360(value: object) -> u.Quantity: return _as_angle(value, u.deg).to(u.deg) % (360 * u.deg) +def _extremum(fn: Callable[..., u.Quantity]) -> Callable[[object, object], u.Quantity]: + def impl(left: object, right: object) -> u.Quantity: + left_q = left if isinstance(left, u.Quantity) else _to_quantity(left) + right_q = right if isinstance(right, u.Quantity) else _to_quantity(right) + aligned = right_q.to(left_q.unit) + return fn(left_q, aligned) + + return impl + + def _mask(cond: object) -> np.ndarray: if isinstance(cond, u.Quantity): return np.asarray(cond.value) @@ -208,6 +218,8 @@ def _where(cond: object, then: object, otherwise: object) -> object: FunctionDef("log10", "Base-10 logarithm", _math(np.log10)), FunctionDef("ln", "Natural logarithm", _math(np.log)), FunctionDef("pow", "Raise x to the power y", _math(np.power), placeholder="${1:x}, ${2:y}"), + FunctionDef("max", "Larger of two values", _extremum(np.maximum), placeholder="${1:x}, ${2:y}"), + FunctionDef("min", "Smaller of two values", _extremum(np.minimum), placeholder="${1:x}, ${2:y}"), FunctionDef("str", "Convert to text", _formula_str), FunctionDef( "where", @@ -275,9 +287,9 @@ def build_namespace(columns: Mapping[str, Value]) -> dict[str, object]: def expression_syntax_help() -> str: - constants = ", ".join(f"`{c.name}` ({c.detail})" for c in NAMED_CONSTANTS) - functions = ", ".join(f"`{fn.signature}` ({fn.detail})" for fn in (COL_FUNCTION, *FUNCTIONS)) - operators = ", ".join(f"`{op.name}` ({op.detail})" for op in OPERATORS) + constants = ", ".join(f"`{c.name}`" for c in NAMED_CONSTANTS) + functions = ", ".join(f"`{fn.signature}`" for fn in (COL_FUNCTION, *FUNCTIONS)) + operators = ", ".join(f"`{op.name}`" for op in OPERATORS) return f"""\ ## Expression syntax From 4ee6ca7babd127e1706b2f752a6040ae2f117b6a Mon Sep 17 00:00:00 2001 From: kraysent Date: Wed, 19 Aug 2026 23:48:46 +0100 Subject: [PATCH 5/6] move constants and functions to separate files --- uploader/app/lib/formula/constants.py | 45 +++++ uploader/app/lib/formula/functions.py | 178 +++++++++++++++++++ uploader/app/lib/formula/namespace.py | 246 +------------------------- uploader/app/lib/formula/operators.py | 25 +++ uploader/app/lib/formula/validate.py | 3 +- 5 files changed, 256 insertions(+), 241 deletions(-) create mode 100644 uploader/app/lib/formula/constants.py create mode 100644 uploader/app/lib/formula/functions.py create mode 100644 uploader/app/lib/formula/operators.py diff --git a/uploader/app/lib/formula/constants.py b/uploader/app/lib/formula/constants.py new file mode 100644 index 0000000..ec5b65a --- /dev/null +++ b/uploader/app/lib/formula/constants.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import final + +import astropy.constants as const +import astropy.units as u +import numpy as np + + +@final +@dataclass(frozen=True) +class ConstantDef: + name: str + value: u.Quantity + detail: str + + @property + def insert(self) -> str: + return self.name + + +NAMED_CONSTANTS: tuple[ConstantDef, ...] = ( + ConstantDef("pi", np.pi * u.dimensionless_unscaled, "Pi"), + ConstantDef("c", const.c, "Speed of light"), + ConstantDef("G", const.G, "Gravitational constant"), + ConstantDef("h", const.h, "Planck constant"), + ConstantDef("k_B", const.k_B, "Boltzmann constant"), + ConstantDef("sigma", const.sigma_sb, "Stefan-Boltzmann constant"), + ConstantDef("m_e", const.m_e, "Electron mass"), + ConstantDef("m_p", const.m_p, "Proton mass"), + ConstantDef("au", 1 * u.au, "Astronomical unit"), + ConstantDef("pc", 1 * u.pc, "Parsec"), + ConstantDef("ly", 1 * u.lyr, "Light year"), + ConstantDef("eV", 1 * u.eV, "Electronvolt"), + ConstantDef("Jy", 1 * u.Jy, "Jansky"), + ConstantDef("M_sun", const.M_sun, "Solar mass"), + ConstantDef("R_sun", const.R_sun, "Nominal solar radius"), + ConstantDef("L_sun", const.L_sun, "Nominal solar luminosity"), + ConstantDef("M_earth", const.M_earth, "Earth mass"), + ConstantDef("M_jup", const.M_jup, "Jupiter mass"), + ConstantDef("deg", 1 * u.deg, "Degree"), + ConstantDef("rad", 1 * u.rad, "Radian"), + ConstantDef("arcmin", 1 * u.arcmin, "Arcminute"), + ConstantDef("arcsec", 1 * u.arcsec, "Arcsecond"), + ConstantDef("mag", 1 * u.mag, "Magnitude"), +) diff --git a/uploader/app/lib/formula/functions.py b/uploader/app/lib/formula/functions.py new file mode 100644 index 0000000..481bb2e --- /dev/null +++ b/uploader/app/lib/formula/functions.py @@ -0,0 +1,178 @@ +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import final + +import astropy.units as u +import numpy as np +from astropy.coordinates import Angle + +from uploader.app.lib.formula.values import TextValue, Value + + +@final +@dataclass(frozen=True) +class FunctionDef: + name: str + detail: str + impl: object | None = None + placeholder: str = "${1:x}" + + @property + def insert(self) -> str: + return f"{self.name}({self.placeholder})" + + @property + def signature(self) -> str: + args = re.sub(r"\$\{\d+:([^}]+)\}", r"\1", self.placeholder) + return f"{self.name}({args})" + + +def _scalar_to_str(value: float | int | np.number) -> str: + numeric = float(value) + if numeric.is_integer(): + return str(int(numeric)) + return str(numeric) + + +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): + scalar = value.value + if isinstance(scalar, np.ndarray): + return np.asarray([_scalar_to_str(x) for x in scalar]) + return _scalar_to_str(scalar) + 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__}") + + +def _unit(name: object) -> u.Quantity: + if not isinstance(name, str): + raise TypeError(f"unit() expected a unit name string, got {type(name).__name__}") + return 1 * u.Unit(name) + + +def _to_quantity(value: object) -> u.Quantity: + if isinstance(value, u.Quantity): + return value + return np.asarray(value, dtype=float) * u.dimensionless_unscaled + + +def _math(fn: Callable[..., u.Quantity]) -> Callable[..., u.Quantity]: + def impl(*args: object) -> u.Quantity: + return fn(*(_to_quantity(arg) for arg in args)) + + return impl + + +def _as_angle(value: object, default_unit: u.Unit) -> u.Quantity: + if isinstance(value, u.Quantity): + if value.unit.is_equivalent(u.rad): + return value + if value.unit.is_equivalent(u.dimensionless_unscaled): + return value.to_value(u.dimensionless_unscaled) * default_unit + raise TypeError(f"expected an angle or dimensionless value, got unit {value.unit}") + return np.asarray(value, dtype=float) * default_unit + + +def _deg2rad(value: object) -> u.Quantity: + return _as_angle(value, u.deg).to(u.rad) + + +def _rad2deg(value: object) -> u.Quantity: + return _as_angle(value, u.rad).to(u.deg) + + +def _wrap360(value: object) -> u.Quantity: + return _as_angle(value, u.deg).to(u.deg) % (360 * u.deg) + + +def _extremum(fn: Callable[..., u.Quantity]) -> Callable[[object, object], u.Quantity]: + def impl(left: object, right: object) -> u.Quantity: + left_q = left if isinstance(left, u.Quantity) else _to_quantity(left) + right_q = right if isinstance(right, u.Quantity) else _to_quantity(right) + aligned = right_q.to(left_q.unit) + return fn(left_q, aligned) + + return impl + + +def _mask(cond: object) -> np.ndarray: + if isinstance(cond, u.Quantity): + return np.asarray(cond.value) + return np.asarray(cond) + + +def _where(cond: object, then: object, otherwise: object) -> object: + mask = _mask(cond) + if mask.shape == (): + return then if bool(mask) else otherwise + if isinstance(then, u.Quantity) or isinstance(otherwise, u.Quantity): + then_q = then if isinstance(then, u.Quantity) else _to_quantity(then) + else_q = otherwise if isinstance(otherwise, u.Quantity) else _to_quantity(otherwise) + aligned = else_q.to(then_q.unit) + return np.where(mask, np.asarray(then_q.value), np.asarray(aligned.value)) * then_q.unit + then_v = then.data if isinstance(then, TextValue) else then + else_v = otherwise.data if isinstance(otherwise, TextValue) else otherwise + return np.where(mask, np.asarray(then_v), np.asarray(else_v)) + + +COL_FUNCTION = FunctionDef("col", "Rawdata column", placeholder='"${1:name}"') + +FUNCTIONS: tuple[FunctionDef, ...] = ( + FunctionDef("sin", "Sine (argument must be an angle)", np.sin), + FunctionDef("cos", "Cosine (argument must be an angle)", np.cos), + FunctionDef("tan", "Tangent (argument must be an angle)", np.tan), + FunctionDef("asin", "Arcsine (returns radians)", _math(np.arcsin)), + FunctionDef("acos", "Arccosine (returns radians)", _math(np.arccos)), + FunctionDef("atan", "Arctangent (returns radians)", _math(np.arctan)), + FunctionDef( + "atan2", + "Two-argument arctangent (returns radians)", + _math(np.arctan2), + placeholder="${1:y}, ${2:x}", + ), + FunctionDef("deg2rad", "Convert degrees to radians", _deg2rad, placeholder="${1:deg}"), + FunctionDef("rad2deg", "Convert radians to degrees", _rad2deg, placeholder="${1:rad}"), + FunctionDef("wrap360", "Wrap angle to [0, 360) degrees", _wrap360, placeholder="${1:deg}"), + FunctionDef("sqrt", "Square root", _math(np.sqrt)), + FunctionDef("exp", "Exponential", _math(np.exp)), + FunctionDef("log10", "Base-10 logarithm", _math(np.log10)), + FunctionDef("ln", "Natural logarithm", _math(np.log)), + FunctionDef("pow", "Raise x to the power y", _math(np.power), placeholder="${1:x}, ${2:y}"), + FunctionDef("max", "Larger of two values", _extremum(np.maximum), placeholder="${1:x}, ${2:y}"), + FunctionDef("min", "Smaller of two values", _extremum(np.minimum), placeholder="${1:x}, ${2:y}"), + FunctionDef("str", "Convert to text", _formula_str), + FunctionDef( + "where", + "Pick then if cond is true, otherwise the third argument; nest for extra branches", + _where, + placeholder="${1:cond}, ${2:then}, ${3:else}", + ), + FunctionDef( + "to_deg", + 'Convert to degrees; e.g. "00 02 08.4" (hourangle), "+16 35 13" (deg), "00h02m08.4s"', + _to_deg, + ), + FunctionDef( + "unit", + 'Astropy unit from a name string; e.g. "Mpc", "km/s", "Jy"', + _unit, + placeholder='"${1:name}"', + ), +) diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index 45dbff6..10483b5 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -1,244 +1,10 @@ -import re -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from typing import Any, Literal, TypedDict, final +from collections.abc import Mapping +from typing import Any, Literal, TypedDict -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 TextValue, Value - - -@final -@dataclass(frozen=True) -class FunctionDef: - name: str - detail: str - impl: object | None = None - placeholder: str = "${1:x}" - - @property - def insert(self) -> str: - return f"{self.name}({self.placeholder})" - - @property - def signature(self) -> str: - args = re.sub(r"\$\{\d+:([^}]+)\}", r"\1", self.placeholder) - return f"{self.name}({args})" - - -@final -@dataclass(frozen=True) -class ConstantDef: - name: str - value: u.Quantity - detail: str - - @property - def insert(self) -> str: - return self.name - - -@final -@dataclass(frozen=True) -class OperatorDef: - name: str - detail: str - - -NAMED_CONSTANTS: tuple[ConstantDef, ...] = ( - ConstantDef("pi", np.pi * u.dimensionless_unscaled, "Pi"), - ConstantDef("c", const.c, "Speed of light"), - ConstantDef("G", const.G, "Gravitational constant"), - ConstantDef("h", const.h, "Planck constant"), - ConstantDef("k_B", const.k_B, "Boltzmann constant"), - ConstantDef("sigma", const.sigma_sb, "Stefan-Boltzmann constant"), - ConstantDef("m_e", const.m_e, "Electron mass"), - ConstantDef("m_p", const.m_p, "Proton mass"), - ConstantDef("au", 1 * u.au, "Astronomical unit"), - ConstantDef("pc", 1 * u.pc, "Parsec"), - ConstantDef("ly", 1 * u.lyr, "Light year"), - ConstantDef("eV", 1 * u.eV, "Electronvolt"), - ConstantDef("Jy", 1 * u.Jy, "Jansky"), - ConstantDef("M_sun", const.M_sun, "Solar mass"), - ConstantDef("R_sun", const.R_sun, "Nominal solar radius"), - ConstantDef("L_sun", const.L_sun, "Nominal solar luminosity"), - ConstantDef("M_earth", const.M_earth, "Earth mass"), - ConstantDef("M_jup", const.M_jup, "Jupiter mass"), - ConstantDef("deg", 1 * u.deg, "Degree"), - ConstantDef("rad", 1 * u.rad, "Radian"), - ConstantDef("arcmin", 1 * u.arcmin, "Arcminute"), - ConstantDef("arcsec", 1 * u.arcsec, "Arcsecond"), - ConstantDef("mag", 1 * u.mag, "Magnitude"), -) - - -OPERATORS: tuple[OperatorDef, ...] = ( - OperatorDef("+", "Addition; also concatenates strings"), - OperatorDef("-", "Subtraction"), - OperatorDef("*", "Multiplication"), - OperatorDef("/", "Division"), - OperatorDef("**", "Exponentiation"), - OperatorDef("%", 'Modulo; divisor must carry units (e.g. col("pa") % (180 * deg))'), - OperatorDef("==", "Equal"), - OperatorDef("!=", "Not equal"), - OperatorDef("<", "Less than"), - OperatorDef("<=", "Less than or equal"), - OperatorDef(">", "Greater than"), - OperatorDef(">=", "Greater than or equal"), -) - - -def _scalar_to_str(value: float | int | np.number) -> str: - numeric = float(value) - if numeric.is_integer(): - return str(int(numeric)) - return str(numeric) - - -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): - scalar = value.value - if isinstance(scalar, np.ndarray): - return np.asarray([_scalar_to_str(x) for x in scalar]) - return _scalar_to_str(scalar) - 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__}") - - -def _unit(name: object) -> u.Quantity: - if not isinstance(name, str): - raise TypeError(f"unit() expected a unit name string, got {type(name).__name__}") - return 1 * u.Unit(name) - - -def _to_quantity(value: object) -> u.Quantity: - if isinstance(value, u.Quantity): - return value - return np.asarray(value, dtype=float) * u.dimensionless_unscaled - - -def _math(fn: Callable[..., u.Quantity]) -> Callable[..., u.Quantity]: - def impl(*args: object) -> u.Quantity: - return fn(*(_to_quantity(arg) for arg in args)) - - return impl - - -def _as_angle(value: object, default_unit: u.Unit) -> u.Quantity: - if isinstance(value, u.Quantity): - if value.unit.is_equivalent(u.rad): - return value - if value.unit.is_equivalent(u.dimensionless_unscaled): - return value.to_value(u.dimensionless_unscaled) * default_unit - raise TypeError(f"expected an angle or dimensionless value, got unit {value.unit}") - return np.asarray(value, dtype=float) * default_unit - - -def _deg2rad(value: object) -> u.Quantity: - return _as_angle(value, u.deg).to(u.rad) - - -def _rad2deg(value: object) -> u.Quantity: - return _as_angle(value, u.rad).to(u.deg) - - -def _wrap360(value: object) -> u.Quantity: - return _as_angle(value, u.deg).to(u.deg) % (360 * u.deg) - - -def _extremum(fn: Callable[..., u.Quantity]) -> Callable[[object, object], u.Quantity]: - def impl(left: object, right: object) -> u.Quantity: - left_q = left if isinstance(left, u.Quantity) else _to_quantity(left) - right_q = right if isinstance(right, u.Quantity) else _to_quantity(right) - aligned = right_q.to(left_q.unit) - return fn(left_q, aligned) - - return impl - - -def _mask(cond: object) -> np.ndarray: - if isinstance(cond, u.Quantity): - return np.asarray(cond.value) - return np.asarray(cond) - - -def _where(cond: object, then: object, otherwise: object) -> object: - mask = _mask(cond) - if mask.shape == (): - return then if bool(mask) else otherwise - if isinstance(then, u.Quantity) or isinstance(otherwise, u.Quantity): - then_q = then if isinstance(then, u.Quantity) else _to_quantity(then) - else_q = otherwise if isinstance(otherwise, u.Quantity) else _to_quantity(otherwise) - aligned = else_q.to(then_q.unit) - return np.where(mask, np.asarray(then_q.value), np.asarray(aligned.value)) * then_q.unit - then_v = then.data if isinstance(then, TextValue) else then - else_v = otherwise.data if isinstance(otherwise, TextValue) else otherwise - return np.where(mask, np.asarray(then_v), np.asarray(else_v)) - - -COL_FUNCTION = FunctionDef("col", "Rawdata column", placeholder='"${1:name}"') - -FUNCTIONS: tuple[FunctionDef, ...] = ( - FunctionDef("sin", "Sine (argument must be an angle)", np.sin), - FunctionDef("cos", "Cosine (argument must be an angle)", np.cos), - FunctionDef("tan", "Tangent (argument must be an angle)", np.tan), - FunctionDef("asin", "Arcsine (returns radians)", _math(np.arcsin)), - FunctionDef("acos", "Arccosine (returns radians)", _math(np.arccos)), - FunctionDef("atan", "Arctangent (returns radians)", _math(np.arctan)), - FunctionDef( - "atan2", - "Two-argument arctangent (returns radians)", - _math(np.arctan2), - placeholder="${1:y}, ${2:x}", - ), - FunctionDef("deg2rad", "Convert degrees to radians", _deg2rad, placeholder="${1:deg}"), - FunctionDef("rad2deg", "Convert radians to degrees", _rad2deg, placeholder="${1:rad}"), - FunctionDef("wrap360", "Wrap angle to [0, 360) degrees", _wrap360, placeholder="${1:deg}"), - FunctionDef("sqrt", "Square root", _math(np.sqrt)), - FunctionDef("exp", "Exponential", _math(np.exp)), - FunctionDef("log10", "Base-10 logarithm", _math(np.log10)), - FunctionDef("ln", "Natural logarithm", _math(np.log)), - FunctionDef("pow", "Raise x to the power y", _math(np.power), placeholder="${1:x}, ${2:y}"), - FunctionDef("max", "Larger of two values", _extremum(np.maximum), placeholder="${1:x}, ${2:y}"), - FunctionDef("min", "Smaller of two values", _extremum(np.minimum), placeholder="${1:x}, ${2:y}"), - FunctionDef("str", "Convert to text", _formula_str), - FunctionDef( - "where", - "Pick then if cond is true, otherwise the third argument; nest for extra branches", - _where, - placeholder="${1:cond}, ${2:then}, ${3:else}", - ), - FunctionDef( - "to_deg", - 'Convert to degrees; e.g. "00 02 08.4" (hourangle), "+16 35 13" (deg), "00h02m08.4s"', - _to_deg, - ), - FunctionDef( - "unit", - 'Astropy unit from a name string; e.g. "Mpc", "km/s", "Jy"', - _unit, - placeholder='"${1:name}"', - ), -) +from uploader.app.lib.formula.constants import NAMED_CONSTANTS +from uploader.app.lib.formula.functions import COL_FUNCTION, FUNCTIONS +from uploader.app.lib.formula.operators import OPERATORS +from uploader.app.lib.formula.values import Value class ExpressionToken(TypedDict): diff --git a/uploader/app/lib/formula/operators.py b/uploader/app/lib/formula/operators.py new file mode 100644 index 0000000..26e088e --- /dev/null +++ b/uploader/app/lib/formula/operators.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import final + + +@final +@dataclass(frozen=True) +class OperatorDef: + name: str + detail: str + + +OPERATORS: tuple[OperatorDef, ...] = ( + OperatorDef("+", "Addition; also concatenates strings"), + OperatorDef("-", "Subtraction"), + OperatorDef("*", "Multiplication"), + OperatorDef("/", "Division"), + OperatorDef("**", "Exponentiation"), + OperatorDef("%", 'Modulo; divisor must carry units (e.g. col("pa") % (180 * deg))'), + OperatorDef("==", "Equal"), + OperatorDef("!=", "Not equal"), + OperatorDef("<", "Less than"), + OperatorDef("<=", "Less than or equal"), + OperatorDef(">", "Greater than"), + OperatorDef(">=", "Greater than or equal"), +) diff --git a/uploader/app/lib/formula/validate.py b/uploader/app/lib/formula/validate.py index f15df99..a69bf01 100644 --- a/uploader/app/lib/formula/validate.py +++ b/uploader/app/lib/formula/validate.py @@ -5,7 +5,8 @@ from pydantic import AfterValidator, ValidationError from pydantic_core import PydanticCustomError -from uploader.app.lib.formula.namespace import COL_FUNCTION, FUNCTIONS, NAMED_CONSTANTS +from uploader.app.lib.formula.constants import NAMED_CONSTANTS +from uploader.app.lib.formula.functions import COL_FUNCTION, FUNCTIONS @final From bbe215c7318c0fe488f19574c0d8b05e0a77fd00 Mon Sep 17 00:00:00 2001 From: kraysent Date: Wed, 19 Aug 2026 23:49:29 +0100 Subject: [PATCH 6/6] update template --- .agents/skills/code-review/SKILL.md | 4 ++++ .template.yaml | 2 +- AGENTS.md | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 1069b4b..92c8a6d 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -119,3 +119,7 @@ An HTTP client fetches a URL taken from user/external input with no allowlist or ### command-injection subprocess/os.system/os.popen runs a shell with concatenated or formatted user/external input (shell=True, or a single string command). Only flag when the injected value is not a constant/whitelist. + +### pointless-wrapper + +A new or changed function or method only forwards to another callable with the same arguments and return value, adding no conversion, validation, defaulting, error handling, or other logic. Only flag when call sites could invoke the inner callable directly, the wrapper does not implement an interface, protocol, or abstract method, and it is not a public re-export of a private or third-party symbol. diff --git a/.template.yaml b/.template.yaml index b945204..366f1b7 100644 --- a/.template.yaml +++ b/.template.yaml @@ -1,6 +1,6 @@ # Autogenerated - DO NOT EDIT # Parameters of the project as generated from template -_commit: 2555e1a +_commit: fcdbd3c _src_path: gh:kraysent/python-template package_name: uploader project_name: uploader diff --git a/AGENTS.md b/AGENTS.md index 29f8f46..22da2d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,3 +79,7 @@ The only exception to the above rule is for imports from the `typing` or `collec from typing import Any from collections.abc import Sequence ``` + +### File changes + +When moving files as part of a refactor of any kind prefer using `git mv` whenver possible instead of removing + adding.