Skip to content

Commit 39bb762

Browse files
author
miranov25
committed
Add bidirectional atan2/arctan2 support for ROOT compatibility
Fixes: ROOT aliases using 'atan2' now work in Python evaluation - Both 'atan2' and 'arctan2' map to np.arctan2 for pandas Series - Export converts 'arctan2' → 'atan2' for ROOT (already working) - Complete Python ↔ ROOT roundtrip now seamless Improvements: - Better error messages showing available functions and hints - Change np.array() → np.asarray() for robustness - Add tests: bidirectional atan2 + error message validation Tests: 33/33 passing (added 2 new tests) Resolves "TypeError: cannot convert series to float" when materializing ROOT-imported geometric aliases.
1 parent 687c548 commit 39bb762

File tree

2 files changed

+89
-15
lines changed

2 files changed

+89
-15
lines changed

UTILS/dfextensions/AliasDataFrame.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -81,21 +81,20 @@ def get_func_name(n):
8181
# Add BEFORE class AliasDataFrame:
8282

8383
class NumpyRootMapper:
84-
"""Maps NumPy function names to ROOT C++ equivalents"""
84+
"""Maps NumPy function names to ROOT C++ equivalents (bidirectional)"""
8585

8686
# Maps function names to (numpy_attr, root_name)
87-
# Some functions are aliases (asinh → arcsinh in numpy)
8887
MAPPING = {
89-
# Hyperbolic functions (needed for compression)
88+
# Hyperbolic functions
9089
'sinh': ('sinh', 'sinh'),
9190
'cosh': ('cosh', 'cosh'),
9291
'tanh': ('tanh', 'tanh'),
9392
'arcsinh': ('arcsinh', 'asinh'),
9493
'arccosh': ('arccosh', 'acosh'),
9594
'arctanh': ('arctanh', 'atanh'),
96-
'asinh': ('arcsinh', 'asinh'), # Alias: np.arcsinh
97-
'acosh': ('arccosh', 'acosh'), # Alias: np.arccosh
98-
'atanh': ('arctanh', 'atanh'), # Alias: np.arctanh
95+
'asinh': ('arcsinh', 'asinh'),
96+
'acosh': ('arccosh', 'acosh'),
97+
'atanh': ('arctanh', 'atanh'),
9998

10099
# Trigonometric
101100
'sin': ('sin', 'sin'),
@@ -105,9 +104,10 @@ class NumpyRootMapper:
105104
'arccos': ('arccos', 'acos'),
106105
'arctan': ('arctan', 'atan'),
107106
'arctan2': ('arctan2', 'atan2'),
108-
'asin': ('arcsin', 'asin'), # Alias: np.arcsin
109-
'acos': ('arccos', 'acos'), # Alias: np.arccos
110-
'atan': ('arctan', 'atan'), # Alias: np.arctan
107+
'asin': ('arcsin', 'asin'),
108+
'acos': ('arccos', 'acos'),
109+
'atan': ('arctan', 'atan'),
110+
'atan2': ('arctan2', 'atan2'), # ← NEW: ROOT name maps to numpy
111111

112112
# Exponential/log
113113
'exp': ('exp', 'exp'),
@@ -126,7 +126,11 @@ class NumpyRootMapper:
126126

127127
@classmethod
128128
def get_numpy_functions_for_eval(cls):
129-
"""Get dict of function_name → numpy_function for evaluation"""
129+
"""Get dict of function_name → numpy_function for evaluation
130+
131+
Includes both Python names (arctan2) and ROOT names (atan2)
132+
for bidirectional compatibility when reading ROOT files.
133+
"""
130134
funcs = {}
131135
for name, (np_attr, _) in cls.MAPPING.items():
132136
if hasattr(np, np_attr):
@@ -176,18 +180,21 @@ def get_subframe(self, name):
176180

177181
def _default_functions(self):
178182
import math
183+
184+
# Start with math functions (scalar fallbacks)
179185
env = {k: getattr(math, k) for k in dir(math) if not k.startswith("_")}
180186

181-
# Add numpy functions (will override math versions with vectorized numpy)
187+
# CRITICAL: Override with numpy vectorized versions
188+
# This ensures both arctan2 AND atan2 map to np.arctan2
182189
env.update(NumpyRootMapper.get_numpy_functions_for_eval())
183190

184191
env["np"] = np
185192
for sf_name, sf_entry in self._subframes.items():
186193
env[sf_name] = sf_entry['frame']
187194

188-
env["int"] = lambda x: np.array(x).astype(np.int32)
189-
env["uint"] = lambda x: np.array(x).astype(np.uint32)
190-
env["float"] = lambda x: np.array(x).astype(np.float32)
195+
env["int"] = lambda x: np.asarray(x, dtype=np.int32)
196+
env["uint"] = lambda x: np.asarray(x, dtype=np.uint32)
197+
env["float"] = lambda x: np.asarray(x, dtype=np.float32)
191198
env["round"] = np.round
192199
env["clip"] = np.clip
193200

@@ -250,7 +257,27 @@ def _eval_in_namespace(self, expr):
250257
expr = self._prepare_subframe_joins(expr)
251258
local_env = {col: self.df[col] for col in self.df.columns}
252259
local_env.update(self._default_functions())
253-
return eval(expr, {}, local_env)
260+
261+
try:
262+
return eval(expr, {}, local_env)
263+
except NameError as e:
264+
# Function or variable not found
265+
missing_name = str(e).split("'")[1] if "'" in str(e) else "unknown"
266+
available_funcs = sorted([k for k in local_env.keys() if callable(local_env.get(k))])[:20]
267+
raise NameError(
268+
f"Undefined function or variable '{missing_name}' in expression: {expr}\n"
269+
f"Available functions include: {', '.join(available_funcs)}\n"
270+
f"Hint: Common functions are available, including both 'arctan2' and 'atan2'"
271+
) from e
272+
except TypeError as e:
273+
if "cannot convert the series" in str(e):
274+
raise TypeError(
275+
f"Scalar function used on array data in expression: {expr}\n"
276+
f"Error: {e}\n"
277+
f"Hint: All math functions should be vectorized (numpy-based). "
278+
f"If you see this with standard functions like 'atan2', please report as a bug."
279+
) from e
280+
raise
254281

255282
def _resolve_dependencies(self):
256283
from collections import defaultdict

UTILS/dfextensions/AliasDataFrameTest.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,53 @@ def test_getattr_column_and_alias_access(self):
109109
expected = df["x"] + df["y"]
110110
np.testing.assert_array_equal(z_val, expected)
111111

112+
def test_bidirectional_atan2_support(self):
113+
"""Test that both atan2 (ROOT) and arctan2 (Python) work"""
114+
df = pd.DataFrame({
115+
'x': np.array([1.0, 0.0, -1.0, 0.0]),
116+
'y': np.array([0.0, 1.0, 0.0, -1.0])
117+
})
118+
adf = AliasDataFrame(df)
119+
120+
# Python style (arctan2)
121+
adf.add_alias('phi_python', 'arctan2(y, x)', dtype=np.float32)
122+
adf.materialize_alias('phi_python')
123+
124+
# ROOT style (atan2) - should also work
125+
adf.add_alias('phi_root', 'atan2(y, x)', dtype=np.float32)
126+
adf.materialize_alias('phi_root')
127+
128+
# Should be identical
129+
np.testing.assert_allclose(adf.df['phi_python'], adf.df['phi_root'], rtol=1e-6)
130+
131+
# Expected values
132+
expected = np.array([0.0, np.pi/2, np.pi, -np.pi/2], dtype=np.float32)
133+
np.testing.assert_allclose(adf.df['phi_python'], expected, rtol=1e-6)
134+
135+
def test_undefined_function_helpful_error(self):
136+
"""Test that undefined functions give helpful error messages"""
137+
df = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})
138+
adf = AliasDataFrame(df)
139+
140+
# Test 1: Undefined function
141+
adf.add_alias('bad', 'nonexistent_func(x)', dtype=np.float32)
142+
with self.assertRaises(NameError) as cm:
143+
adf.materialize_alias('bad')
144+
145+
error_msg = str(cm.exception)
146+
# Check error message contains helpful info
147+
self.assertIn('nonexistent_func', error_msg)
148+
self.assertIn('Available functions include:', error_msg)
149+
self.assertIn('arctan2', error_msg) # Should mention both forms
150+
self.assertIn('atan2', error_msg)
151+
152+
# Test 2: Undefined variable
153+
adf.add_alias('bad2', 'x + undefined_var', dtype=np.float32)
154+
with self.assertRaises(NameError) as cm:
155+
adf.materialize_alias('bad2')
156+
157+
error_msg = str(cm.exception)
158+
self.assertIn('undefined_var', error_msg)
112159

113160
class TestAliasDataFrameWithSubframes(unittest.TestCase):
114161
def setUp(self):

0 commit comments

Comments
 (0)