Skip to content

Commit a7acec0

Browse files
authored
Better plot tests (#1541)
We only have a couple plot tests that actually look at the output, and those were constructed and run laboriously, and none of them test the new vectorized plot code. This makes refactoring the code risky (and so far I've been relying mostly on end-to-end testing that compares the generated visuals in my test front-end). Here's a different approach: the idea is to write the result expression to a file in outline tree form, and then compare the actual result with an expected reference result using diff. This provides easily constructed tests with a fairly readable indication of what has changed. See comment in test_plot__detailed.py In addition, this change * Makes it possible to change at runtime whether vectorized or classic plot code is used * Augments print_expression_tree to print the abbreviated numpy arrays (default numpy str implementation) so that numeric data can be checked efficiently for gross changes.
1 parent d171d37 commit a7acec0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+48010
-26
lines changed

mathics/builtin/drawing/plot.py

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
import palettable
1717

18+
import mathics.eval.drawing.plot3d
19+
import mathics.eval.drawing.plot3d_vectorized
1820
from mathics.builtin.drawing.graphics3d import Graphics3D
1921
from mathics.builtin.graphics import Graphics
2022
from mathics.builtin.options import options_to_rules
@@ -63,23 +65,25 @@
6365
# Set option such as $UseVectorizedPlot, and maybe a non-standard Plot3D option.
6466
# For now an env variable is simplest.
6567
# TODO: work out exactly how to deploy.
68+
69+
70+
# can be set via environment variable at startup time,
71+
# or changed dynamically by setting the use_vectorized_plot flag
6672
use_vectorized_plot = os.getenv("MATHICS3_USE_VECTORIZED_PLOT", False)
67-
if use_vectorized_plot:
68-
from mathics.eval.drawing.plot3d_vectorized import (
69-
eval_ComplexPlot,
70-
eval_ComplexPlot3D,
71-
eval_ContourPlot,
72-
eval_DensityPlot,
73-
eval_Plot3D,
74-
)
75-
else:
76-
from mathics.eval.drawing.plot3d import (
77-
eval_ComplexPlot,
78-
eval_ComplexPlot3D,
79-
eval_ContourPlot,
80-
eval_DensityPlot,
81-
eval_Plot3D,
73+
74+
75+
# get the plot eval function for the given class,
76+
# depending on whether vectorized plot functions are enabled
77+
def get_plot_eval_function(cls):
78+
function_name = "eval_" + cls.__name__
79+
plot_module = (
80+
mathics.eval.drawing.plot3d_vectorized
81+
if use_vectorized_plot
82+
else mathics.eval.drawing.plot3d
8283
)
84+
fun = getattr(plot_module, function_name)
85+
return fun
86+
8387

8488
# This tells documentation how to sort this module
8589
# Here we are also hiding "drawing" since this erroneously appears at the top level.
@@ -649,7 +653,8 @@ def eval(
649653
plot_options.functions = [functions]
650654

651655
# subclass must set eval_function and graphics_class
652-
graphics = self.eval_function(plot_options, evaluation)
656+
eval_function = get_plot_eval_function(self.__class__)
657+
graphics = eval_function(plot_options, evaluation)
653658
if not graphics:
654659
return
655660

@@ -833,7 +838,6 @@ class ComplexPlot3D(_Plot3D):
833838
options = _Plot3D.options3d | {"Mesh": "None"}
834839

835840
many_functions = True
836-
eval_function = staticmethod(eval_ComplexPlot3D)
837841
graphics_class = Graphics3D
838842

839843

@@ -857,7 +861,6 @@ class ComplexPlot(_Plot3D):
857861
options = _Plot3D.options2d
858862

859863
many_functions = False
860-
eval_function = staticmethod(eval_ComplexPlot)
861864
graphics_class = Graphics
862865

863866

@@ -883,7 +886,6 @@ class ContourPlot(_Plot3D):
883886
# TODO: other options?
884887

885888
many_functions = True
886-
eval_function = staticmethod(eval_ContourPlot)
887889
graphics_class = Graphics
888890

889891

@@ -916,7 +918,6 @@ class DensityPlot(_Plot3D):
916918
options = _Plot3D.options2d
917919

918920
many_functions = False
919-
eval_function = staticmethod(eval_DensityPlot)
920921
graphics_class = Graphics
921922

922923

@@ -2036,5 +2037,4 @@ class Plot3D(_Plot3D):
20362037
options = _Plot3D.options3d
20372038

20382039
many_functions = True
2039-
eval_function = staticmethod(eval_Plot3D)
20402040
graphics_class = Graphics3D

mathics/core/util.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
Miscellaneous mathics.core utility functions.
44
"""
55

6+
import re
67
import sys
78
from itertools import chain
89
from pathlib import PureWindowsPath
910
from platform import python_implementation
1011
from typing import Optional
1112

13+
from mathics.core.atoms import MachineReal, NumericArray
1214
from mathics.core.symbols import Symbol
1315

1416
IS_PYPY = python_implementation() == "PyPy"
@@ -117,20 +119,46 @@ def subranges(
117119
)
118120

119121

120-
def print_expression_tree(expr, indent="", marker=lambda expr: ""):
122+
def print_expression_tree(
123+
expr, indent="", marker=lambda expr: "", file=None, approximate=False
124+
):
121125
"""
122126
Print a Mathics Expression as an indented tree.
123127
Caller may supply a marker function that computes a marker
124128
to be displayed in the tree for the given node.
129+
The approximate flag causes numbers to be printed with fewer digits
130+
and the number of bits of precision (i.e. Real32 vs Real64) to be
131+
omitted from printing for numpy arrays, to faciliate comparisons
132+
across systems.
125133
"""
134+
if file is None:
135+
file = sys.stdout
136+
126137
if isinstance(expr, Symbol):
127-
print(f"{indent}{marker(expr)}{expr}")
138+
print(f"{indent}{marker(expr)}{expr}", file=file)
128139
elif not hasattr(expr, "elements"):
129-
print(f"{indent}{marker(expr)}{expr.get_head()} {expr}")
140+
if isinstance(expr, MachineReal) and approximate:
141+
# fewer digits
142+
value = str(round(expr.value * 1e6) / 1e6)
143+
elif isinstance(expr, NumericArray) and approximate:
144+
# Real32/64->Real*, Integer32/64->Integer*
145+
value = re.sub("[0-9]+,", "*,", str(expr))
146+
else:
147+
value = str(expr)
148+
print(f"{indent}{marker(expr)}{expr.get_head()} {value}", file=file)
149+
if isinstance(expr, NumericArray):
150+
# numpy provides an abbreviated version of the array
151+
# it is inherently approximated (i.e. limited precision) in its own way
152+
na_str = str(expr.value)
153+
i = indent + " "
154+
na_str = i + na_str.replace("\n", "\n" + i)
155+
print(na_str, file=file)
130156
else:
131-
print(f"{indent}{marker(expr)}{expr.head}")
157+
print(f"{indent}{marker(expr)}{expr.head}", file=file)
132158
for elt in expr.elements:
133-
print_expression_tree(elt, indent + " ", marker=marker)
159+
print_expression_tree(
160+
elt, indent + " ", marker=marker, file=file, approximate=approximate
161+
)
134162

135163

136164
def print_sympy_tree(expr, indent=""):

test/builtin/drawing/test_plot.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ def test_plot(str_expr, msgs, str_expected, fail_msg):
252252
)
253253

254254

255+
#
256+
# NOTE: I think the following tests have been superseded by test_plot_detail.py which
257+
# does similar (actually, more stringent) tests much less laboriously. Keeping these
258+
# for now just in case, but probably better to add new tests to test_plot_detail.py
255259
#
256260
# Call plotting functions and examine the structure of the output
257261
# In case of error trees are printed with an embedded >>> marker showing location of error
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""
2+
These tests evaluate Plot* functions, write the result expression to a file in
3+
outline tree form, and then compare the actual result with an expected reference
4+
result using diff. For example, if the code that emits a PlotRange based on
5+
the actual range of data plotted is disabled, the diff looks like this,
6+
making it fairly clear what is wrong:
7+
8+
@@ -109,13 +109,7 @@
9+
System`None
10+
System`Rule
11+
System`PlotRange
12+
- System`List
13+
- System`List
14+
- System`Real 0.0
15+
- System`Real 1.0
16+
- System`List
17+
- System`Real 0.0
18+
- System`Real 1.0
19+
+ System`Automatic
20+
System`Rule
21+
System`PlotRangeClipping
22+
System`False
23+
24+
The NumericArrays are emitted using NumPy's default str, which is an
25+
abbreviated display of the array, which has enough data that it should
26+
generally catch any gross error. For example if the function being
27+
plotted is changed the diff shows that the the of the array is correct,
28+
but the xyz coordinates output points are changed:
29+
30+
@@ -7,12 +7,12 @@
31+
System`GraphicsComplex
32+
System`NumericArray NumericArray[Real64, 40000×3]
33+
[[0. 0. 0. ]
34+
- [0.00502513 0. 0. ]
35+
- [0.01005025 0. 0. ]
36+
+ [0.00502513 0. 0.00502513]
37+
+ [0.01005025 0. 0.01005025]
38+
...
39+
- [0.98994975 1. 0.98994975]
40+
- [0.99497487 1. 0.99497487]
41+
- [1. 1. 1. ]]
42+
+ [0.98994975 1. 1.98994975]
43+
+ [0.99497487 1. 1.99497487]
44+
+ [1. 1. 2. ]]
45+
System`Polygon
46+
System`NumericArray NumericArray[Integer64, 39601×4]
47+
[[ 1 2 202 201]
48+
49+
The reference results are not huge but they are too unwieldy
50+
to include in code, so they are stored as files in their own
51+
*_ref directory.
52+
"""
53+
54+
import os
55+
import subprocess
56+
57+
# couple tests depend on ths
58+
try:
59+
import skimage
60+
except:
61+
skimage = None
62+
63+
from test.helper import session
64+
65+
import mathics.builtin.drawing.plot as plot
66+
from mathics.core.util import print_expression_tree
67+
68+
# common plotting options for 2d plots to test with and without
69+
opt2 = """
70+
AspectRatio -> 2,
71+
Axes -> False,
72+
Frame -> False,
73+
Mesh -> Full,
74+
PlotPoints -> 10
75+
"""
76+
77+
# 3d plots add these options
78+
opt3 = (
79+
opt2
80+
+ """,
81+
BoxRatios -> {1, 2, 3}
82+
"""
83+
)
84+
85+
# non-vectorized available, vectorized not available,
86+
classic = [
87+
("barchart", "BarChart[{3,5,2,7}]", opt2, True),
88+
("discreteplot", "DiscretePlot[n^2,{n,1,10}]", opt2, True),
89+
("histogram", "Histogram[{1,1,1,5,5,7,8,8,8}]", opt2, True),
90+
("listlineplot", "ListLinePlot[{1,4,2,5,3}]", opt2, True),
91+
("listplot", "ListPlot[{1,4,2,5,3}]", opt2, True),
92+
("liststepplot", "ListStepPlot[{1,4,2,5,3}]", opt2, True),
93+
# ("manipulate", "Manipulate[Plot[a x,{x,0,1}],{a,0,5}]", opt2, True),
94+
("numberlineplot", "NumberLinePlot[{1,3,4}]", opt2, True),
95+
("parametricplot", "ParametricPlot[{t,2 t},{t,0,2}]", opt2, True),
96+
("piechart", "PieChart[{3,2,5}]", opt2, True),
97+
("plot", "Plot[x, {x, 0, 1}]", opt2, True),
98+
("polarplot", "PolarPlot[3 θ,{θ,0,2}]", opt2, True),
99+
]
100+
101+
# vectorized available, non-vectorized not available
102+
vectorized = [
103+
("complexplot", "ComplexPlot[Exp[I z],{z,-2-2 I,2+2 I}]", opt2, True),
104+
("complexplot3d", "ComplexPlot3D[Exp[I z],{z,-2-2 I,2+2 I}]", opt3, True),
105+
("contourplot-1", "ContourPlot[x^2-y^2,{x,-2,2},{y,-2,2}]", opt2, skimage),
106+
("contourplot-2", "ContourPlot[x^2+y^2==1,{x,-2,2},{y,-2,2}]", opt2, skimage),
107+
]
108+
109+
# both vectorized and non-vectorized available
110+
both = [
111+
("densityplot", "DensityPlot[x y,{x,-2,2},{y,-2,2}]", opt2, True),
112+
("plot3d", "Plot3D[x y,{x,-2,2},{y,-2,2}]", opt3, True),
113+
]
114+
115+
# compute reference dir, which is this file minus .py plus _ref
116+
path, _ = os.path.splitext(__file__)
117+
ref_dir = path + "_ref"
118+
print(f"ref_dir {ref_dir}")
119+
120+
121+
def one_test(name, str_expr, vec, opt, act_dir="/tmp"):
122+
# update name and set use_vectorized_plot depending on
123+
# whether vectorized test
124+
if vec:
125+
name += "-vec"
126+
plot.use_vectorized_plot = vec
127+
else:
128+
name += "-cls"
129+
130+
# update name and splice in options depending on
131+
# whether default or with-options test
132+
if opt:
133+
name += "-opt"
134+
str_expr = f"{str_expr[:-1]}, {opt}]"
135+
else:
136+
name += "-def"
137+
138+
print(f"=== running {name} {str_expr}")
139+
140+
try:
141+
# evaluate the expression to be tested
142+
expr = session.parse(str_expr)
143+
act_expr = expr.evaluate(session.evaluation)
144+
if len(session.evaluation.out):
145+
print("=== messages:")
146+
for message in session.evaluation.out:
147+
print(message.text)
148+
assert not session.evaluation.out, "no output messages expected"
149+
150+
# write the results to act_fn in act_dir
151+
act_fn = os.path.join(act_dir, f"{name}.txt")
152+
with open(act_fn, "w") as act_f:
153+
print_expression_tree(act_expr, file=act_f, approximate=True)
154+
155+
# use diff to compare the actual result in act_fn to reference result in ref_fn,
156+
# with a fallback of simple string comparison if diff is not available
157+
ref_fn = os.path.join(ref_dir, f"{name}.txt")
158+
try:
159+
result = subprocess.run(
160+
["diff", "-U", "5", ref_fn, act_fn], capture_output=False
161+
)
162+
assert result.returncode == 0, "reference and actual result differ"
163+
except OSError:
164+
with open(ref_fn) as ref_f, open(act_fn) as act_f:
165+
ref_str, act_str = ref_f.read(), act_f.read()
166+
assert ref_str == act_str, "reference and actual result differ"
167+
168+
# remove /tmp file if test was successful
169+
if act_fn != ref_fn:
170+
os.remove(act_fn)
171+
172+
finally:
173+
plot.use_vectorized_plot = False
174+
175+
176+
def test_all(act_dir="/tmp", opt=None):
177+
# run twice, once without and once with options
178+
for use_opt in [False, True]:
179+
# run classic tests
180+
for name, str_expr, opt, cond in classic + both:
181+
if cond:
182+
opt = opt if use_opt else None
183+
one_test(name, str_expr, False, opt, act_dir)
184+
185+
# run vectorized tests
186+
for name, str_expr, opt, cond in vectorized + both:
187+
if cond:
188+
opt = opt if use_opt else None
189+
one_test(name, str_expr, True, opt, act_dir)
190+
191+
192+
if __name__ == "__main__":
193+
# reference files can be generated by pointing saved actual
194+
# output at reference dir instead of /tmp
195+
def make_ref_files():
196+
test_all(ref_dir)
197+
198+
def run_tests():
199+
try:
200+
test_all()
201+
except AssertionError:
202+
print("FAIL")
203+
204+
make_ref_files()
205+
# run_tests()

0 commit comments

Comments
 (0)