Spaces:
Sleeping
Sleeping
### The base class for all series | |
from collections.abc import Callable | |
from sympy.calculus.util import continuous_domain | |
from sympy.concrete import Sum, Product | |
from sympy.core.containers import Tuple | |
from sympy.core.expr import Expr | |
from sympy.core.function import arity | |
from sympy.core.sorting import default_sort_key | |
from sympy.core.symbol import Symbol | |
from sympy.functions import atan2, zeta, frac, ceiling, floor, im | |
from sympy.core.relational import (Equality, GreaterThan, | |
LessThan, Relational, Ne) | |
from sympy.core.sympify import sympify | |
from sympy.external import import_module | |
from sympy.logic.boolalg import BooleanFunction | |
from sympy.plotting.utils import _get_free_symbols, extract_solution | |
from sympy.printing.latex import latex | |
from sympy.printing.pycode import PythonCodePrinter | |
from sympy.printing.precedence import precedence | |
from sympy.sets.sets import Set, Interval, Union | |
from sympy.simplify.simplify import nsimplify | |
from sympy.utilities.exceptions import sympy_deprecation_warning | |
from sympy.utilities.lambdify import lambdify | |
from .intervalmath import interval | |
import warnings | |
class IntervalMathPrinter(PythonCodePrinter): | |
"""A printer to be used inside `plot_implicit` when `adaptive=True`, | |
in which case the interval arithmetic module is going to be used, which | |
requires the following edits. | |
""" | |
def _print_And(self, expr): | |
PREC = precedence(expr) | |
return " & ".join(self.parenthesize(a, PREC) | |
for a in sorted(expr.args, key=default_sort_key)) | |
def _print_Or(self, expr): | |
PREC = precedence(expr) | |
return " | ".join(self.parenthesize(a, PREC) | |
for a in sorted(expr.args, key=default_sort_key)) | |
def _uniform_eval(f1, f2, *args, modules=None, | |
force_real_eval=False, has_sum=False): | |
""" | |
Note: this is an experimental function, as such it is prone to changes. | |
Please, do not use it in your code. | |
""" | |
np = import_module('numpy') | |
def wrapper_func(func, *args): | |
try: | |
return complex(func(*args)) | |
except (ZeroDivisionError, OverflowError): | |
return complex(np.nan, np.nan) | |
# NOTE: np.vectorize is much slower than numpy vectorized operations. | |
# However, this modules must be able to evaluate functions also with | |
# mpmath or sympy. | |
wrapper_func = np.vectorize(wrapper_func, otypes=[complex]) | |
def _eval_with_sympy(err=None): | |
if f2 is None: | |
msg = "Impossible to evaluate the provided numerical function" | |
if err is None: | |
msg += "." | |
else: | |
msg += "because the following exception was raised:\n" | |
"{}: {}".format(type(err).__name__, err) | |
raise RuntimeError(msg) | |
if err: | |
warnings.warn( | |
"The evaluation with %s failed.\n" % ( | |
"NumPy/SciPy" if not modules else modules) + | |
"{}: {}\n".format(type(err).__name__, err) + | |
"Trying to evaluate the expression with Sympy, but it might " | |
"be a slow operation." | |
) | |
return wrapper_func(f2, *args) | |
if modules == "sympy": | |
return _eval_with_sympy() | |
try: | |
return wrapper_func(f1, *args) | |
except Exception as err: | |
return _eval_with_sympy(err) | |
def _adaptive_eval(f, x): | |
"""Evaluate f(x) with an adaptive algorithm. Post-process the result. | |
If a symbolic expression is evaluated with SymPy, it might returns | |
another symbolic expression, containing additions, ... | |
Force evaluation to a float. | |
Parameters | |
========== | |
f : callable | |
x : float | |
""" | |
np = import_module('numpy') | |
y = f(x) | |
if isinstance(y, Expr) and (not y.is_Number): | |
y = y.evalf() | |
y = complex(y) | |
if y.imag > 1e-08: | |
return np.nan | |
return y.real | |
def _get_wrapper_for_expr(ret): | |
wrapper = "%s" | |
if ret == "real": | |
wrapper = "re(%s)" | |
elif ret == "imag": | |
wrapper = "im(%s)" | |
elif ret == "abs": | |
wrapper = "abs(%s)" | |
elif ret == "arg": | |
wrapper = "arg(%s)" | |
return wrapper | |
class BaseSeries: | |
"""Base class for the data objects containing stuff to be plotted. | |
Notes | |
===== | |
The backend should check if it supports the data series that is given. | |
(e.g. TextBackend supports only LineOver1DRangeSeries). | |
It is the backend responsibility to know how to use the class of | |
data series that is given. | |
Some data series classes are grouped (using a class attribute like is_2Dline) | |
according to the api they present (based only on convention). The backend is | |
not obliged to use that api (e.g. LineOver1DRangeSeries belongs to the | |
is_2Dline group and presents the get_points method, but the | |
TextBackend does not use the get_points method). | |
BaseSeries | |
""" | |
# Some flags follow. The rationale for using flags instead of checking base | |
# classes is that setting multiple flags is simpler than multiple | |
# inheritance. | |
is_2Dline = False | |
# Some of the backends expect: | |
# - get_points returning 1D np.arrays list_x, list_y | |
# - get_color_array returning 1D np.array (done in Line2DBaseSeries) | |
# with the colors calculated at the points from get_points | |
is_3Dline = False | |
# Some of the backends expect: | |
# - get_points returning 1D np.arrays list_x, list_y, list_y | |
# - get_color_array returning 1D np.array (done in Line2DBaseSeries) | |
# with the colors calculated at the points from get_points | |
is_3Dsurface = False | |
# Some of the backends expect: | |
# - get_meshes returning mesh_x, mesh_y, mesh_z (2D np.arrays) | |
# - get_points an alias for get_meshes | |
is_contour = False | |
# Some of the backends expect: | |
# - get_meshes returning mesh_x, mesh_y, mesh_z (2D np.arrays) | |
# - get_points an alias for get_meshes | |
is_implicit = False | |
# Some of the backends expect: | |
# - get_meshes returning mesh_x (1D array), mesh_y(1D array, | |
# mesh_z (2D np.arrays) | |
# - get_points an alias for get_meshes | |
# Different from is_contour as the colormap in backend will be | |
# different | |
is_interactive = False | |
# An interactive series can update its data. | |
is_parametric = False | |
# The calculation of aesthetics expects: | |
# - get_parameter_points returning one or two np.arrays (1D or 2D) | |
# used for calculation aesthetics | |
is_generic = False | |
# Represent generic user-provided numerical data | |
is_vector = False | |
is_2Dvector = False | |
is_3Dvector = False | |
# Represents a 2D or 3D vector data series | |
_N = 100 | |
# default number of discretization points for uniform sampling. Each | |
# subclass can set its number. | |
def __init__(self, *args, **kwargs): | |
kwargs = _set_discretization_points(kwargs.copy(), type(self)) | |
# discretize the domain using only integer numbers | |
self.only_integers = kwargs.get("only_integers", False) | |
# represents the evaluation modules to be used by lambdify | |
self.modules = kwargs.get("modules", None) | |
# plot functions might create data series that might not be useful to | |
# be shown on the legend, for example wireframe lines on 3D plots. | |
self.show_in_legend = kwargs.get("show_in_legend", True) | |
# line and surface series can show data with a colormap, hence a | |
# colorbar is essential to understand the data. However, sometime it | |
# is useful to hide it on series-by-series base. The following keyword | |
# controls wheter the series should show a colorbar or not. | |
self.colorbar = kwargs.get("colorbar", True) | |
# Some series might use a colormap as default coloring. Setting this | |
# attribute to False will inform the backends to use solid color. | |
self.use_cm = kwargs.get("use_cm", False) | |
# If True, the backend will attempt to render it on a polar-projection | |
# axis, or using a polar discretization if a 3D plot is requested | |
self.is_polar = kwargs.get("is_polar", kwargs.get("polar", False)) | |
# If True, the rendering will use points, not lines. | |
self.is_point = kwargs.get("is_point", kwargs.get("point", False)) | |
# some backend is able to render latex, other needs standard text | |
self._label = self._latex_label = "" | |
self._ranges = [] | |
self._n = [ | |
int(kwargs.get("n1", self._N)), | |
int(kwargs.get("n2", self._N)), | |
int(kwargs.get("n3", self._N)) | |
] | |
self._scales = [ | |
kwargs.get("xscale", "linear"), | |
kwargs.get("yscale", "linear"), | |
kwargs.get("zscale", "linear") | |
] | |
# enable interactive widget plots | |
self._params = kwargs.get("params", {}) | |
if not isinstance(self._params, dict): | |
raise TypeError("`params` must be a dictionary mapping symbols " | |
"to numeric values.") | |
if len(self._params) > 0: | |
self.is_interactive = True | |
# contains keyword arguments that will be passed to the rendering | |
# function of the chosen plotting library | |
self.rendering_kw = kwargs.get("rendering_kw", {}) | |
# numerical transformation functions to be applied to the output data: | |
# x, y, z (coordinates), p (parameter on parametric plots) | |
self._tx = kwargs.get("tx", None) | |
self._ty = kwargs.get("ty", None) | |
self._tz = kwargs.get("tz", None) | |
self._tp = kwargs.get("tp", None) | |
if not all(callable(t) or (t is None) for t in | |
[self._tx, self._ty, self._tz, self._tp]): | |
raise TypeError("`tx`, `ty`, `tz`, `tp` must be functions.") | |
# list of numerical functions representing the expressions to evaluate | |
self._functions = [] | |
# signature for the numerical functions | |
self._signature = [] | |
# some expressions don't like to be evaluated over complex data. | |
# if that's the case, set this to True | |
self._force_real_eval = kwargs.get("force_real_eval", None) | |
# this attribute will eventually contain a dictionary with the | |
# discretized ranges | |
self._discretized_domain = None | |
# wheter the series contains any interactive range, which is a range | |
# where the minimum and maximum values can be changed with an | |
# interactive widget | |
self._interactive_ranges = False | |
# NOTE: consider a generic summation, for example: | |
# s = Sum(cos(pi * x), (x, 1, y)) | |
# This gets lambdified to something: | |
# sum(cos(pi*x) for x in range(1, y+1)) | |
# Hence, y needs to be an integer, otherwise it raises: | |
# TypeError: 'complex' object cannot be interpreted as an integer | |
# This list will contains symbols that are upper bound to summations | |
# or products | |
self._needs_to_be_int = [] | |
# a color function will be responsible to set the line/surface color | |
# according to some logic. Each data series will et an appropriate | |
# default value. | |
self.color_func = None | |
# NOTE: color_func usually receives numerical functions that are going | |
# to be evaluated over the coordinates of the computed points (or the | |
# discretized meshes). | |
# However, if an expression is given to color_func, then it will be | |
# lambdified with symbols in self._signature, and it will be evaluated | |
# with the same data used to evaluate the plotted expression. | |
self._eval_color_func_with_signature = False | |
def _block_lambda_functions(self, *exprs): | |
"""Some data series can be used to plot numerical functions, others | |
cannot. Execute this method inside the `__init__` to prevent the | |
processing of numerical functions. | |
""" | |
if any(callable(e) for e in exprs): | |
raise TypeError(type(self).__name__ + " requires a symbolic " | |
"expression.") | |
def _check_fs(self): | |
""" Checks if there are enogh parameters and free symbols. | |
""" | |
exprs, ranges = self.expr, self.ranges | |
params, label = self.params, self.label | |
exprs = exprs if hasattr(exprs, "__iter__") else [exprs] | |
if any(callable(e) for e in exprs): | |
return | |
# from the expression's free symbols, remove the ones used in | |
# the parameters and the ranges | |
fs = _get_free_symbols(exprs) | |
fs = fs.difference(params.keys()) | |
if ranges is not None: | |
fs = fs.difference([r[0] for r in ranges]) | |
if len(fs) > 0: | |
raise ValueError( | |
"Incompatible expression and parameters.\n" | |
+ "Expression: {}\n".format( | |
(exprs, ranges, label) if ranges is not None else (exprs, label)) | |
+ "params: {}\n".format(params) | |
+ "Specify what these symbols represent: {}\n".format(fs) | |
+ "Are they ranges or parameters?" | |
) | |
# verify that all symbols are known (they either represent plotting | |
# ranges or parameters) | |
range_symbols = [r[0] for r in ranges] | |
for r in ranges: | |
fs = set().union(*[e.free_symbols for e in r[1:]]) | |
if any(t in fs for t in range_symbols): | |
# ranges can't depend on each other, for example this are | |
# not allowed: | |
# (x, 0, y), (y, 0, 3) | |
# (x, 0, y), (y, x + 2, 3) | |
raise ValueError("Range symbols can't be included into " | |
"minimum and maximum of a range. " | |
"Received range: %s" % str(r)) | |
if len(fs) > 0: | |
self._interactive_ranges = True | |
remaining_fs = fs.difference(params.keys()) | |
if len(remaining_fs) > 0: | |
raise ValueError( | |
"Unkown symbols found in plotting range: %s. " % (r,) + | |
"Are the following parameters? %s" % remaining_fs) | |
def _create_lambda_func(self): | |
"""Create the lambda functions to be used by the uniform meshing | |
strategy. | |
Notes | |
===== | |
The old sympy.plotting used experimental_lambdify. It created one | |
lambda function each time an evaluation was requested. If that failed, | |
it went on to create a different lambda function and evaluated it, | |
and so on. | |
This new module changes strategy: it creates right away the default | |
lambda function as well as the backup one. The reason is that the | |
series could be interactive, hence the numerical function will be | |
evaluated multiple times. So, let's create the functions just once. | |
This approach works fine for the majority of cases, in which the | |
symbolic expression is relatively short, hence the lambdification | |
is fast. If the expression is very long, this approach takes twice | |
the time to create the lambda functions. Be aware of that! | |
""" | |
exprs = self.expr if hasattr(self.expr, "__iter__") else [self.expr] | |
if not any(callable(e) for e in exprs): | |
fs = _get_free_symbols(exprs) | |
self._signature = sorted(fs, key=lambda t: t.name) | |
# Generate a list of lambda functions, two for each expression: | |
# 1. the default one. | |
# 2. the backup one, in case of failures with the default one. | |
self._functions = [] | |
for e in exprs: | |
# TODO: set cse=True once this issue is solved: | |
# https://github.com/sympy/sympy/issues/24246 | |
self._functions.append([ | |
lambdify(self._signature, e, modules=self.modules), | |
lambdify(self._signature, e, modules="sympy", dummify=True), | |
]) | |
else: | |
self._signature = sorted([r[0] for r in self.ranges], key=lambda t: t.name) | |
self._functions = [(e, None) for e in exprs] | |
# deal with symbolic color_func | |
if isinstance(self.color_func, Expr): | |
self.color_func = lambdify(self._signature, self.color_func) | |
self._eval_color_func_with_signature = True | |
def _update_range_value(self, t): | |
"""If the value of a plotting range is a symbolic expression, | |
substitute the parameters in order to get a numerical value. | |
""" | |
if not self._interactive_ranges: | |
return complex(t) | |
return complex(t.subs(self.params)) | |
def _create_discretized_domain(self): | |
"""Discretize the ranges for uniform meshing strategy. | |
""" | |
# NOTE: the goal is to create a dictionary stored in | |
# self._discretized_domain, mapping symbols to a numpy array | |
# representing the discretization | |
discr_symbols = [] | |
discretizations = [] | |
# create a 1D discretization | |
for i, r in enumerate(self.ranges): | |
discr_symbols.append(r[0]) | |
c_start = self._update_range_value(r[1]) | |
c_end = self._update_range_value(r[2]) | |
start = c_start.real if c_start.imag == c_end.imag == 0 else c_start | |
end = c_end.real if c_start.imag == c_end.imag == 0 else c_end | |
needs_integer_discr = self.only_integers or (r[0] in self._needs_to_be_int) | |
d = BaseSeries._discretize(start, end, self.n[i], | |
scale=self.scales[i], | |
only_integers=needs_integer_discr) | |
if ((not self._force_real_eval) and (not needs_integer_discr) and | |
(d.dtype != "complex")): | |
d = d + 1j * c_start.imag | |
if needs_integer_discr: | |
d = d.astype(int) | |
discretizations.append(d) | |
# create 2D or 3D | |
self._create_discretized_domain_helper(discr_symbols, discretizations) | |
def _create_discretized_domain_helper(self, discr_symbols, discretizations): | |
"""Create 2D or 3D discretized grids. | |
Subclasses should override this method in order to implement a | |
different behaviour. | |
""" | |
np = import_module('numpy') | |
# discretization suitable for 2D line plots, 3D surface plots, | |
# contours plots, vector plots | |
# NOTE: why indexing='ij'? Because it produces consistent results with | |
# np.mgrid. This is important as Mayavi requires this indexing | |
# to correctly compute 3D streamlines. While VTK is able to compute | |
# streamlines regardless of the indexing, with indexing='xy' it | |
# produces "strange" results with "voids" into the | |
# discretization volume. indexing='ij' solves the problem. | |
# Also note that matplotlib 2D streamlines requires indexing='xy'. | |
indexing = "xy" | |
if self.is_3Dvector or (self.is_3Dsurface and self.is_implicit): | |
indexing = "ij" | |
meshes = np.meshgrid(*discretizations, indexing=indexing) | |
self._discretized_domain = dict(zip(discr_symbols, meshes)) | |
def _evaluate(self, cast_to_real=True): | |
"""Evaluation of the symbolic expression (or expressions) with the | |
uniform meshing strategy, based on current values of the parameters. | |
""" | |
np = import_module('numpy') | |
# create lambda functions | |
if not self._functions: | |
self._create_lambda_func() | |
# create (or update) the discretized domain | |
if (not self._discretized_domain) or self._interactive_ranges: | |
self._create_discretized_domain() | |
# ensure that discretized domains are returned with the proper order | |
discr = [self._discretized_domain[s[0]] for s in self.ranges] | |
args = self._aggregate_args() | |
results = [] | |
for f in self._functions: | |
r = _uniform_eval(*f, *args) | |
# the evaluation might produce an int/float. Need this correction. | |
r = self._correct_shape(np.array(r), discr[0]) | |
# sometime the evaluation is performed over arrays of type object. | |
# hence, `result` might be of type object, which don't work well | |
# with numpy real and imag functions. | |
r = r.astype(complex) | |
results.append(r) | |
if cast_to_real: | |
discr = [np.real(d.astype(complex)) for d in discr] | |
return [*discr, *results] | |
def _aggregate_args(self): | |
"""Create a list of arguments to be passed to the lambda function, | |
sorted accoring to self._signature. | |
""" | |
args = [] | |
for s in self._signature: | |
if s in self._params.keys(): | |
args.append( | |
int(self._params[s]) if s in self._needs_to_be_int else | |
self._params[s] if self._force_real_eval | |
else complex(self._params[s])) | |
else: | |
args.append(self._discretized_domain[s]) | |
return args | |
def expr(self): | |
"""Return the expression (or expressions) of the series.""" | |
return self._expr | |
def expr(self, e): | |
"""Set the expression (or expressions) of the series.""" | |
is_iter = hasattr(e, "__iter__") | |
is_callable = callable(e) if not is_iter else any(callable(t) for t in e) | |
if is_callable: | |
self._expr = e | |
else: | |
self._expr = sympify(e) if not is_iter else Tuple(*e) | |
# look for the upper bound of summations and products | |
s = set() | |
for e in self._expr.atoms(Sum, Product): | |
for a in e.args[1:]: | |
if isinstance(a[-1], Symbol): | |
s.add(a[-1]) | |
self._needs_to_be_int = list(s) | |
# list of sympy functions that when lambdified, the corresponding | |
# numpy functions don't like complex-type arguments | |
pf = [ceiling, floor, atan2, frac, zeta] | |
if self._force_real_eval is not True: | |
check_res = [self._expr.has(f) for f in pf] | |
self._force_real_eval = any(check_res) | |
if self._force_real_eval and ((self.modules is None) or | |
(isinstance(self.modules, str) and "numpy" in self.modules)): | |
funcs = [f for f, c in zip(pf, check_res) if c] | |
warnings.warn("NumPy is unable to evaluate with complex " | |
"numbers some of the functions included in this " | |
"symbolic expression: %s. " % funcs + | |
"Hence, the evaluation will use real numbers. " | |
"If you believe the resulting plot is incorrect, " | |
"change the evaluation module by setting the " | |
"`modules` keyword argument.") | |
if self._functions: | |
# update lambda functions | |
self._create_lambda_func() | |
def is_3D(self): | |
flags3D = [self.is_3Dline, self.is_3Dsurface, self.is_3Dvector] | |
return any(flags3D) | |
def is_line(self): | |
flagslines = [self.is_2Dline, self.is_3Dline] | |
return any(flagslines) | |
def _line_surface_color(self, prop, val): | |
"""This method enables back-compatibility with old sympy.plotting""" | |
# NOTE: color_func is set inside the init method of the series. | |
# If line_color/surface_color is not a callable, then color_func will | |
# be set to None. | |
setattr(self, prop, val) | |
if callable(val) or isinstance(val, Expr): | |
self.color_func = val | |
setattr(self, prop, None) | |
elif val is not None: | |
self.color_func = None | |
def line_color(self): | |
return self._line_color | |
def line_color(self, val): | |
self._line_surface_color("_line_color", val) | |
def n(self): | |
"""Returns a list [n1, n2, n3] of numbers of discratization points. | |
""" | |
return self._n | |
def n(self, v): | |
"""Set the numbers of discretization points. ``v`` must be an int or | |
a list. | |
Let ``s`` be a series. Then: | |
* to set the number of discretization points along the x direction (or | |
first parameter): ``s.n = 10`` | |
* to set the number of discretization points along the x and y | |
directions (or first and second parameters): ``s.n = [10, 15]`` | |
* to set the number of discretization points along the x, y and z | |
directions: ``s.n = [10, 15, 20]`` | |
The following is highly unreccomended, because it prevents | |
the execution of necessary code in order to keep updated data: | |
``s.n[1] = 15`` | |
""" | |
if not hasattr(v, "__iter__"): | |
self._n[0] = v | |
else: | |
self._n[:len(v)] = v | |
if self._discretized_domain: | |
# update the discretized domain | |
self._create_discretized_domain() | |
def params(self): | |
"""Get or set the current parameters dictionary. | |
Parameters | |
========== | |
p : dict | |
* key: symbol associated to the parameter | |
* val: the numeric value | |
""" | |
return self._params | |
def params(self, p): | |
self._params = p | |
def _post_init(self): | |
exprs = self.expr if hasattr(self.expr, "__iter__") else [self.expr] | |
if any(callable(e) for e in exprs) and self.params: | |
raise TypeError("`params` was provided, hence an interactive plot " | |
"is expected. However, interactive plots do not support " | |
"user-provided numerical functions.") | |
# if the expressions is a lambda function and no label has been | |
# provided, then its better to do the following in order to avoid | |
# suprises on the backend | |
if any(callable(e) for e in exprs): | |
if self._label == str(self.expr): | |
self.label = "" | |
self._check_fs() | |
if hasattr(self, "adaptive") and self.adaptive and self.params: | |
warnings.warn("`params` was provided, hence an interactive plot " | |
"is expected. However, interactive plots do not support " | |
"adaptive evaluation. Automatically switched to " | |
"adaptive=False.") | |
self.adaptive = False | |
def scales(self): | |
return self._scales | |
def scales(self, v): | |
if isinstance(v, str): | |
self._scales[0] = v | |
else: | |
self._scales[:len(v)] = v | |
def surface_color(self): | |
return self._surface_color | |
def surface_color(self, val): | |
self._line_surface_color("_surface_color", val) | |
def rendering_kw(self): | |
return self._rendering_kw | |
def rendering_kw(self, kwargs): | |
if isinstance(kwargs, dict): | |
self._rendering_kw = kwargs | |
else: | |
self._rendering_kw = {} | |
if kwargs is not None: | |
warnings.warn( | |
"`rendering_kw` must be a dictionary, instead an " | |
"object of type %s was received. " % type(kwargs) + | |
"Automatically setting `rendering_kw` to an empty " | |
"dictionary") | |
def _discretize(start, end, N, scale="linear", only_integers=False): | |
"""Discretize a 1D domain. | |
Returns | |
======= | |
domain : np.ndarray with dtype=float or complex | |
The domain's dtype will be float or complex (depending on the | |
type of start/end) even if only_integers=True. It is left for | |
the downstream code to perform further casting, if necessary. | |
""" | |
np = import_module('numpy') | |
if only_integers is True: | |
start, end = int(start), int(end) | |
N = end - start + 1 | |
if scale == "linear": | |
return np.linspace(start, end, N) | |
return np.geomspace(start, end, N) | |
def _correct_shape(a, b): | |
"""Convert ``a`` to a np.ndarray of the same shape of ``b``. | |
Parameters | |
========== | |
a : int, float, complex, np.ndarray | |
Usually, this is the result of a numerical evaluation of a | |
symbolic expression. Even if a discretized domain was used to | |
evaluate the function, the result can be a scalar (int, float, | |
complex). Think for example to ``expr = Float(2)`` and | |
``f = lambdify(x, expr)``. No matter the shape of the numerical | |
array representing x, the result of the evaluation will be | |
a single value. | |
b : np.ndarray | |
It represents the correct shape that ``a`` should have. | |
Returns | |
======= | |
new_a : np.ndarray | |
An array with the correct shape. | |
""" | |
np = import_module('numpy') | |
if not isinstance(a, np.ndarray): | |
a = np.array(a) | |
if a.shape != b.shape: | |
if a.shape == (): | |
a = a * np.ones_like(b) | |
else: | |
a = a.reshape(b.shape) | |
return a | |
def eval_color_func(self, *args): | |
"""Evaluate the color function. | |
Parameters | |
========== | |
args : tuple | |
Arguments to be passed to the coloring function. Can be coordinates | |
or parameters or both. | |
Notes | |
===== | |
The backend will request the data series to generate the numerical | |
data. Depending on the data series, either the data series itself or | |
the backend will eventually execute this function to generate the | |
appropriate coloring value. | |
""" | |
np = import_module('numpy') | |
if self.color_func is None: | |
# NOTE: with the line_color and surface_color attributes | |
# (back-compatibility with the old sympy.plotting module) it is | |
# possible to create a plot with a callable line_color (or | |
# surface_color). For example: | |
# p = plot(sin(x), line_color=lambda x, y: -y) | |
# This creates a ColoredLineOver1DRangeSeries with line_color=None | |
# and color_func=lambda x, y: -y, which efffectively is a | |
# parametric series. Later we could change it to a string value: | |
# p[0].line_color = "red" | |
# However, this sets ine_color="red" and color_func=None, but the | |
# series is still ColoredLineOver1DRangeSeries (a parametric | |
# series), which will render using a color_func... | |
warnings.warn("This is likely not the result you were " | |
"looking for. Please, re-execute the plot command, this time " | |
"with the appropriate an appropriate value to line_color " | |
"or surface_color.") | |
return np.ones_like(args[0]) | |
if self._eval_color_func_with_signature: | |
args = self._aggregate_args() | |
color = self.color_func(*args) | |
_re, _im = np.real(color), np.imag(color) | |
_re[np.invert(np.isclose(_im, np.zeros_like(_im)))] = np.nan | |
return _re | |
nargs = arity(self.color_func) | |
if nargs == 1: | |
if self.is_2Dline and self.is_parametric: | |
if len(args) == 2: | |
# ColoredLineOver1DRangeSeries | |
return self._correct_shape(self.color_func(args[0]), args[0]) | |
# Parametric2DLineSeries | |
return self._correct_shape(self.color_func(args[2]), args[2]) | |
elif self.is_3Dline and self.is_parametric: | |
return self._correct_shape(self.color_func(args[3]), args[3]) | |
elif self.is_3Dsurface and self.is_parametric: | |
return self._correct_shape(self.color_func(args[3]), args[3]) | |
return self._correct_shape(self.color_func(args[0]), args[0]) | |
elif nargs == 2: | |
if self.is_3Dsurface and self.is_parametric: | |
return self._correct_shape(self.color_func(*args[3:]), args[3]) | |
return self._correct_shape(self.color_func(*args[:2]), args[0]) | |
return self._correct_shape(self.color_func(*args[:nargs]), args[0]) | |
def get_data(self): | |
"""Compute and returns the numerical data. | |
The number of parameters returned by this method depends on the | |
specific instance. If ``s`` is the series, make sure to read | |
``help(s.get_data)`` to understand what it returns. | |
""" | |
raise NotImplementedError | |
def _get_wrapped_label(self, label, wrapper): | |
"""Given a latex representation of an expression, wrap it inside | |
some characters. Matplotlib needs "$%s%$", K3D-Jupyter needs "%s". | |
""" | |
return wrapper % label | |
def get_label(self, use_latex=False, wrapper="$%s$"): | |
"""Return the label to be used to display the expression. | |
Parameters | |
========== | |
use_latex : bool | |
If False, the string representation of the expression is returned. | |
If True, the latex representation is returned. | |
wrapper : str | |
The backend might need the latex representation to be wrapped by | |
some characters. Default to ``"$%s$"``. | |
Returns | |
======= | |
label : str | |
""" | |
if use_latex is False: | |
return self._label | |
if self._label == str(self.expr): | |
# when the backend requests a latex label and user didn't provide | |
# any label | |
return self._get_wrapped_label(self._latex_label, wrapper) | |
return self._latex_label | |
def label(self): | |
return self.get_label() | |
def label(self, val): | |
"""Set the labels associated to this series.""" | |
# NOTE: the init method of any series requires a label. If the user do | |
# not provide it, the preprocessing function will set label=None, which | |
# informs the series to initialize two attributes: | |
# _label contains the string representation of the expression. | |
# _latex_label contains the latex representation of the expression. | |
self._label = self._latex_label = val | |
def ranges(self): | |
return self._ranges | |
def ranges(self, val): | |
new_vals = [] | |
for v in val: | |
if v is not None: | |
new_vals.append(tuple([sympify(t) for t in v])) | |
self._ranges = new_vals | |
def _apply_transform(self, *args): | |
"""Apply transformations to the results of numerical evaluation. | |
Parameters | |
========== | |
args : tuple | |
Results of numerical evaluation. | |
Returns | |
======= | |
transformed_args : tuple | |
Tuple containing the transformed results. | |
""" | |
t = lambda x, transform: x if transform is None else transform(x) | |
x, y, z = None, None, None | |
if len(args) == 2: | |
x, y = args | |
return t(x, self._tx), t(y, self._ty) | |
elif (len(args) == 3) and isinstance(self, Parametric2DLineSeries): | |
x, y, u = args | |
return (t(x, self._tx), t(y, self._ty), t(u, self._tp)) | |
elif len(args) == 3: | |
x, y, z = args | |
return t(x, self._tx), t(y, self._ty), t(z, self._tz) | |
elif (len(args) == 4) and isinstance(self, Parametric3DLineSeries): | |
x, y, z, u = args | |
return (t(x, self._tx), t(y, self._ty), t(z, self._tz), t(u, self._tp)) | |
elif len(args) == 4: # 2D vector plot | |
x, y, u, v = args | |
return ( | |
t(x, self._tx), t(y, self._ty), | |
t(u, self._tx), t(v, self._ty) | |
) | |
elif (len(args) == 5) and isinstance(self, ParametricSurfaceSeries): | |
x, y, z, u, v = args | |
return (t(x, self._tx), t(y, self._ty), t(z, self._tz), u, v) | |
elif (len(args) == 6) and self.is_3Dvector: # 3D vector plot | |
x, y, z, u, v, w = args | |
return ( | |
t(x, self._tx), t(y, self._ty), t(z, self._tz), | |
t(u, self._tx), t(v, self._ty), t(w, self._tz) | |
) | |
elif len(args) == 6: # complex plot | |
x, y, _abs, _arg, img, colors = args | |
return ( | |
x, y, t(_abs, self._tz), _arg, img, colors) | |
return args | |
def _str_helper(self, s): | |
pre, post = "", "" | |
if self.is_interactive: | |
pre = "interactive " | |
post = " and parameters " + str(tuple(self.params.keys())) | |
return pre + s + post | |
def _detect_poles_numerical_helper(x, y, eps=0.01, expr=None, symb=None, symbolic=False): | |
"""Compute the steepness of each segment. If it's greater than a | |
threshold, set the right-point y-value non NaN and record the | |
corresponding x-location for further processing. | |
Returns | |
======= | |
x : np.ndarray | |
Unchanged x-data. | |
yy : np.ndarray | |
Modified y-data with NaN values. | |
""" | |
np = import_module('numpy') | |
yy = y.copy() | |
threshold = np.pi / 2 - eps | |
for i in range(len(x) - 1): | |
dx = x[i + 1] - x[i] | |
dy = abs(y[i + 1] - y[i]) | |
angle = np.arctan(dy / dx) | |
if abs(angle) >= threshold: | |
yy[i + 1] = np.nan | |
return x, yy | |
def _detect_poles_symbolic_helper(expr, symb, start, end): | |
"""Attempts to compute symbolic discontinuities. | |
Returns | |
======= | |
pole : list | |
List of symbolic poles, possibily empty. | |
""" | |
poles = [] | |
interval = Interval(nsimplify(start), nsimplify(end)) | |
res = continuous_domain(expr, symb, interval) | |
res = res.simplify() | |
if res == interval: | |
pass | |
elif (isinstance(res, Union) and | |
all(isinstance(t, Interval) for t in res.args)): | |
poles = [] | |
for s in res.args: | |
if s.left_open: | |
poles.append(s.left) | |
if s.right_open: | |
poles.append(s.right) | |
poles = list(set(poles)) | |
else: | |
raise ValueError( | |
f"Could not parse the following object: {res} .\n" | |
"Please, submit this as a bug. Consider also to set " | |
"`detect_poles=True`." | |
) | |
return poles | |
### 2D lines | |
class Line2DBaseSeries(BaseSeries): | |
"""A base class for 2D lines. | |
- adding the label, steps and only_integers options | |
- making is_2Dline true | |
- defining get_segments and get_color_array | |
""" | |
is_2Dline = True | |
_dim = 2 | |
_N = 1000 | |
def __init__(self, **kwargs): | |
super().__init__(**kwargs) | |
self.steps = kwargs.get("steps", False) | |
self.is_point = kwargs.get("is_point", kwargs.get("point", False)) | |
self.is_filled = kwargs.get("is_filled", kwargs.get("fill", True)) | |
self.adaptive = kwargs.get("adaptive", False) | |
self.depth = kwargs.get('depth', 12) | |
self.use_cm = kwargs.get("use_cm", False) | |
self.color_func = kwargs.get("color_func", None) | |
self.line_color = kwargs.get("line_color", None) | |
self.detect_poles = kwargs.get("detect_poles", False) | |
self.eps = kwargs.get("eps", 0.01) | |
self.is_polar = kwargs.get("is_polar", kwargs.get("polar", False)) | |
self.unwrap = kwargs.get("unwrap", False) | |
# when detect_poles="symbolic", stores the location of poles so that | |
# they can be appropriately rendered | |
self.poles_locations = [] | |
exclude = kwargs.get("exclude", []) | |
if isinstance(exclude, Set): | |
exclude = list(extract_solution(exclude, n=100)) | |
if not hasattr(exclude, "__iter__"): | |
exclude = [exclude] | |
exclude = [float(e) for e in exclude] | |
self.exclude = sorted(exclude) | |
def get_data(self): | |
"""Return coordinates for plotting the line. | |
Returns | |
======= | |
x: np.ndarray | |
x-coordinates | |
y: np.ndarray | |
y-coordinates | |
z: np.ndarray (optional) | |
z-coordinates in case of Parametric3DLineSeries, | |
Parametric3DLineInteractiveSeries | |
param : np.ndarray (optional) | |
The parameter in case of Parametric2DLineSeries, | |
Parametric3DLineSeries or AbsArgLineSeries (and their | |
corresponding interactive series). | |
""" | |
np = import_module('numpy') | |
points = self._get_data_helper() | |
if (isinstance(self, LineOver1DRangeSeries) and | |
(self.detect_poles == "symbolic")): | |
poles = _detect_poles_symbolic_helper( | |
self.expr.subs(self.params), *self.ranges[0]) | |
poles = np.array([float(t) for t in poles]) | |
t = lambda x, transform: x if transform is None else transform(x) | |
self.poles_locations = t(np.array(poles), self._tx) | |
# postprocessing | |
points = self._apply_transform(*points) | |
if self.is_2Dline and self.detect_poles: | |
if len(points) == 2: | |
x, y = points | |
x, y = _detect_poles_numerical_helper( | |
x, y, self.eps) | |
points = (x, y) | |
else: | |
x, y, p = points | |
x, y = _detect_poles_numerical_helper(x, y, self.eps) | |
points = (x, y, p) | |
if self.unwrap: | |
kw = {} | |
if self.unwrap is not True: | |
kw = self.unwrap | |
if self.is_2Dline: | |
if len(points) == 2: | |
x, y = points | |
y = np.unwrap(y, **kw) | |
points = (x, y) | |
else: | |
x, y, p = points | |
y = np.unwrap(y, **kw) | |
points = (x, y, p) | |
if self.steps is True: | |
if self.is_2Dline: | |
x, y = points[0], points[1] | |
x = np.array((x, x)).T.flatten()[1:] | |
y = np.array((y, y)).T.flatten()[:-1] | |
if self.is_parametric: | |
points = (x, y, points[2]) | |
else: | |
points = (x, y) | |
elif self.is_3Dline: | |
x = np.repeat(points[0], 3)[2:] | |
y = np.repeat(points[1], 3)[:-2] | |
z = np.repeat(points[2], 3)[1:-1] | |
if len(points) > 3: | |
points = (x, y, z, points[3]) | |
else: | |
points = (x, y, z) | |
if len(self.exclude) > 0: | |
points = self._insert_exclusions(points) | |
return points | |
def get_segments(self): | |
sympy_deprecation_warning( | |
""" | |
The Line2DBaseSeries.get_segments() method is deprecated. | |
Instead, use the MatplotlibBackend.get_segments() method, or use | |
The get_points() or get_data() methods. | |
""", | |
deprecated_since_version="1.9", | |
active_deprecations_target="deprecated-get-segments") | |
np = import_module('numpy') | |
points = type(self).get_data(self) | |
points = np.ma.array(points).T.reshape(-1, 1, self._dim) | |
return np.ma.concatenate([points[:-1], points[1:]], axis=1) | |
def _insert_exclusions(self, points): | |
"""Add NaN to each of the exclusion point. Practically, this adds a | |
NaN to the exlusion point, plus two other nearby points evaluated with | |
the numerical functions associated to this data series. | |
These nearby points are important when the number of discretization | |
points is low, or the scale is logarithm. | |
NOTE: it would be easier to just add exclusion points to the | |
discretized domain before evaluation, then after evaluation add NaN | |
to the exclusion points. But that's only work with adaptive=False. | |
The following approach work even with adaptive=True. | |
""" | |
np = import_module("numpy") | |
points = list(points) | |
n = len(points) | |
# index of the x-coordinate (for 2d plots) or parameter (for 2d/3d | |
# parametric plots) | |
k = n - 1 | |
if n == 2: | |
k = 0 | |
# indeces of the other coordinates | |
j_indeces = sorted(set(range(n)).difference([k])) | |
# TODO: for now, I assume that numpy functions are going to succeed | |
funcs = [f[0] for f in self._functions] | |
for e in self.exclude: | |
res = points[k] - e >= 0 | |
# if res contains both True and False, ie, if e is found | |
if any(res) and any(~res): | |
idx = np.nanargmax(res) | |
# select the previous point with respect to e | |
idx -= 1 | |
# TODO: what if points[k][idx]==e or points[k][idx+1]==e? | |
if idx > 0 and idx < len(points[k]) - 1: | |
delta_prev = abs(e - points[k][idx]) | |
delta_post = abs(e - points[k][idx + 1]) | |
delta = min(delta_prev, delta_post) / 100 | |
prev = e - delta | |
post = e + delta | |
# add points to the x-coord or the parameter | |
points[k] = np.concatenate( | |
(points[k][:idx], [prev, e, post], points[k][idx+1:])) | |
# add points to the other coordinates | |
c = 0 | |
for j in j_indeces: | |
values = funcs[c](np.array([prev, post])) | |
c += 1 | |
points[j] = np.concatenate( | |
(points[j][:idx], [values[0], np.nan, values[1]], points[j][idx+1:])) | |
return points | |
def var(self): | |
return None if not self.ranges else self.ranges[0][0] | |
def start(self): | |
if not self.ranges: | |
return None | |
try: | |
return self._cast(self.ranges[0][1]) | |
except TypeError: | |
return self.ranges[0][1] | |
def end(self): | |
if not self.ranges: | |
return None | |
try: | |
return self._cast(self.ranges[0][2]) | |
except TypeError: | |
return self.ranges[0][2] | |
def xscale(self): | |
return self._scales[0] | |
def xscale(self, v): | |
self.scales = v | |
def get_color_array(self): | |
np = import_module('numpy') | |
c = self.line_color | |
if hasattr(c, '__call__'): | |
f = np.vectorize(c) | |
nargs = arity(c) | |
if nargs == 1 and self.is_parametric: | |
x = self.get_parameter_points() | |
return f(centers_of_segments(x)) | |
else: | |
variables = list(map(centers_of_segments, self.get_points())) | |
if nargs == 1: | |
return f(variables[0]) | |
elif nargs == 2: | |
return f(*variables[:2]) | |
else: # only if the line is 3D (otherwise raises an error) | |
return f(*variables) | |
else: | |
return c*np.ones(self.nb_of_points) | |
class List2DSeries(Line2DBaseSeries): | |
"""Representation for a line consisting of list of points.""" | |
def __init__(self, list_x, list_y, label="", **kwargs): | |
super().__init__(**kwargs) | |
np = import_module('numpy') | |
if len(list_x) != len(list_y): | |
raise ValueError( | |
"The two lists of coordinates must have the same " | |
"number of elements.\n" | |
"Received: len(list_x) = {} ".format(len(list_x)) + | |
"and len(list_y) = {}".format(len(list_y)) | |
) | |
self._block_lambda_functions(list_x, list_y) | |
check = lambda l: [isinstance(t, Expr) and (not t.is_number) for t in l] | |
if any(check(list_x) + check(list_y)) or self.params: | |
if not self.params: | |
raise ValueError("Some or all elements of the provided lists " | |
"are symbolic expressions, but the ``params`` dictionary " | |
"was not provided: those elements can't be evaluated.") | |
self.list_x = Tuple(*list_x) | |
self.list_y = Tuple(*list_y) | |
else: | |
self.list_x = np.array(list_x, dtype=np.float64) | |
self.list_y = np.array(list_y, dtype=np.float64) | |
self._expr = (self.list_x, self.list_y) | |
if not any(isinstance(t, np.ndarray) for t in [self.list_x, self.list_y]): | |
self._check_fs() | |
self.is_polar = kwargs.get("is_polar", kwargs.get("polar", False)) | |
self.label = label | |
self.rendering_kw = kwargs.get("rendering_kw", {}) | |
if self.use_cm and self.color_func: | |
self.is_parametric = True | |
if isinstance(self.color_func, Expr): | |
raise TypeError( | |
"%s don't support symbolic " % self.__class__.__name__ + | |
"expression for `color_func`.") | |
def __str__(self): | |
return "2D list plot" | |
def _get_data_helper(self): | |
"""Returns coordinates that needs to be postprocessed.""" | |
lx, ly = self.list_x, self.list_y | |
if not self.is_interactive: | |
return self._eval_color_func_and_return(lx, ly) | |
np = import_module('numpy') | |
lx = np.array([t.evalf(subs=self.params) for t in lx], dtype=float) | |
ly = np.array([t.evalf(subs=self.params) for t in ly], dtype=float) | |
return self._eval_color_func_and_return(lx, ly) | |
def _eval_color_func_and_return(self, *data): | |
if self.use_cm and callable(self.color_func): | |
return [*data, self.eval_color_func(*data)] | |
return data | |
class LineOver1DRangeSeries(Line2DBaseSeries): | |
"""Representation for a line consisting of a SymPy expression over a range.""" | |
def __init__(self, expr, var_start_end, label="", **kwargs): | |
super().__init__(**kwargs) | |
self.expr = expr if callable(expr) else sympify(expr) | |
self._label = str(self.expr) if label is None else label | |
self._latex_label = latex(self.expr) if label is None else label | |
self.ranges = [var_start_end] | |
self._cast = complex | |
# for complex-related data series, this determines what data to return | |
# on the y-axis | |
self._return = kwargs.get("return", None) | |
self._post_init() | |
if not self._interactive_ranges: | |
# NOTE: the following check is only possible when the minimum and | |
# maximum values of a plotting range are numeric | |
start, end = [complex(t) for t in self.ranges[0][1:]] | |
if im(start) != im(end): | |
raise ValueError( | |
"%s requires the imaginary " % self.__class__.__name__ + | |
"part of the start and end values of the range " | |
"to be the same.") | |
if self.adaptive and self._return: | |
warnings.warn("The adaptive algorithm is unable to deal with " | |
"complex numbers. Automatically switching to uniform meshing.") | |
self.adaptive = False | |
def nb_of_points(self): | |
return self.n[0] | |
def nb_of_points(self, v): | |
self.n = v | |
def __str__(self): | |
def f(t): | |
if isinstance(t, complex): | |
if t.imag != 0: | |
return t | |
return t.real | |
return t | |
pre = "interactive " if self.is_interactive else "" | |
post = "" | |
if self.is_interactive: | |
post = " and parameters " + str(tuple(self.params.keys())) | |
wrapper = _get_wrapper_for_expr(self._return) | |
return pre + "cartesian line: %s for %s over %s" % ( | |
wrapper % self.expr, | |
str(self.var), | |
str((f(self.start), f(self.end))), | |
) + post | |
def get_points(self): | |
"""Return lists of coordinates for plotting. Depending on the | |
``adaptive`` option, this function will either use an adaptive algorithm | |
or it will uniformly sample the expression over the provided range. | |
This function is available for back-compatibility purposes. Consider | |
using ``get_data()`` instead. | |
Returns | |
======= | |
x : list | |
List of x-coordinates | |
y : list | |
List of y-coordinates | |
""" | |
return self._get_data_helper() | |
def _adaptive_sampling(self): | |
try: | |
if callable(self.expr): | |
f = self.expr | |
else: | |
f = lambdify([self.var], self.expr, self.modules) | |
x, y = self._adaptive_sampling_helper(f) | |
except Exception as err: | |
warnings.warn( | |
"The evaluation with %s failed.\n" % ( | |
"NumPy/SciPy" if not self.modules else self.modules) + | |
"{}: {}\n".format(type(err).__name__, err) + | |
"Trying to evaluate the expression with Sympy, but it might " | |
"be a slow operation." | |
) | |
f = lambdify([self.var], self.expr, "sympy") | |
x, y = self._adaptive_sampling_helper(f) | |
return x, y | |
def _adaptive_sampling_helper(self, f): | |
"""The adaptive sampling is done by recursively checking if three | |
points are almost collinear. If they are not collinear, then more | |
points are added between those points. | |
References | |
========== | |
.. [1] Adaptive polygonal approximation of parametric curves, | |
Luiz Henrique de Figueiredo. | |
""" | |
np = import_module('numpy') | |
x_coords = [] | |
y_coords = [] | |
def sample(p, q, depth): | |
""" Samples recursively if three points are almost collinear. | |
For depth < 6, points are added irrespective of whether they | |
satisfy the collinearity condition or not. The maximum depth | |
allowed is 12. | |
""" | |
# Randomly sample to avoid aliasing. | |
random = 0.45 + np.random.rand() * 0.1 | |
if self.xscale == 'log': | |
xnew = 10**(np.log10(p[0]) + random * (np.log10(q[0]) - | |
np.log10(p[0]))) | |
else: | |
xnew = p[0] + random * (q[0] - p[0]) | |
ynew = _adaptive_eval(f, xnew) | |
new_point = np.array([xnew, ynew]) | |
# Maximum depth | |
if depth > self.depth: | |
x_coords.append(q[0]) | |
y_coords.append(q[1]) | |
# Sample to depth of 6 (whether the line is flat or not) | |
# without using linspace (to avoid aliasing). | |
elif depth < 6: | |
sample(p, new_point, depth + 1) | |
sample(new_point, q, depth + 1) | |
# Sample ten points if complex values are encountered | |
# at both ends. If there is a real value in between, then | |
# sample those points further. | |
elif p[1] is None and q[1] is None: | |
if self.xscale == 'log': | |
xarray = np.logspace(p[0], q[0], 10) | |
else: | |
xarray = np.linspace(p[0], q[0], 10) | |
yarray = list(map(f, xarray)) | |
if not all(y is None for y in yarray): | |
for i in range(len(yarray) - 1): | |
if not (yarray[i] is None and yarray[i + 1] is None): | |
sample([xarray[i], yarray[i]], | |
[xarray[i + 1], yarray[i + 1]], depth + 1) | |
# Sample further if one of the end points in None (i.e. a | |
# complex value) or the three points are not almost collinear. | |
elif (p[1] is None or q[1] is None or new_point[1] is None | |
or not flat(p, new_point, q)): | |
sample(p, new_point, depth + 1) | |
sample(new_point, q, depth + 1) | |
else: | |
x_coords.append(q[0]) | |
y_coords.append(q[1]) | |
f_start = _adaptive_eval(f, self.start.real) | |
f_end = _adaptive_eval(f, self.end.real) | |
x_coords.append(self.start.real) | |
y_coords.append(f_start) | |
sample(np.array([self.start.real, f_start]), | |
np.array([self.end.real, f_end]), 0) | |
return (x_coords, y_coords) | |
def _uniform_sampling(self): | |
np = import_module('numpy') | |
x, result = self._evaluate() | |
_re, _im = np.real(result), np.imag(result) | |
_re = self._correct_shape(_re, x) | |
_im = self._correct_shape(_im, x) | |
return x, _re, _im | |
def _get_data_helper(self): | |
"""Returns coordinates that needs to be postprocessed. | |
""" | |
np = import_module('numpy') | |
if self.adaptive and (not self.only_integers): | |
x, y = self._adaptive_sampling() | |
return [np.array(t) for t in [x, y]] | |
x, _re, _im = self._uniform_sampling() | |
if self._return is None: | |
# The evaluation could produce complex numbers. Set real elements | |
# to NaN where there are non-zero imaginary elements | |
_re[np.invert(np.isclose(_im, np.zeros_like(_im)))] = np.nan | |
elif self._return == "real": | |
pass | |
elif self._return == "imag": | |
_re = _im | |
elif self._return == "abs": | |
_re = np.sqrt(_re**2 + _im**2) | |
elif self._return == "arg": | |
_re = np.arctan2(_im, _re) | |
else: | |
raise ValueError("`_return` not recognized. " | |
"Received: %s" % self._return) | |
return x, _re | |
class ParametricLineBaseSeries(Line2DBaseSeries): | |
is_parametric = True | |
def _set_parametric_line_label(self, label): | |
"""Logic to set the correct label to be shown on the plot. | |
If `use_cm=True` there will be a colorbar, so we show the parameter. | |
If `use_cm=False`, there might be a legend, so we show the expressions. | |
Parameters | |
========== | |
label : str | |
label passed in by the pre-processor or the user | |
""" | |
self._label = str(self.var) if label is None else label | |
self._latex_label = latex(self.var) if label is None else label | |
if (self.use_cm is False) and (self._label == str(self.var)): | |
self._label = str(self.expr) | |
self._latex_label = latex(self.expr) | |
# if the expressions is a lambda function and use_cm=False and no label | |
# has been provided, then its better to do the following in order to | |
# avoid suprises on the backend | |
if any(callable(e) for e in self.expr) and (not self.use_cm): | |
if self._label == str(self.expr): | |
self._label = "" | |
def get_label(self, use_latex=False, wrapper="$%s$"): | |
# parametric lines returns the representation of the parameter to be | |
# shown on the colorbar if `use_cm=True`, otherwise it returns the | |
# representation of the expression to be placed on the legend. | |
if self.use_cm: | |
if str(self.var) == self._label: | |
if use_latex: | |
return self._get_wrapped_label(latex(self.var), wrapper) | |
return str(self.var) | |
# here the user has provided a custom label | |
return self._label | |
if use_latex: | |
if self._label != str(self.expr): | |
return self._latex_label | |
return self._get_wrapped_label(self._latex_label, wrapper) | |
return self._label | |
def _get_data_helper(self): | |
"""Returns coordinates that needs to be postprocessed. | |
Depending on the `adaptive` option, this function will either use an | |
adaptive algorithm or it will uniformly sample the expression over the | |
provided range. | |
""" | |
if self.adaptive: | |
np = import_module("numpy") | |
coords = self._adaptive_sampling() | |
coords = [np.array(t) for t in coords] | |
else: | |
coords = self._uniform_sampling() | |
if self.is_2Dline and self.is_polar: | |
# when plot_polar is executed with polar_axis=True | |
np = import_module('numpy') | |
x, y, _ = coords | |
r = np.sqrt(x**2 + y**2) | |
t = np.arctan2(y, x) | |
coords = [t, r, coords[-1]] | |
if callable(self.color_func): | |
coords = list(coords) | |
coords[-1] = self.eval_color_func(*coords) | |
return coords | |
def _uniform_sampling(self): | |
"""Returns coordinates that needs to be postprocessed.""" | |
np = import_module('numpy') | |
results = self._evaluate() | |
for i, r in enumerate(results): | |
_re, _im = np.real(r), np.imag(r) | |
_re[np.invert(np.isclose(_im, np.zeros_like(_im)))] = np.nan | |
results[i] = _re | |
return [*results[1:], results[0]] | |
def get_parameter_points(self): | |
return self.get_data()[-1] | |
def get_points(self): | |
""" Return lists of coordinates for plotting. Depending on the | |
``adaptive`` option, this function will either use an adaptive algorithm | |
or it will uniformly sample the expression over the provided range. | |
This function is available for back-compatibility purposes. Consider | |
using ``get_data()`` instead. | |
Returns | |
======= | |
x : list | |
List of x-coordinates | |
y : list | |
List of y-coordinates | |
z : list | |
List of z-coordinates, only for 3D parametric line plot. | |
""" | |
return self._get_data_helper()[:-1] | |
def nb_of_points(self): | |
return self.n[0] | |
def nb_of_points(self, v): | |
self.n = v | |
class Parametric2DLineSeries(ParametricLineBaseSeries): | |
"""Representation for a line consisting of two parametric SymPy expressions | |
over a range.""" | |
is_2Dline = True | |
def __init__(self, expr_x, expr_y, var_start_end, label="", **kwargs): | |
super().__init__(**kwargs) | |
self.expr_x = expr_x if callable(expr_x) else sympify(expr_x) | |
self.expr_y = expr_y if callable(expr_y) else sympify(expr_y) | |
self.expr = (self.expr_x, self.expr_y) | |
self.ranges = [var_start_end] | |
self._cast = float | |
self.use_cm = kwargs.get("use_cm", True) | |
self._set_parametric_line_label(label) | |
self._post_init() | |
def __str__(self): | |
return self._str_helper( | |
"parametric cartesian line: (%s, %s) for %s over %s" % ( | |
str(self.expr_x), | |
str(self.expr_y), | |
str(self.var), | |
str((self.start, self.end)) | |
)) | |
def _adaptive_sampling(self): | |
try: | |
if callable(self.expr_x) and callable(self.expr_y): | |
f_x = self.expr_x | |
f_y = self.expr_y | |
else: | |
f_x = lambdify([self.var], self.expr_x) | |
f_y = lambdify([self.var], self.expr_y) | |
x, y, p = self._adaptive_sampling_helper(f_x, f_y) | |
except Exception as err: | |
warnings.warn( | |
"The evaluation with %s failed.\n" % ( | |
"NumPy/SciPy" if not self.modules else self.modules) + | |
"{}: {}\n".format(type(err).__name__, err) + | |
"Trying to evaluate the expression with Sympy, but it might " | |
"be a slow operation." | |
) | |
f_x = lambdify([self.var], self.expr_x, "sympy") | |
f_y = lambdify([self.var], self.expr_y, "sympy") | |
x, y, p = self._adaptive_sampling_helper(f_x, f_y) | |
return x, y, p | |
def _adaptive_sampling_helper(self, f_x, f_y): | |
"""The adaptive sampling is done by recursively checking if three | |
points are almost collinear. If they are not collinear, then more | |
points are added between those points. | |
References | |
========== | |
.. [1] Adaptive polygonal approximation of parametric curves, | |
Luiz Henrique de Figueiredo. | |
""" | |
x_coords = [] | |
y_coords = [] | |
param = [] | |
def sample(param_p, param_q, p, q, depth): | |
""" Samples recursively if three points are almost collinear. | |
For depth < 6, points are added irrespective of whether they | |
satisfy the collinearity condition or not. The maximum depth | |
allowed is 12. | |
""" | |
# Randomly sample to avoid aliasing. | |
np = import_module('numpy') | |
random = 0.45 + np.random.rand() * 0.1 | |
param_new = param_p + random * (param_q - param_p) | |
xnew = _adaptive_eval(f_x, param_new) | |
ynew = _adaptive_eval(f_y, param_new) | |
new_point = np.array([xnew, ynew]) | |
# Maximum depth | |
if depth > self.depth: | |
x_coords.append(q[0]) | |
y_coords.append(q[1]) | |
param.append(param_p) | |
# Sample irrespective of whether the line is flat till the | |
# depth of 6. We are not using linspace to avoid aliasing. | |
elif depth < 6: | |
sample(param_p, param_new, p, new_point, depth + 1) | |
sample(param_new, param_q, new_point, q, depth + 1) | |
# Sample ten points if complex values are encountered | |
# at both ends. If there is a real value in between, then | |
# sample those points further. | |
elif ((p[0] is None and q[1] is None) or | |
(p[1] is None and q[1] is None)): | |
param_array = np.linspace(param_p, param_q, 10) | |
x_array = [_adaptive_eval(f_x, t) for t in param_array] | |
y_array = [_adaptive_eval(f_y, t) for t in param_array] | |
if not all(x is None and y is None | |
for x, y in zip(x_array, y_array)): | |
for i in range(len(y_array) - 1): | |
if ((x_array[i] is not None and y_array[i] is not None) or | |
(x_array[i + 1] is not None and y_array[i + 1] is not None)): | |
point_a = [x_array[i], y_array[i]] | |
point_b = [x_array[i + 1], y_array[i + 1]] | |
sample(param_array[i], param_array[i], point_a, | |
point_b, depth + 1) | |
# Sample further if one of the end points in None (i.e. a complex | |
# value) or the three points are not almost collinear. | |
elif (p[0] is None or p[1] is None | |
or q[1] is None or q[0] is None | |
or not flat(p, new_point, q)): | |
sample(param_p, param_new, p, new_point, depth + 1) | |
sample(param_new, param_q, new_point, q, depth + 1) | |
else: | |
x_coords.append(q[0]) | |
y_coords.append(q[1]) | |
param.append(param_p) | |
f_start_x = _adaptive_eval(f_x, self.start) | |
f_start_y = _adaptive_eval(f_y, self.start) | |
start = [f_start_x, f_start_y] | |
f_end_x = _adaptive_eval(f_x, self.end) | |
f_end_y = _adaptive_eval(f_y, self.end) | |
end = [f_end_x, f_end_y] | |
x_coords.append(f_start_x) | |
y_coords.append(f_start_y) | |
param.append(self.start) | |
sample(self.start, self.end, start, end, 0) | |
return x_coords, y_coords, param | |
### 3D lines | |
class Line3DBaseSeries(Line2DBaseSeries): | |
"""A base class for 3D lines. | |
Most of the stuff is derived from Line2DBaseSeries.""" | |
is_2Dline = False | |
is_3Dline = True | |
_dim = 3 | |
def __init__(self): | |
super().__init__() | |
class Parametric3DLineSeries(ParametricLineBaseSeries): | |
"""Representation for a 3D line consisting of three parametric SymPy | |
expressions and a range.""" | |
is_2Dline = False | |
is_3Dline = True | |
def __init__(self, expr_x, expr_y, expr_z, var_start_end, label="", **kwargs): | |
super().__init__(**kwargs) | |
self.expr_x = expr_x if callable(expr_x) else sympify(expr_x) | |
self.expr_y = expr_y if callable(expr_y) else sympify(expr_y) | |
self.expr_z = expr_z if callable(expr_z) else sympify(expr_z) | |
self.expr = (self.expr_x, self.expr_y, self.expr_z) | |
self.ranges = [var_start_end] | |
self._cast = float | |
self.adaptive = False | |
self.use_cm = kwargs.get("use_cm", True) | |
self._set_parametric_line_label(label) | |
self._post_init() | |
# TODO: remove this | |
self._xlim = None | |
self._ylim = None | |
self._zlim = None | |
def __str__(self): | |
return self._str_helper( | |
"3D parametric cartesian line: (%s, %s, %s) for %s over %s" % ( | |
str(self.expr_x), | |
str(self.expr_y), | |
str(self.expr_z), | |
str(self.var), | |
str((self.start, self.end)) | |
)) | |
def get_data(self): | |
# TODO: remove this | |
np = import_module("numpy") | |
x, y, z, p = super().get_data() | |
self._xlim = (np.amin(x), np.amax(x)) | |
self._ylim = (np.amin(y), np.amax(y)) | |
self._zlim = (np.amin(z), np.amax(z)) | |
return x, y, z, p | |
### Surfaces | |
class SurfaceBaseSeries(BaseSeries): | |
"""A base class for 3D surfaces.""" | |
is_3Dsurface = True | |
def __init__(self, *args, **kwargs): | |
super().__init__(**kwargs) | |
self.use_cm = kwargs.get("use_cm", False) | |
# NOTE: why should SurfaceOver2DRangeSeries support is polar? | |
# After all, the same result can be achieve with | |
# ParametricSurfaceSeries. For example: | |
# sin(r) for (r, 0, 2 * pi) and (theta, 0, pi/2) can be parameterized | |
# as (r * cos(theta), r * sin(theta), sin(t)) for (r, 0, 2 * pi) and | |
# (theta, 0, pi/2). | |
# Because it is faster to evaluate (important for interactive plots). | |
self.is_polar = kwargs.get("is_polar", kwargs.get("polar", False)) | |
self.surface_color = kwargs.get("surface_color", None) | |
self.color_func = kwargs.get("color_func", lambda x, y, z: z) | |
if callable(self.surface_color): | |
self.color_func = self.surface_color | |
self.surface_color = None | |
def _set_surface_label(self, label): | |
exprs = self.expr | |
self._label = str(exprs) if label is None else label | |
self._latex_label = latex(exprs) if label is None else label | |
# if the expressions is a lambda function and no label | |
# has been provided, then its better to do the following to avoid | |
# suprises on the backend | |
is_lambda = (callable(exprs) if not hasattr(exprs, "__iter__") | |
else any(callable(e) for e in exprs)) | |
if is_lambda and (self._label == str(exprs)): | |
self._label = "" | |
self._latex_label = "" | |
def get_color_array(self): | |
np = import_module('numpy') | |
c = self.surface_color | |
if isinstance(c, Callable): | |
f = np.vectorize(c) | |
nargs = arity(c) | |
if self.is_parametric: | |
variables = list(map(centers_of_faces, self.get_parameter_meshes())) | |
if nargs == 1: | |
return f(variables[0]) | |
elif nargs == 2: | |
return f(*variables) | |
variables = list(map(centers_of_faces, self.get_meshes())) | |
if nargs == 1: | |
return f(variables[0]) | |
elif nargs == 2: | |
return f(*variables[:2]) | |
else: | |
return f(*variables) | |
else: | |
if isinstance(self, SurfaceOver2DRangeSeries): | |
return c*np.ones(min(self.nb_of_points_x, self.nb_of_points_y)) | |
else: | |
return c*np.ones(min(self.nb_of_points_u, self.nb_of_points_v)) | |
class SurfaceOver2DRangeSeries(SurfaceBaseSeries): | |
"""Representation for a 3D surface consisting of a SymPy expression and 2D | |
range.""" | |
def __init__(self, expr, var_start_end_x, var_start_end_y, label="", **kwargs): | |
super().__init__(**kwargs) | |
self.expr = expr if callable(expr) else sympify(expr) | |
self.ranges = [var_start_end_x, var_start_end_y] | |
self._set_surface_label(label) | |
self._post_init() | |
# TODO: remove this | |
self._xlim = (self.start_x, self.end_x) | |
self._ylim = (self.start_y, self.end_y) | |
def var_x(self): | |
return self.ranges[0][0] | |
def var_y(self): | |
return self.ranges[1][0] | |
def start_x(self): | |
try: | |
return float(self.ranges[0][1]) | |
except TypeError: | |
return self.ranges[0][1] | |
def end_x(self): | |
try: | |
return float(self.ranges[0][2]) | |
except TypeError: | |
return self.ranges[0][2] | |
def start_y(self): | |
try: | |
return float(self.ranges[1][1]) | |
except TypeError: | |
return self.ranges[1][1] | |
def end_y(self): | |
try: | |
return float(self.ranges[1][2]) | |
except TypeError: | |
return self.ranges[1][2] | |
def nb_of_points_x(self): | |
return self.n[0] | |
def nb_of_points_x(self, v): | |
n = self.n | |
self.n = [v, n[1:]] | |
def nb_of_points_y(self): | |
return self.n[1] | |
def nb_of_points_y(self, v): | |
n = self.n | |
self.n = [n[0], v, n[2]] | |
def __str__(self): | |
series_type = "cartesian surface" if self.is_3Dsurface else "contour" | |
return self._str_helper( | |
series_type + ": %s for" " %s over %s and %s over %s" % ( | |
str(self.expr), | |
str(self.var_x), str((self.start_x, self.end_x)), | |
str(self.var_y), str((self.start_y, self.end_y)), | |
)) | |
def get_meshes(self): | |
"""Return the x,y,z coordinates for plotting the surface. | |
This function is available for back-compatibility purposes. Consider | |
using ``get_data()`` instead. | |
""" | |
return self.get_data() | |
def get_data(self): | |
"""Return arrays of coordinates for plotting. | |
Returns | |
======= | |
mesh_x : np.ndarray | |
Discretized x-domain. | |
mesh_y : np.ndarray | |
Discretized y-domain. | |
mesh_z : np.ndarray | |
Results of the evaluation. | |
""" | |
np = import_module('numpy') | |
results = self._evaluate() | |
# mask out complex values | |
for i, r in enumerate(results): | |
_re, _im = np.real(r), np.imag(r) | |
_re[np.invert(np.isclose(_im, np.zeros_like(_im)))] = np.nan | |
results[i] = _re | |
x, y, z = results | |
if self.is_polar and self.is_3Dsurface: | |
r = x.copy() | |
x = r * np.cos(y) | |
y = r * np.sin(y) | |
# TODO: remove this | |
self._zlim = (np.amin(z), np.amax(z)) | |
return self._apply_transform(x, y, z) | |
class ParametricSurfaceSeries(SurfaceBaseSeries): | |
"""Representation for a 3D surface consisting of three parametric SymPy | |
expressions and a range.""" | |
is_parametric = True | |
def __init__(self, expr_x, expr_y, expr_z, | |
var_start_end_u, var_start_end_v, label="", **kwargs): | |
super().__init__(**kwargs) | |
self.expr_x = expr_x if callable(expr_x) else sympify(expr_x) | |
self.expr_y = expr_y if callable(expr_y) else sympify(expr_y) | |
self.expr_z = expr_z if callable(expr_z) else sympify(expr_z) | |
self.expr = (self.expr_x, self.expr_y, self.expr_z) | |
self.ranges = [var_start_end_u, var_start_end_v] | |
self.color_func = kwargs.get("color_func", lambda x, y, z, u, v: z) | |
self._set_surface_label(label) | |
self._post_init() | |
def var_u(self): | |
return self.ranges[0][0] | |
def var_v(self): | |
return self.ranges[1][0] | |
def start_u(self): | |
try: | |
return float(self.ranges[0][1]) | |
except TypeError: | |
return self.ranges[0][1] | |
def end_u(self): | |
try: | |
return float(self.ranges[0][2]) | |
except TypeError: | |
return self.ranges[0][2] | |
def start_v(self): | |
try: | |
return float(self.ranges[1][1]) | |
except TypeError: | |
return self.ranges[1][1] | |
def end_v(self): | |
try: | |
return float(self.ranges[1][2]) | |
except TypeError: | |
return self.ranges[1][2] | |
def nb_of_points_u(self): | |
return self.n[0] | |
def nb_of_points_u(self, v): | |
n = self.n | |
self.n = [v, n[1:]] | |
def nb_of_points_v(self): | |
return self.n[1] | |
def nb_of_points_v(self, v): | |
n = self.n | |
self.n = [n[0], v, n[2]] | |
def __str__(self): | |
return self._str_helper( | |
"parametric cartesian surface: (%s, %s, %s) for" | |
" %s over %s and %s over %s" % ( | |
str(self.expr_x), str(self.expr_y), str(self.expr_z), | |
str(self.var_u), str((self.start_u, self.end_u)), | |
str(self.var_v), str((self.start_v, self.end_v)), | |
)) | |
def get_parameter_meshes(self): | |
return self.get_data()[3:] | |
def get_meshes(self): | |
"""Return the x,y,z coordinates for plotting the surface. | |
This function is available for back-compatibility purposes. Consider | |
using ``get_data()`` instead. | |
""" | |
return self.get_data()[:3] | |
def get_data(self): | |
"""Return arrays of coordinates for plotting. | |
Returns | |
======= | |
x : np.ndarray [n2 x n1] | |
x-coordinates. | |
y : np.ndarray [n2 x n1] | |
y-coordinates. | |
z : np.ndarray [n2 x n1] | |
z-coordinates. | |
mesh_u : np.ndarray [n2 x n1] | |
Discretized u range. | |
mesh_v : np.ndarray [n2 x n1] | |
Discretized v range. | |
""" | |
np = import_module('numpy') | |
results = self._evaluate() | |
# mask out complex values | |
for i, r in enumerate(results): | |
_re, _im = np.real(r), np.imag(r) | |
_re[np.invert(np.isclose(_im, np.zeros_like(_im)))] = np.nan | |
results[i] = _re | |
# TODO: remove this | |
x, y, z = results[2:] | |
self._xlim = (np.amin(x), np.amax(x)) | |
self._ylim = (np.amin(y), np.amax(y)) | |
self._zlim = (np.amin(z), np.amax(z)) | |
return self._apply_transform(*results[2:], *results[:2]) | |
### Contours | |
class ContourSeries(SurfaceOver2DRangeSeries): | |
"""Representation for a contour plot.""" | |
is_3Dsurface = False | |
is_contour = True | |
def __init__(self, *args, **kwargs): | |
super().__init__(*args, **kwargs) | |
self.is_filled = kwargs.get("is_filled", kwargs.get("fill", True)) | |
self.show_clabels = kwargs.get("clabels", True) | |
# NOTE: contour plots are used by plot_contour, plot_vector and | |
# plot_complex_vector. By implementing contour_kw we are able to | |
# quickly target the contour plot. | |
self.rendering_kw = kwargs.get("contour_kw", | |
kwargs.get("rendering_kw", {})) | |
class GenericDataSeries(BaseSeries): | |
"""Represents generic numerical data. | |
Notes | |
===== | |
This class serves the purpose of back-compatibility with the "markers, | |
annotations, fill, rectangles" keyword arguments that represent | |
user-provided numerical data. In particular, it solves the problem of | |
combining together two or more plot-objects with the ``extend`` or | |
``append`` methods: user-provided numerical data is also taken into | |
consideration because it is stored in this series class. | |
Also note that the current implementation is far from optimal, as each | |
keyword argument is stored into an attribute in the ``Plot`` class, which | |
requires a hard-coded if-statement in the ``MatplotlibBackend`` class. | |
The implementation suggests that it is ok to add attributes and | |
if-statements to provide more and more functionalities for user-provided | |
numerical data (e.g. adding horizontal lines, or vertical lines, or bar | |
plots, etc). However, in doing so one would reinvent the wheel: plotting | |
libraries (like Matplotlib) already implements the necessary API. | |
Instead of adding more keyword arguments and attributes, users interested | |
in adding custom numerical data to a plot should retrieve the figure | |
created by this plotting module. For example, this code: | |
.. plot:: | |
:context: close-figs | |
:include-source: True | |
from sympy import Symbol, plot, cos | |
x = Symbol("x") | |
p = plot(cos(x), markers=[{"args": [[0, 1, 2], [0, 1, -1], "*"]}]) | |
Becomes: | |
.. plot:: | |
:context: close-figs | |
:include-source: True | |
p = plot(cos(x), backend="matplotlib") | |
fig, ax = p._backend.fig, p._backend.ax[0] | |
ax.plot([0, 1, 2], [0, 1, -1], "*") | |
fig | |
Which is far better in terms of readibility. Also, it gives access to the | |
full plotting library capabilities, without the need to reinvent the wheel. | |
""" | |
is_generic = True | |
def __init__(self, tp, *args, **kwargs): | |
self.type = tp | |
self.args = args | |
self.rendering_kw = kwargs | |
def get_data(self): | |
return self.args | |
class ImplicitSeries(BaseSeries): | |
"""Representation for 2D Implicit plot.""" | |
is_implicit = True | |
use_cm = False | |
_N = 100 | |
def __init__(self, expr, var_start_end_x, var_start_end_y, label="", **kwargs): | |
super().__init__(**kwargs) | |
self.adaptive = kwargs.get("adaptive", False) | |
self.expr = expr | |
self._label = str(expr) if label is None else label | |
self._latex_label = latex(expr) if label is None else label | |
self.ranges = [var_start_end_x, var_start_end_y] | |
self.var_x, self.start_x, self.end_x = self.ranges[0] | |
self.var_y, self.start_y, self.end_y = self.ranges[1] | |
self._color = kwargs.get("color", kwargs.get("line_color", None)) | |
if self.is_interactive and self.adaptive: | |
raise NotImplementedError("Interactive plot with `adaptive=True` " | |
"is not supported.") | |
# Check whether the depth is greater than 4 or less than 0. | |
depth = kwargs.get("depth", 0) | |
if depth > 4: | |
depth = 4 | |
elif depth < 0: | |
depth = 0 | |
self.depth = 4 + depth | |
self._post_init() | |
def expr(self): | |
if self.adaptive: | |
return self._adaptive_expr | |
return self._non_adaptive_expr | |
def expr(self, expr): | |
self._block_lambda_functions(expr) | |
# these are needed for adaptive evaluation | |
expr, has_equality = self._has_equality(sympify(expr)) | |
self._adaptive_expr = expr | |
self.has_equality = has_equality | |
self._label = str(expr) | |
self._latex_label = latex(expr) | |
if isinstance(expr, (BooleanFunction, Ne)) and (not self.adaptive): | |
self.adaptive = True | |
msg = "contains Boolean functions. " | |
if isinstance(expr, Ne): | |
msg = "is an unequality. " | |
warnings.warn( | |
"The provided expression " + msg | |
+ "In order to plot the expression, the algorithm " | |
+ "automatically switched to an adaptive sampling." | |
) | |
if isinstance(expr, BooleanFunction): | |
self._non_adaptive_expr = None | |
self._is_equality = False | |
else: | |
# these are needed for uniform meshing evaluation | |
expr, is_equality = self._preprocess_meshgrid_expression(expr, self.adaptive) | |
self._non_adaptive_expr = expr | |
self._is_equality = is_equality | |
def line_color(self): | |
return self._color | |
def line_color(self, v): | |
self._color = v | |
color = line_color | |
def _has_equality(self, expr): | |
# Represents whether the expression contains an Equality, GreaterThan | |
# or LessThan | |
has_equality = False | |
def arg_expand(bool_expr): | |
"""Recursively expands the arguments of an Boolean Function""" | |
for arg in bool_expr.args: | |
if isinstance(arg, BooleanFunction): | |
arg_expand(arg) | |
elif isinstance(arg, Relational): | |
arg_list.append(arg) | |
arg_list = [] | |
if isinstance(expr, BooleanFunction): | |
arg_expand(expr) | |
# Check whether there is an equality in the expression provided. | |
if any(isinstance(e, (Equality, GreaterThan, LessThan)) for e in arg_list): | |
has_equality = True | |
elif not isinstance(expr, Relational): | |
expr = Equality(expr, 0) | |
has_equality = True | |
elif isinstance(expr, (Equality, GreaterThan, LessThan)): | |
has_equality = True | |
return expr, has_equality | |
def __str__(self): | |
f = lambda t: float(t) if len(t.free_symbols) == 0 else t | |
return self._str_helper( | |
"Implicit expression: %s for %s over %s and %s over %s") % ( | |
str(self._adaptive_expr), | |
str(self.var_x), | |
str((f(self.start_x), f(self.end_x))), | |
str(self.var_y), | |
str((f(self.start_y), f(self.end_y))), | |
) | |
def get_data(self): | |
"""Returns numerical data. | |
Returns | |
======= | |
If the series is evaluated with the `adaptive=True` it returns: | |
interval_list : list | |
List of bounding rectangular intervals to be postprocessed and | |
eventually used with Matplotlib's ``fill`` command. | |
dummy : str | |
A string containing ``"fill"``. | |
Otherwise, it returns 2D numpy arrays to be used with Matplotlib's | |
``contour`` or ``contourf`` commands: | |
x_array : np.ndarray | |
y_array : np.ndarray | |
z_array : np.ndarray | |
plot_type : str | |
A string specifying which plot command to use, ``"contour"`` | |
or ``"contourf"``. | |
""" | |
if self.adaptive: | |
data = self._adaptive_eval() | |
if data is not None: | |
return data | |
return self._get_meshes_grid() | |
def _adaptive_eval(self): | |
""" | |
References | |
========== | |
.. [1] Jeffrey Allen Tupper. Reliable Two-Dimensional Graphing Methods for | |
Mathematical Formulae with Two Free Variables. | |
.. [2] Jeffrey Allen Tupper. Graphing Equations with Generalized Interval | |
Arithmetic. Master's thesis. University of Toronto, 1996 | |
""" | |
import sympy.plotting.intervalmath.lib_interval as li | |
user_functions = {} | |
printer = IntervalMathPrinter({ | |
'fully_qualified_modules': False, 'inline': True, | |
'allow_unknown_functions': True, | |
'user_functions': user_functions}) | |
keys = [t for t in dir(li) if ("__" not in t) and (t not in ["import_module", "interval"])] | |
vals = [getattr(li, k) for k in keys] | |
d = dict(zip(keys, vals)) | |
func = lambdify((self.var_x, self.var_y), self.expr, modules=[d], printer=printer) | |
data = None | |
try: | |
data = self._get_raster_interval(func) | |
except NameError as err: | |
warnings.warn( | |
"Adaptive meshing could not be applied to the" | |
" expression, as some functions are not yet implemented" | |
" in the interval math module:\n\n" | |
"NameError: %s\n\n" % err + | |
"Proceeding with uniform meshing." | |
) | |
self.adaptive = False | |
except TypeError: | |
warnings.warn( | |
"Adaptive meshing could not be applied to the" | |
" expression. Using uniform meshing.") | |
self.adaptive = False | |
return data | |
def _get_raster_interval(self, func): | |
"""Uses interval math to adaptively mesh and obtain the plot""" | |
np = import_module('numpy') | |
k = self.depth | |
interval_list = [] | |
sx, sy = [float(t) for t in [self.start_x, self.start_y]] | |
ex, ey = [float(t) for t in [self.end_x, self.end_y]] | |
# Create initial 32 divisions | |
xsample = np.linspace(sx, ex, 33) | |
ysample = np.linspace(sy, ey, 33) | |
# Add a small jitter so that there are no false positives for equality. | |
# Ex: y==x becomes True for x interval(1, 2) and y interval(1, 2) | |
# which will draw a rectangle. | |
jitterx = ( | |
(np.random.rand(len(xsample)) * 2 - 1) | |
* (ex - sx) | |
/ 2 ** 20 | |
) | |
jittery = ( | |
(np.random.rand(len(ysample)) * 2 - 1) | |
* (ey - sy) | |
/ 2 ** 20 | |
) | |
xsample += jitterx | |
ysample += jittery | |
xinter = [interval(x1, x2) for x1, x2 in zip(xsample[:-1], xsample[1:])] | |
yinter = [interval(y1, y2) for y1, y2 in zip(ysample[:-1], ysample[1:])] | |
interval_list = [[x, y] for x in xinter for y in yinter] | |
plot_list = [] | |
# recursive call refinepixels which subdivides the intervals which are | |
# neither True nor False according to the expression. | |
def refine_pixels(interval_list): | |
"""Evaluates the intervals and subdivides the interval if the | |
expression is partially satisfied.""" | |
temp_interval_list = [] | |
plot_list = [] | |
for intervals in interval_list: | |
# Convert the array indices to x and y values | |
intervalx = intervals[0] | |
intervaly = intervals[1] | |
func_eval = func(intervalx, intervaly) | |
# The expression is valid in the interval. Change the contour | |
# array values to 1. | |
if func_eval[1] is False or func_eval[0] is False: | |
pass | |
elif func_eval == (True, True): | |
plot_list.append([intervalx, intervaly]) | |
elif func_eval[1] is None or func_eval[0] is None: | |
# Subdivide | |
avgx = intervalx.mid | |
avgy = intervaly.mid | |
a = interval(intervalx.start, avgx) | |
b = interval(avgx, intervalx.end) | |
c = interval(intervaly.start, avgy) | |
d = interval(avgy, intervaly.end) | |
temp_interval_list.append([a, c]) | |
temp_interval_list.append([a, d]) | |
temp_interval_list.append([b, c]) | |
temp_interval_list.append([b, d]) | |
return temp_interval_list, plot_list | |
while k >= 0 and len(interval_list): | |
interval_list, plot_list_temp = refine_pixels(interval_list) | |
plot_list.extend(plot_list_temp) | |
k = k - 1 | |
# Check whether the expression represents an equality | |
# If it represents an equality, then none of the intervals | |
# would have satisfied the expression due to floating point | |
# differences. Add all the undecided values to the plot. | |
if self.has_equality: | |
for intervals in interval_list: | |
intervalx = intervals[0] | |
intervaly = intervals[1] | |
func_eval = func(intervalx, intervaly) | |
if func_eval[1] and func_eval[0] is not False: | |
plot_list.append([intervalx, intervaly]) | |
return plot_list, "fill" | |
def _get_meshes_grid(self): | |
"""Generates the mesh for generating a contour. | |
In the case of equality, ``contour`` function of matplotlib can | |
be used. In other cases, matplotlib's ``contourf`` is used. | |
""" | |
np = import_module('numpy') | |
xarray, yarray, z_grid = self._evaluate() | |
_re, _im = np.real(z_grid), np.imag(z_grid) | |
_re[np.invert(np.isclose(_im, np.zeros_like(_im)))] = np.nan | |
if self._is_equality: | |
return xarray, yarray, _re, 'contour' | |
return xarray, yarray, _re, 'contourf' | |
def _preprocess_meshgrid_expression(expr, adaptive): | |
"""If the expression is a Relational, rewrite it as a single | |
expression. | |
Returns | |
======= | |
expr : Expr | |
The rewritten expression | |
equality : Boolean | |
Wheter the original expression was an Equality or not. | |
""" | |
equality = False | |
if isinstance(expr, Equality): | |
expr = expr.lhs - expr.rhs | |
equality = True | |
elif isinstance(expr, Relational): | |
expr = expr.gts - expr.lts | |
elif not adaptive: | |
raise NotImplementedError( | |
"The expression is not supported for " | |
"plotting in uniform meshed plot." | |
) | |
return expr, equality | |
def get_label(self, use_latex=False, wrapper="$%s$"): | |
"""Return the label to be used to display the expression. | |
Parameters | |
========== | |
use_latex : bool | |
If False, the string representation of the expression is returned. | |
If True, the latex representation is returned. | |
wrapper : str | |
The backend might need the latex representation to be wrapped by | |
some characters. Default to ``"$%s$"``. | |
Returns | |
======= | |
label : str | |
""" | |
if use_latex is False: | |
return self._label | |
if self._label == str(self._adaptive_expr): | |
return self._get_wrapped_label(self._latex_label, wrapper) | |
return self._latex_label | |
############################################################################## | |
# Finding the centers of line segments or mesh faces | |
############################################################################## | |
def centers_of_segments(array): | |
np = import_module('numpy') | |
return np.mean(np.vstack((array[:-1], array[1:])), 0) | |
def centers_of_faces(array): | |
np = import_module('numpy') | |
return np.mean(np.dstack((array[:-1, :-1], | |
array[1:, :-1], | |
array[:-1, 1:], | |
array[:-1, :-1], | |
)), 2) | |
def flat(x, y, z, eps=1e-3): | |
"""Checks whether three points are almost collinear""" | |
np = import_module('numpy') | |
# Workaround plotting piecewise (#8577) | |
vector_a = (x - y).astype(float) | |
vector_b = (z - y).astype(float) | |
dot_product = np.dot(vector_a, vector_b) | |
vector_a_norm = np.linalg.norm(vector_a) | |
vector_b_norm = np.linalg.norm(vector_b) | |
cos_theta = dot_product / (vector_a_norm * vector_b_norm) | |
return abs(cos_theta + 1) < eps | |
def _set_discretization_points(kwargs, pt): | |
"""Allow the use of the keyword arguments ``n, n1, n2`` to | |
specify the number of discretization points in one and two | |
directions, while keeping back-compatibility with older keyword arguments | |
like, ``nb_of_points, nb_of_points_*, points``. | |
Parameters | |
========== | |
kwargs : dict | |
Dictionary of keyword arguments passed into a plotting function. | |
pt : type | |
The type of the series, which indicates the kind of plot we are | |
trying to create. | |
""" | |
replace_old_keywords = { | |
"nb_of_points": "n", | |
"nb_of_points_x": "n1", | |
"nb_of_points_y": "n2", | |
"nb_of_points_u": "n1", | |
"nb_of_points_v": "n2", | |
"points": "n" | |
} | |
for k, v in replace_old_keywords.items(): | |
if k in kwargs.keys(): | |
kwargs[v] = kwargs.pop(k) | |
if pt in [LineOver1DRangeSeries, Parametric2DLineSeries, | |
Parametric3DLineSeries]: | |
if "n" in kwargs.keys(): | |
kwargs["n1"] = kwargs["n"] | |
if hasattr(kwargs["n"], "__iter__") and (len(kwargs["n"]) > 0): | |
kwargs["n1"] = kwargs["n"][0] | |
elif pt in [SurfaceOver2DRangeSeries, ContourSeries, | |
ParametricSurfaceSeries, ImplicitSeries]: | |
if "n" in kwargs.keys(): | |
if hasattr(kwargs["n"], "__iter__") and (len(kwargs["n"]) > 1): | |
kwargs["n1"] = kwargs["n"][0] | |
kwargs["n2"] = kwargs["n"][1] | |
else: | |
kwargs["n1"] = kwargs["n2"] = kwargs["n"] | |
return kwargs | |