From 6b65a80dd0d19affce77d561db42d76b58a63170 Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Wed, 5 Nov 2025 17:47:39 -0500 Subject: [PATCH 01/11] WIP --- SYMBOLS_MANIFEST.txt | 1 + mathics/builtin/numericarray.py | 67 ++++++++++++++++++ mathics/core/atoms.py | 112 ++++++++++++++++++++++++++++++ mathics/core/convert/python.py | 9 ++- mathics/core/systemsymbols.py | 1 + test/builtin/test_numericarray.py | 68 ++++++++++++++++++ 6 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 mathics/builtin/numericarray.py create mode 100644 test/builtin/test_numericarray.py diff --git a/SYMBOLS_MANIFEST.txt b/SYMBOLS_MANIFEST.txt index 6a4797982..861113891 100644 --- a/SYMBOLS_MANIFEST.txt +++ b/SYMBOLS_MANIFEST.txt @@ -845,6 +845,7 @@ System`NumberQ System`NumberString System`Numerator System`NumericFunction +System`NumericArray System`NumericQ System`O System`Octahedron diff --git a/mathics/builtin/numericarray.py b/mathics/builtin/numericarray.py new file mode 100644 index 000000000..bed007778 --- /dev/null +++ b/mathics/builtin/numericarray.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""NumericArray backed by NumPy arrays.""" + +from typing import Optional, Tuple + +try: # pragma: no cover - numpy is optional at runtime + import numpy +except ImportError: # pragma: no cover - handled via requires attribute + numpy = None + +from mathics.core.atoms import ( + NumericArray as NumericArrayAtom, + NUMERIC_ARRAY_TYPE_MAP, + String, + numeric_array_summary_string, +) +from mathics.core.builtin import Builtin +from mathics.core.convert.python import from_python +from mathics.core.symbols import Symbol, strip_context +from mathics.core.systemsymbols import SymbolAutomatic, SymbolFailed, SymbolNumericArray + + +class NumericArray_(Builtin): + + summary_text = "head for NumericArray" + messages = { + "type": "The type specification `1` is not supported in NumericArray.", + } + + # rule to convert NumericArray[...nested list...] expression to NumericArray atom + def eval_list(self, data_List, typespec, evaluation): + "NumericArray[data_, typespec_]" + + # get a string key from the typespec + if isinstance(typespec, Symbol): + key = strip_context(type_spec.get_name()) + elif isinstance(type_spec, String): + key = typespec.value + else: + evaluation.message("NumericArray", "type", typespec) + return SymbolFailed + + # compute dtype from key + if key == "Automatic": + dtype = None + else: + dtype = NUMERIC_ARRAY_TYPE_MAP.get(key, None) + if not dtype: + evaluation.message("NumericArray", "type", typespec) + return SymbolFailed + + # compute array from data and dtype and wrap it in a NumericArray atom + python_value = data.to_python() + array = numpy.array(python_value, dtype=dtype) + atom = NumericArray(array, dtype) + + return atom + + def eval_normal(self, array, evaluation): + "System`Normal[array_NumericArray]" + return from_python(array.value.tolist()) + + # TODO: is this needed or does it happen by default? + def eval_to_string(self, array, evaluation): + "ToString[array_NumericArray]" + return String(str(array)) + diff --git a/mathics/core/atoms.py b/mathics/core/atoms.py index 17e5062ce..16e392f91 100644 --- a/mathics/core/atoms.py +++ b/mathics/core/atoms.py @@ -10,6 +10,11 @@ import sympy from sympy.core import numbers as sympy_numbers +try: # pragma: no cover - optional dependency handled at runtime + import numpy +except ImportError: # pragma: no cover - numpy is optional at import time + numpy = None + from mathics.core.element import BoxElementMixin, ImmutableValueMixin from mathics.core.keycomparable import ( BASIC_ATOM_BYTEARRAY_ELT_ORDER, @@ -1098,6 +1103,113 @@ def is_zero(self) -> bool: } +# +# NumericArray +# + +if numpy is not None: + NUMERIC_ARRAY_TYPE_MAP = { + "UnsignedInteger8": numpy.dtype("uint8"), + "UnsignedInteger16": numpy.dtype("uint16"), + "UnsignedInteger32": numpy.dtype("uint32"), + "UnsignedInteger64": numpy.dtype("uint64"), + "Integer8": numpy.dtype("int8"), + "Integer16": numpy.dtype("int16"), + "Integer32": numpy.dtype("int32"), + "Integer64": numpy.dtype("int64"), + "Real32": numpy.dtype("float32"), + "Real64": numpy.dtype("float64"), + "ComplexReal32": numpy.dtype("complex64"), + "ComplexReal64": numpy.dtype("complex128"), + } + NUMERIC_ARRAY_DTYPE_TO_NAME = { + dtype: name for name, dtype in NUMERIC_ARRAY_TYPE_MAP.items() + } +else: # pragma: no cover - executed only when numpy is absent + NUMERIC_ARRAY_TYPE_MAP = {} + NUMERIC_ARRAY_DTYPE_TO_NAME = {} + + +# TODO: would it be useful to follow the example of Complex and parameterize by type? +# would that be array.dtype or the MMA type from the map above? +class NumericArray(Atom, ImmutableValueMixin): + """ + NumericArray provides compact storage and efficient access for machine-precision numeric arrays, + backed by NumPy arrays. + """ + + class_head_name = "System`NumericArray" + + def __init__(cls, value, dtype=None): + if numpy is None: + raise ImportError("numpy is required for NumericArray") + + # compute value + if not isinstance(value, numpy.ndarray): + value = numpy.asarray(value, dtype=dtype) + elif dtype is not None: + value = value.astype(dtype) + self.value = value + + # check type + self._type_name = NUMERIC_ARRAY_DTYPE_TO_NAME.get(self.value.dtype, None) + if not self._type_name: + allowed = ", ".join(str(dtype) for dtype in NUMERIC_ARRAY_TYPE_MAP.values()) + message = f"Argument 'value' must be one of {allowed}; is {str(self.value.dtype)}." + raise ValueError(message) + + # summary and hash + self._summary = (self._type_name(), self.value.shape, self.value.tobytes()) + shape_string = "×".join(str(dim) for dim in shape) or "0" + self._summary_string = f"{self._type_name}, {shape_string}" + self._hash = None + + def __hash__(self): + if not self._hash: + self._hash = hash(("NumericArray", self._summary)) + return self._hash + + def __str__(self) -> str: + return f"NumericArray[{self._summary_string}]" + + def atom_to_boxes(self, f, evaluation): + return String(f"<{self._summary_string}>") + + def do_copy(self) -> "NumericArray": + return NumericArray(self.value.copy()) + + def default_format(self, evaluation, form) -> str: + return f"NumericArray[<{self._summary_string}>]" + + @property + def element_order(self) -> tuple: + return (BASIC_ATOM_STRING_OR_BYTEARRAY_SORT_KEY, *self._summary) + + @property + def pattern_precedence(self) -> tuple: + return super().pattern_precedence + + def sameQ(self, rhs) -> bool: + return isinstance(rhs, NumericArray) and numpy.array_equal(self.value, rhs.value) + + def to_sympy(self, **kwargs): + return None + + def to_python(self, *args, **kwargs): + return self.value + + # TODO: what is this? is it right? + def user_hash(self, update): + update(self._summary[2]) + + def __getnewargs__(self): + return (self.value, self.value.dtype) + + +# +# String +# + class String(Atom, BoxElementMixin): value: str class_head_name = "System`String" diff --git a/mathics/core/convert/python.py b/mathics/core/convert/python.py index 70340f3a0..c61941edd 100644 --- a/mathics/core/convert/python.py +++ b/mathics/core/convert/python.py @@ -5,7 +5,12 @@ from typing import Any -from mathics.core.atoms import Complex, Integer, Rational, Real, String +try: # pragma: no cover - numpy is optional + import numpy +except ImportError: # pragma: no cover - optional dependency missing + numpy = None + +from mathics.core.atoms import Complex, Integer, NumericArray, Rational, Real, String from mathics.core.number import get_type from mathics.core.symbols import ( BaseElement, @@ -113,5 +118,7 @@ def from_python(arg: Any) -> BaseElement: from mathics.builtin.binary.bytearray import ByteArray return Expression(SymbolByteArray, ByteArray(arg)) + elif numpy is not None and isinstance(arg, numpy.ndarray): + return NumericArray(arg) else: raise NotImplementedError diff --git a/mathics/core/systemsymbols.py b/mathics/core/systemsymbols.py index a6b014081..3665eb71c 100644 --- a/mathics/core/systemsymbols.py +++ b/mathics/core/systemsymbols.py @@ -179,6 +179,7 @@ SymbolNumberForm = Symbol("System`NumberForm") SymbolNumberString = Symbol("System`NumberString") SymbolNumberQ = Symbol("System`NumberQ") +SymbolNumericArray = Symbol("System`NumericArray") SymbolNumericQ = Symbol("System`NumericQ") SymbolO = Symbol("System`O") SymbolOpacity = Symbol("System`Opacity") diff --git a/test/builtin/test_numericarray.py b/test/builtin/test_numericarray.py new file mode 100644 index 000000000..7009bc9a3 --- /dev/null +++ b/test/builtin/test_numericarray.py @@ -0,0 +1,68 @@ +import numpy as np + +from mathics.builtin.numericarray import NumericArray as NumericArrayBuiltin +from mathics.core.atoms import Integer, NumericArray, String +from mathics.core.convert.python import from_python +from mathics.core.expression import Expression +from mathics.core.list import ListExpression +from mathics.core.systemsymbols import SymbolAutomatic, SymbolNumericArray + + +def test_numericarray_atom_preserves_array_reference(): + arr = np.array([1, 2, 3], dtype=np.int64) + atom = NumericArray(arr) + assert atom.value is arr + np.testing.assert_array_equal(atom.value, arr) + + +def test_numericarray_expression_from_python_array(): + arr = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + expr = from_python(arr) + assert expr.head is SymbolNumericArray + atom = expr.elements[0] + np.testing.assert_array_equal(atom.value, arr) + assert expr.value is atom.value + + +class DummyEvaluation: + def message(self, *args, **kwargs): + raise AssertionError(f"Unexpected message: {args!r}") + + +def test_numericarray_builtin_default_type(): + evaluation = DummyEvaluation() + builtin = NumericArrayBuiltin(expression=False) + data = from_python([1, 2, 3]) + result = builtin.eval(data, SymbolAutomatic, evaluation) + assert result.head is SymbolNumericArray + atom = result.elements[0] + np.testing.assert_array_equal(atom.value, np.array([1, 2, 3])) + + +def test_numericarray_builtin_type_conversion(): + evaluation = DummyEvaluation() + builtin = NumericArrayBuiltin(expression=False) + data = from_python([1, 2, 3]) + type_spec = String("UnsignedInteger16") + result = builtin.eval(data, type_spec, evaluation) + atom = result.elements[0] + assert atom.value.dtype == np.uint16 + + +def test_numericarray_normal_returns_list_expression(): + evaluation = DummyEvaluation() + builtin = NumericArrayBuiltin(expression=False) + arr = np.array([1, 2], dtype=np.int64) + atom = NumericArray(arr) + normal_expr = builtin.eval_normal(atom, evaluation) + assert normal_expr.value == (1, 2) + + +def test_numericarray_from_expression_nested_lists(): + list_expr = ListExpression( + ListExpression(Integer(1), Integer(2)), + ListExpression(Integer(3), Integer(4)), + ) + expr = Expression(SymbolNumericArray, list_expr) + atom = NumericArray.from_expression(expr) + np.testing.assert_array_equal(atom.value, np.array([[1, 2], [3, 4]])) From 607a53a9796f4740dba85846cd5dc773a72410a3 Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Wed, 5 Nov 2025 22:31:59 -0500 Subject: [PATCH 02/11] Update numericarray.py --- mathics/builtin/numericarray.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mathics/builtin/numericarray.py b/mathics/builtin/numericarray.py index bed007778..f6564412a 100644 --- a/mathics/builtin/numericarray.py +++ b/mathics/builtin/numericarray.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""NumericArray backed by NumPy arrays.""" +"""Rules for working with NumericArray atoms.""" from typing import Optional, Tuple @@ -20,6 +20,7 @@ from mathics.core.systemsymbols import SymbolAutomatic, SymbolFailed, SymbolNumericArray +# name modeled on Complex_ to avoid collision with NumericArray atom class NumericArray_(Builtin): summary_text = "head for NumericArray" @@ -40,7 +41,7 @@ def eval_list(self, data_List, typespec, evaluation): evaluation.message("NumericArray", "type", typespec) return SymbolFailed - # compute dtype from key + # compute numpy dtype from key if key == "Automatic": dtype = None else: From 36ee61407031348570d0c4e992afd329390d5732 Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Thu, 6 Nov 2025 07:31:12 -0500 Subject: [PATCH 03/11] WIP --- mathics/builtin/numericarray.py | 29 +++++++++++++++-------------- mathics/core/atoms.py | 6 +++--- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/mathics/builtin/numericarray.py b/mathics/builtin/numericarray.py index f6564412a..74b8cc0fe 100644 --- a/mathics/builtin/numericarray.py +++ b/mathics/builtin/numericarray.py @@ -8,12 +8,7 @@ except ImportError: # pragma: no cover - handled via requires attribute numpy = None -from mathics.core.atoms import ( - NumericArray as NumericArrayAtom, - NUMERIC_ARRAY_TYPE_MAP, - String, - numeric_array_summary_string, -) +from mathics.core.atoms import NumericArray as NumericArrayAtom, NUMERIC_ARRAY_TYPE_MAP, String from mathics.core.builtin import Builtin from mathics.core.convert.python import from_python from mathics.core.symbols import Symbol, strip_context @@ -24,18 +19,22 @@ class NumericArray_(Builtin): summary_text = "head for NumericArray" + name = "NumericArray" + rules = { + "NumericArray[list_List]": "NumericArray[list, Automatic]" + } messages = { "type": "The type specification `1` is not supported in NumericArray.", } # rule to convert NumericArray[...nested list...] expression to NumericArray atom - def eval_list(self, data_List, typespec, evaluation): - "NumericArray[data_, typespec_]" + def eval_list(self, data, typespec, evaluation): + "System`NumericArray[data_List, typespec_]" # get a string key from the typespec if isinstance(typespec, Symbol): - key = strip_context(type_spec.get_name()) - elif isinstance(type_spec, String): + key = strip_context(typespec.get_name()) + elif isinstance(typespec, String): key = typespec.value else: evaluation.message("NumericArray", "type", typespec) @@ -53,16 +52,18 @@ def eval_list(self, data_List, typespec, evaluation): # compute array from data and dtype and wrap it in a NumericArray atom python_value = data.to_python() array = numpy.array(python_value, dtype=dtype) - atom = NumericArray(array, dtype) + atom = NumericArrayAtom(array, dtype) + print("xxx eval_list returning", type(atom), atom) return atom def eval_normal(self, array, evaluation): "System`Normal[array_NumericArray]" + print("xxx eval_normal", type(array)) return from_python(array.value.tolist()) # TODO: is this needed or does it happen by default? - def eval_to_string(self, array, evaluation): - "ToString[array_NumericArray]" - return String(str(array)) + #def eval_to_string(self, array, evaluation): + # "ToString[array_NumericArray]" + # return String(str(array)) diff --git a/mathics/core/atoms.py b/mathics/core/atoms.py index 16e392f91..a107234e6 100644 --- a/mathics/core/atoms.py +++ b/mathics/core/atoms.py @@ -1140,7 +1140,7 @@ class NumericArray(Atom, ImmutableValueMixin): class_head_name = "System`NumericArray" - def __init__(cls, value, dtype=None): + def __init__(self, value, dtype=None): if numpy is None: raise ImportError("numpy is required for NumericArray") @@ -1159,8 +1159,8 @@ def __init__(cls, value, dtype=None): raise ValueError(message) # summary and hash - self._summary = (self._type_name(), self.value.shape, self.value.tobytes()) - shape_string = "×".join(str(dim) for dim in shape) or "0" + self._summary = (self._type_name, self.value.shape, self.value.tobytes()) + shape_string = "×".join(str(dim) for dim in self.value.shape) or "0" self._summary_string = f"{self._type_name}, {shape_string}" self._hash = None From 2814dd5f6e03db618ff28029824190f24fa51622 Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Thu, 6 Nov 2025 08:37:29 -0500 Subject: [PATCH 04/11] WIP --- mathics/builtin/list/constructing.py | 4 +++- mathics/builtin/numericarray.py | 22 ++++++++-------------- mathics/core/atoms.py | 10 +++++++++- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/mathics/builtin/list/constructing.py b/mathics/builtin/list/constructing.py index 73aa8fb4d..b35c8f602 100644 --- a/mathics/builtin/list/constructing.py +++ b/mathics/builtin/list/constructing.py @@ -13,7 +13,7 @@ from typing import Optional, Tuple from mathics.builtin.box.layout import RowBox -from mathics.core.atoms import ByteArray, Integer, Integer1, is_integer_rational_or_real +from mathics.core.atoms import ByteArray, Integer, Integer1, is_integer_rational_or_real, NumericArray from mathics.core.attributes import A_HOLD_FIRST, A_LISTABLE, A_LOCKED, A_PROTECTED from mathics.core.builtin import BasePattern, Builtin, IterationFunction from mathics.core.convert.expression import to_expression @@ -197,6 +197,8 @@ class Normal(Builtin): def eval_general(self, expr: Expression, evaluation: Evaluation): "Normal[expr_]" + if isinstance(expr, NumericArray): + return expr.to_normal() if isinstance(expr, Atom): if isinstance(expr, ByteArray): return ListExpression(*expr.items) diff --git a/mathics/builtin/numericarray.py b/mathics/builtin/numericarray.py index 74b8cc0fe..7c23151e8 100644 --- a/mathics/builtin/numericarray.py +++ b/mathics/builtin/numericarray.py @@ -8,7 +8,7 @@ except ImportError: # pragma: no cover - handled via requires attribute numpy = None -from mathics.core.atoms import NumericArray as NumericArrayAtom, NUMERIC_ARRAY_TYPE_MAP, String +from mathics.core.atoms import NumericArray, NUMERIC_ARRAY_TYPE_MAP, String from mathics.core.builtin import Builtin from mathics.core.convert.python import from_python from mathics.core.symbols import Symbol, strip_context @@ -18,7 +18,7 @@ # name modeled on Complex_ to avoid collision with NumericArray atom class NumericArray_(Builtin): - summary_text = "head for NumericArray" + summary_text = "construct NumericArray" name = "NumericArray" rules = { "NumericArray[list_List]": "NumericArray[list, Automatic]" @@ -52,18 +52,12 @@ def eval_list(self, data, typespec, evaluation): # compute array from data and dtype and wrap it in a NumericArray atom python_value = data.to_python() array = numpy.array(python_value, dtype=dtype) - atom = NumericArrayAtom(array, dtype) + atom = NumericArray(array, dtype) - print("xxx eval_list returning", type(atom), atom) return atom - def eval_normal(self, array, evaluation): - "System`Normal[array_NumericArray]" - print("xxx eval_normal", type(array)) - return from_python(array.value.tolist()) - - # TODO: is this needed or does it happen by default? - #def eval_to_string(self, array, evaluation): - # "ToString[array_NumericArray]" - # return String(str(array)) - + # this doesn't work + # instead see Normal builtin in mathics/builtin/list/constructing.py + #def eval_normal(self, array, evaluation): + # "System`Normal[array_NumericArray]" + # return from_python(array.value.tolist()) diff --git a/mathics/core/atoms.py b/mathics/core/atoms.py index a107234e6..782748b6a 100644 --- a/mathics/core/atoms.py +++ b/mathics/core/atoms.py @@ -1138,7 +1138,7 @@ class NumericArray(Atom, ImmutableValueMixin): backed by NumPy arrays. """ - class_head_name = "System`NumericArray" + class_head_name = "NumericArray" def __init__(self, value, dtype=None): if numpy is None: @@ -1166,6 +1166,7 @@ def __init__(self, value, dtype=None): def __hash__(self): if not self._hash: + print("HASHING NUMERICARRAY") self._hash = hash(("NumericArray", self._summary)) return self._hash @@ -1195,9 +1196,16 @@ def sameQ(self, rhs) -> bool: def to_sympy(self, **kwargs): return None + # TODO: should to_python return the numpy array or the numpy array converted to nested lists? def to_python(self, *args, **kwargs): return self.value + # called from Normal builtin in mathics/builtin/list/constructing.py + # if to_python returns numpy list this could just be moved to the Normal builtin + def to_normal(self): + from mathics.core.convert.python import from_python + return from_python(self.value.tolist()) + # TODO: what is this? is it right? def user_hash(self, update): update(self._summary[2]) From 9cb4da122d6fff337935c9c02ec05f08b5658963 Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Thu, 6 Nov 2025 13:22:54 -0500 Subject: [PATCH 05/11] WIP --- mathics/builtin/numericarray.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mathics/builtin/numericarray.py b/mathics/builtin/numericarray.py index 7c23151e8..a2ff8bab5 100644 --- a/mathics/builtin/numericarray.py +++ b/mathics/builtin/numericarray.py @@ -15,7 +15,7 @@ from mathics.core.systemsymbols import SymbolAutomatic, SymbolFailed, SymbolNumericArray -# name modeled on Complex_ to avoid collision with NumericArray atom +# class name modeled on Complex_ to avoid collision with NumericArray atom class NumericArray_(Builtin): summary_text = "construct NumericArray" From c2b0ace938a4d49aaa9401e260135eb22115aa65 Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Thu, 6 Nov 2025 13:29:23 -0500 Subject: [PATCH 06/11] WIP --- mathics/builtin/list/constructing.py | 3 ++- mathics/core/atoms.py | 15 +++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/mathics/builtin/list/constructing.py b/mathics/builtin/list/constructing.py index b35c8f602..dbba34152 100644 --- a/mathics/builtin/list/constructing.py +++ b/mathics/builtin/list/constructing.py @@ -17,6 +17,7 @@ from mathics.core.attributes import A_HOLD_FIRST, A_LISTABLE, A_LOCKED, A_PROTECTED from mathics.core.builtin import BasePattern, Builtin, IterationFunction from mathics.core.convert.expression import to_expression +from mathics.core.convert.python import from_python from mathics.core.convert.sympy import from_sympy from mathics.core.element import ElementsProperties from mathics.core.evaluation import Evaluation @@ -198,7 +199,7 @@ class Normal(Builtin): def eval_general(self, expr: Expression, evaluation: Evaluation): "Normal[expr_]" if isinstance(expr, NumericArray): - return expr.to_normal() + return from_python(expr.to_python()) if isinstance(expr, Atom): if isinstance(expr, ByteArray): return ListExpression(*expr.items) diff --git a/mathics/core/atoms.py b/mathics/core/atoms.py index 782748b6a..0773fd3ac 100644 --- a/mathics/core/atoms.py +++ b/mathics/core/atoms.py @@ -1196,15 +1196,10 @@ def sameQ(self, rhs) -> bool: def to_sympy(self, **kwargs): return None - # TODO: should to_python return the numpy array or the numpy array converted to nested lists? + # TODO: note that this returns a simple python list (of lists), + # not the numpy array - ok? def to_python(self, *args, **kwargs): - return self.value - - # called from Normal builtin in mathics/builtin/list/constructing.py - # if to_python returns numpy list this could just be moved to the Normal builtin - def to_normal(self): - from mathics.core.convert.python import from_python - return from_python(self.value.tolist()) + return self.value.tolist() # TODO: what is this? is it right? def user_hash(self, update): @@ -1214,10 +1209,6 @@ def __getnewargs__(self): return (self.value, self.value.dtype) -# -# String -# - class String(Atom, BoxElementMixin): value: str class_head_name = "System`String" From 088f68a1df7f8fc81dc4527d9d6de7d2d0cfbcbe Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Thu, 6 Nov 2025 13:34:06 -0500 Subject: [PATCH 07/11] WIP --- mathics/core/atoms.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mathics/core/atoms.py b/mathics/core/atoms.py index 0773fd3ac..faec09ab7 100644 --- a/mathics/core/atoms.py +++ b/mathics/core/atoms.py @@ -1164,6 +1164,8 @@ def __init__(self, value, dtype=None): self._summary_string = f"{self._type_name}, {shape_string}" self._hash = None + # TODO: this is potentially expensive - what if we left it unimplemented? is hashing a numpy array reasonable? + # TODO: or maybe make self._summary included only some of the bytes, since it's just a hash? def __hash__(self): if not self._hash: print("HASHING NUMERICARRAY") From 77b855558eaf302e7534966694c4bcbf5d961366 Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Sun, 9 Nov 2025 08:19:36 -0500 Subject: [PATCH 08/11] Fix tests --- test/builtin/test_numericarray.py | 102 +++++++++++++----------------- 1 file changed, 44 insertions(+), 58 deletions(-) diff --git a/test/builtin/test_numericarray.py b/test/builtin/test_numericarray.py index 7009bc9a3..b539aae7a 100644 --- a/test/builtin/test_numericarray.py +++ b/test/builtin/test_numericarray.py @@ -1,68 +1,54 @@ -import numpy as np - -from mathics.builtin.numericarray import NumericArray as NumericArrayBuiltin from mathics.core.atoms import Integer, NumericArray, String from mathics.core.convert.python import from_python -from mathics.core.expression import Expression -from mathics.core.list import ListExpression -from mathics.core.systemsymbols import SymbolAutomatic, SymbolNumericArray - - -def test_numericarray_atom_preserves_array_reference(): - arr = np.array([1, 2, 3], dtype=np.int64) - atom = NumericArray(arr) - assert atom.value is arr - np.testing.assert_array_equal(atom.value, arr) - - -def test_numericarray_expression_from_python_array(): - arr = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) - expr = from_python(arr) - assert expr.head is SymbolNumericArray - atom = expr.elements[0] - np.testing.assert_array_equal(atom.value, arr) - assert expr.value is atom.value - - -class DummyEvaluation: - def message(self, *args, **kwargs): - raise AssertionError(f"Unexpected message: {args!r}") +from test.helper import check_evaluation, evaluate +import numpy as np +import pytest -def test_numericarray_builtin_default_type(): - evaluation = DummyEvaluation() - builtin = NumericArrayBuiltin(expression=False) - data = from_python([1, 2, 3]) - result = builtin.eval(data, SymbolAutomatic, evaluation) - assert result.head is SymbolNumericArray - atom = result.elements[0] - np.testing.assert_array_equal(atom.value, np.array([1, 2, 3])) +# +# Python API tests +# +def test_numericarray_atom_preserves_array_reference(): + array = np.array([1, 2, 3], dtype=np.int64) + atom = NumericArray(array) + assert atom.value is array -def test_numericarray_builtin_type_conversion(): - evaluation = DummyEvaluation() - builtin = NumericArrayBuiltin(expression=False) - data = from_python([1, 2, 3]) - type_spec = String("UnsignedInteger16") - result = builtin.eval(data, type_spec, evaluation) - atom = result.elements[0] - assert atom.value.dtype == np.uint16 +def test_numericarray_atom_preserves_equality(): + array = np.array([1, 2, 3], dtype=np.int64) + atom = NumericArray(array, dtype=np.float64) + np.testing.assert_array_equal(atom.value, array) -def test_numericarray_normal_returns_list_expression(): - evaluation = DummyEvaluation() - builtin = NumericArrayBuiltin(expression=False) - arr = np.array([1, 2], dtype=np.int64) - atom = NumericArray(arr) - normal_expr = builtin.eval_normal(atom, evaluation) - assert normal_expr.value == (1, 2) +def test_numericarray_expression_from_python_array(): + array = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + atom = from_python(array) + assert isinstance(atom, NumericArray) + assert atom.value is array + + +# +# WL tests +# + +@pytest.mark.parametrize( + ("str_expr", "str_expected"), + [ + ("NumericArray[{{1,2},{3,4}}]", ""), + ("ToString[NumericArray[{{1,2},{3,4}}]]", ""), + ("Head[NumericArray[{1,2}]]", "NumericArray"), + ("AtomQ[NumericArray[{1,2}]]", "True"), + ("Normal[NumericArray[{{1,2}, {3,4}}]]", "{{1, 2}, {3, 4}}"), + ] +) +def test_basics(str_expr, str_expected): + check_evaluation(str_expr, str_expected, hold_expected=True) + +def test_type_conversion(): + expr = evaluate("NumericArray[{1,2}]") + assert isinstance(expr, NumericArray) + assert expr.value.dtype == np.int64 + expr = evaluate('NumericArray[{1,2}, "ComplexReal32"]') + assert expr.value.dtype == np.complex64 -def test_numericarray_from_expression_nested_lists(): - list_expr = ListExpression( - ListExpression(Integer(1), Integer(2)), - ListExpression(Integer(3), Integer(4)), - ) - expr = Expression(SymbolNumericArray, list_expr) - atom = NumericArray.from_expression(expr) - np.testing.assert_array_equal(atom.value, np.array([[1, 2], [3, 4]])) From 21e7b31e31388994b48f87cea72436112b7c976d Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Sun, 9 Nov 2025 10:58:12 -0500 Subject: [PATCH 09/11] Generalize using Atom.items --- mathics/builtin/list/constructing.py | 12 ++++++++---- mathics/builtin/list/eol.py | 15 +++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/mathics/builtin/list/constructing.py b/mathics/builtin/list/constructing.py index dbba34152..429ff7d67 100644 --- a/mathics/builtin/list/constructing.py +++ b/mathics/builtin/list/constructing.py @@ -198,11 +198,15 @@ class Normal(Builtin): def eval_general(self, expr: Expression, evaluation: Evaluation): "Normal[expr_]" - if isinstance(expr, NumericArray): - return from_python(expr.to_python()) if isinstance(expr, Atom): - if isinstance(expr, ByteArray): - return ListExpression(*expr.items) + if hasattr(expr, "items"): + def normal(items): + return ListExpression(*( + normal(item.items) if isinstance(item, Atom) and hasattr(item, "items") + else item + for item in items + )) + return normal(expr.items) return expr if expr.has_form("RootSum", 2): return from_sympy(expr.to_sympy().doit(roots=True)) diff --git a/mathics/builtin/list/eol.py b/mathics/builtin/list/eol.py index 89c061144..d93a03521 100644 --- a/mathics/builtin/list/eol.py +++ b/mathics/builtin/list/eol.py @@ -698,25 +698,24 @@ def eval(self, expr, evaluation: Evaluation, expression: Expression): if not hasattr(expr, "items"): evaluation.message("First", "normal", Integer1, expression) return - expr_len = len(expr.items) + parts = expr.items else: - expr_len = len(expr.elements) + parts = expr.elements + + expr_len = len(parts) if expr_len == 0: evaluation.message("First", "nofirst", expr) return - if isinstance(expr, ByteArray): - return expr.items[0] - - if expr_len > 2 and expr.head is SymbolSequence: + if expr_len > 2 and expr.get_head() is SymbolSequence: evaluation.message( "First", "argt", SymbolFirst, Integer(expr_len), Integer1, Integer2 ) return - first_elem = expr.elements[0] + first_elem = parts[0] - if expr.head == SymbolSequence or ( + if expr.get_head() == SymbolSequence or ( not isinstance(expr, ListExpression) and len == 2 and isinstance(first_elem, Atom) From 9ab56ffed6e830bc486106a939fd813b1fe4993e Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Sun, 9 Nov 2025 10:59:00 -0500 Subject: [PATCH 10/11] Fix hash, element_order, items --- mathics/core/atoms.py | 22 +++++++++++++++++----- mathics/core/keycomparable.py | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/mathics/core/atoms.py b/mathics/core/atoms.py index faec09ab7..695a5647b 100644 --- a/mathics/core/atoms.py +++ b/mathics/core/atoms.py @@ -19,6 +19,7 @@ from mathics.core.keycomparable import ( BASIC_ATOM_BYTEARRAY_ELT_ORDER, BASIC_ATOM_NUMBER_ELT_ORDER, + BASIC_ATOM_NUMERICARRAY_ELT_ORDER, BASIC_ATOM_STRING_ELT_ORDER, ) from mathics.core.number import ( @@ -1159,17 +1160,15 @@ def __init__(self, value, dtype=None): raise ValueError(message) # summary and hash - self._summary = (self._type_name, self.value.shape, self.value.tobytes()) shape_string = "×".join(str(dim) for dim in self.value.shape) or "0" self._summary_string = f"{self._type_name}, {shape_string}" self._hash = None # TODO: this is potentially expensive - what if we left it unimplemented? is hashing a numpy array reasonable? - # TODO: or maybe make self._summary included only some of the bytes, since it's just a hash? + # TODO: to make it less expensive only look at first 100 bytes - ok? needed? def __hash__(self): if not self._hash: - print("HASHING NUMERICARRAY") - self._hash = hash(("NumericArray", self._summary)) + self._hash = hash(("NumericArray", self.value.shape, self.value.tobytes()[:100])) return self._hash def __str__(self) -> str: @@ -1184,9 +1183,22 @@ def do_copy(self) -> "NumericArray": def default_format(self, evaluation, form) -> str: return f"NumericArray[<{self._summary_string}>]" + @property + def items(self) -> Tuple: + from mathics.core.convert.python import from_python + if len(self.value.shape) == 1: + return tuple(from_python(item.item()) for item in self.value) + else: + return tuple(NumericArray(array) for array in self.value) + @property def element_order(self) -> tuple: - return (BASIC_ATOM_STRING_OR_BYTEARRAY_SORT_KEY, *self._summary) + return ( + BASIC_ATOM_NUMERICARRAY_ELT_ORDER, + self.value.shape, + self.value.dtype, + self.value.tobytes() + ) @property def pattern_precedence(self) -> tuple: diff --git a/mathics/core/keycomparable.py b/mathics/core/keycomparable.py index c8bd5a771..dac5c4901 100644 --- a/mathics/core/keycomparable.py +++ b/mathics/core/keycomparable.py @@ -279,6 +279,7 @@ def __ne__(self, other) -> bool: BASIC_ATOM_STRING_ELT_ORDER = 0x01 BASIC_ATOM_BYTEARRAY_ELT_ORDER = 0x02 LITERAL_EXPRESSION_ELT_ORDER = 0x03 +BASIC_ATOM_NUMERICARRAY_ELT_ORDER = 0x04 BASIC_NUMERIC_EXPRESSION_ELT_ORDER = 0x12 GENERAL_NUMERIC_EXPRESSION_ELT_ORDER = 0x13 From 85b627db6dacf4aabd86ce3fcfd9231da7621a11 Mon Sep 17 00:00:00 2001 From: Bruce Lucas Date: Sun, 9 Nov 2025 10:59:08 -0500 Subject: [PATCH 11/11] More tests --- test/builtin/test_numericarray.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/builtin/test_numericarray.py b/test/builtin/test_numericarray.py index b539aae7a..36f23e021 100644 --- a/test/builtin/test_numericarray.py +++ b/test/builtin/test_numericarray.py @@ -26,6 +26,17 @@ def test_numericarray_expression_from_python_array(): assert isinstance(atom, NumericArray) assert atom.value is array +def test_numericarray_hash(): + a = [[1, 2], [3, 4]] + array1a = np.array(a, dtype=np.float32) + atom1a = from_python(array1a) + array1b = np.array(a, dtype=np.float32) + atom1b = from_python(array1a) + array2 = np.array(a, dtype=np.float64) + atom2 = from_python(array2) + assert hash(atom1a) == hash(atom1b), "hashes of different arrays with same value should be same" + assert hash(atom1a) != hash(atom2), "hashes of arrays with different values should be different" + # # WL tests @@ -34,10 +45,19 @@ def test_numericarray_expression_from_python_array(): @pytest.mark.parametrize( ("str_expr", "str_expected"), [ + # TODO: remove this - was temp to see if Normal still worked on ByteArray after changes + #("Normal[ByteArray[{1,2}]]", "{1, 2}"), ("NumericArray[{{1,2},{3,4}}]", ""), ("ToString[NumericArray[{{1,2},{3,4}}]]", ""), ("Head[NumericArray[{1,2}]]", "NumericArray"), ("AtomQ[NumericArray[{1,2}]]", "True"), + ("First[NumericArray[{1,2,3}]]", "1"), + ("First[NumericArray[{{1,2}, {3,4}}]]", ""), + # TODO: these do not work yet + # change was applied to First to make that work, + # but did not want to change Last because it is awaiting DRYing + #("Last[NumericArray[{1,2,3}]]", "3"), + #("Last[NumericArray[{{1,2}, {3,4}}]]", ""), ("Normal[NumericArray[{{1,2}, {3,4}}]]", "{{1, 2}, {3, 4}}"), ] )