plot3 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot3/__init__.py +301 -0
- plot3/__version__.py +1 -0
- plot3/aesexpr.py +271 -0
- plot3/build.py +3948 -0
- plot3/calculus.py +1179 -0
- plot3/compose.py +285 -0
- plot3/contour.py +476 -0
- plot3/craft.py +142 -0
- plot3/encode.py +68 -0
- plot3/expr.py +1557 -0
- plot3/flip.py +245 -0
- plot3/function.py +1301 -0
- plot3/geoms.py +2558 -0
- plot3/ggplot.py +713 -0
- plot3/io.py +76 -0
- plot3/jupyter.py +514 -0
- plot3/latexin.py +616 -0
- plot3/masking.py +494 -0
- plot3/mathtext.py +842 -0
- plot3/payload.py +216 -0
- plot3/remote.py +220 -0
- plot3/scales.py +387 -0
- plot3/scaling.py +636 -0
- plot3/special.py +407 -0
- plot3/stat2d.py +1539 -0
- plot3/static.py +3760 -0
- plot3/stats3d.py +462 -0
- plot3/table.py +775 -0
- plot3/themes.py +104 -0
- plot3/viewer.py +3354 -0
- plot3-0.4.0.dist-info/METADATA +504 -0
- plot3-0.4.0.dist-info/RECORD +35 -0
- plot3-0.4.0.dist-info/WHEEL +5 -0
- plot3-0.4.0.dist-info/licenses/LICENSE +21 -0
- plot3-0.4.0.dist-info/top_level.txt +1 -0
plot3/calculus.py
ADDED
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
"""Shaded integrals, marks, tangents, parametric curves, polar plots, fields.
|
|
2
|
+
|
|
3
|
+
``geom_function`` stays the formula. This module turns the extra math
|
|
4
|
+
(``area``, ``tangent``, ``derivative``, ``mark``, polar coordinates, and
|
|
5
|
+
``geom_vector_field``) into line, area, polygon, and point layers the
|
|
6
|
+
viewer already draws.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from fractions import Fraction
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from plot3.expr import (
|
|
20
|
+
ExprError,
|
|
21
|
+
Formula,
|
|
22
|
+
_compile_tree,
|
|
23
|
+
_missing_param_build_message,
|
|
24
|
+
differentiate,
|
|
25
|
+
evaluate,
|
|
26
|
+
)
|
|
27
|
+
from plot3.function import (
|
|
28
|
+
_DEFAULT_DOMAIN,
|
|
29
|
+
_assign_axes,
|
|
30
|
+
_call_formula,
|
|
31
|
+
_curve_values,
|
|
32
|
+
_domain_for,
|
|
33
|
+
_linspace,
|
|
34
|
+
_sample_count,
|
|
35
|
+
_stamp_formula,
|
|
36
|
+
)
|
|
37
|
+
from plot3.geoms import _Geom, aes, geom_line, geom_path, geom_point
|
|
38
|
+
from plot3.mathtext import formula_texts
|
|
39
|
+
|
|
40
|
+
_POLAR_DOMAIN = (0.0, float(2.0 * np.pi))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def animation_blocked(formula: Formula, geom, coord, addons) -> str | None:
|
|
44
|
+
"""Why a slider or parameter sweep cannot drive this layer, or None."""
|
|
45
|
+
names: list[str] = []
|
|
46
|
+
for addon in addons or []:
|
|
47
|
+
names.append(type(addon).__name__ + "()")
|
|
48
|
+
if tuple(getattr(geom, "marks", ()) or ()):
|
|
49
|
+
names.append("mark=")
|
|
50
|
+
if formula.mode == "parametric":
|
|
51
|
+
names.append("a parametric curve")
|
|
52
|
+
elif formula.mode == "inequality":
|
|
53
|
+
names.append("an inequality")
|
|
54
|
+
elif formula.mode == "field":
|
|
55
|
+
names.append("a vector field")
|
|
56
|
+
if coord is not None and type(coord).__name__ == "coord_polar":
|
|
57
|
+
names.append("coord_polar()")
|
|
58
|
+
if not names:
|
|
59
|
+
return None
|
|
60
|
+
head = names[0]
|
|
61
|
+
return (
|
|
62
|
+
f"{head} cannot follow slider() or transition_time() yet. "
|
|
63
|
+
"Draw it at one value of the coefficient."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def expand_special(geom, formula: Formula, domains, coord) -> list | None:
|
|
68
|
+
"""Parametric, inequality, and polar layers. None for an ordinary formula."""
|
|
69
|
+
if formula.mode == "parametric":
|
|
70
|
+
return [_expand_parametric(geom, formula)]
|
|
71
|
+
if formula.mode == "inequality":
|
|
72
|
+
return _expand_inequality(geom, formula, domains)
|
|
73
|
+
if coord is not None and type(coord).__name__ == "coord_polar":
|
|
74
|
+
return [_expand_polar(geom, formula)]
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def attach_calculus(primary, geom, formula: Formula, domains, addons) -> list:
|
|
79
|
+
"""Fills and marks that belong to ``primary``. The curve stays on top of a fill."""
|
|
80
|
+
axes = _assign_axes(formula)
|
|
81
|
+
before: list = []
|
|
82
|
+
after: list = []
|
|
83
|
+
for addon in addons or []:
|
|
84
|
+
kind = type(addon).__name__
|
|
85
|
+
if kind == "area":
|
|
86
|
+
before.append(_integral_layer(geom, formula, axes, addon, primary))
|
|
87
|
+
elif kind == "tangent":
|
|
88
|
+
after.extend(_tangent_layers(geom, formula, axes, primary, addon))
|
|
89
|
+
elif kind == "derivative":
|
|
90
|
+
after.append(_derivative_layer(geom, formula, axes, primary))
|
|
91
|
+
else:
|
|
92
|
+
raise TypeError(f"cannot draw {kind}()")
|
|
93
|
+
marks = tuple(getattr(geom, "marks", ()) or ())
|
|
94
|
+
if "roots" in marks or "extrema" in marks:
|
|
95
|
+
after.extend(_mark_layers(geom, formula, axes, primary, marks))
|
|
96
|
+
if "intersections" in marks:
|
|
97
|
+
primary._want_intersections = True
|
|
98
|
+
return before + [primary] + after
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def mark_intersections(layers: list) -> list:
|
|
102
|
+
"""Add a point where two function curves cross, when one of them asked."""
|
|
103
|
+
if not any(getattr(layer, "_want_intersections", False) for layer in layers):
|
|
104
|
+
return layers
|
|
105
|
+
curves = [layer for layer in layers if _is_function_curve(layer)]
|
|
106
|
+
found: list[tuple[float, float]] = []
|
|
107
|
+
for index, left in enumerate(curves):
|
|
108
|
+
for right in curves[index + 1 :]:
|
|
109
|
+
found.extend(_crossings(left, right))
|
|
110
|
+
if not found:
|
|
111
|
+
return layers
|
|
112
|
+
frame = pd.DataFrame(
|
|
113
|
+
{
|
|
114
|
+
"x": [point[0] for point in found],
|
|
115
|
+
"y": [point[1] for point in found],
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
out = geom_point(aes(x="x", y="y"), size=8, alpha=1)
|
|
119
|
+
out.data_override = frame
|
|
120
|
+
out._replace_mapping = True
|
|
121
|
+
out._legend_label = "intersections"
|
|
122
|
+
out._is_formula = True
|
|
123
|
+
return [*layers, out]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def expand_vector_field(geom, transition, slider) -> list:
|
|
127
|
+
"""Arrows or streamlines for ``dx, dy`` on a grid."""
|
|
128
|
+
formula: Formula = geom.formula
|
|
129
|
+
if formula.mode != "field":
|
|
130
|
+
raise ExprError(
|
|
131
|
+
'geom_vector_field() needs dx and dy, for example "dx = -y, dy = x"'
|
|
132
|
+
)
|
|
133
|
+
sweep = slider if slider is not None else transition
|
|
134
|
+
ranges = getattr(sweep, "ranges", None) or {}
|
|
135
|
+
pending = tuple(getattr(formula, "pending", ()) or ())
|
|
136
|
+
if pending and any(name not in ranges for name in pending):
|
|
137
|
+
missing = next(name for name in pending if name not in ranges)
|
|
138
|
+
raise ExprError(_missing_param_build_message(missing))
|
|
139
|
+
if ranges:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
"a vector field cannot follow slider() or transition_time() yet. "
|
|
142
|
+
"Draw it at one value of the coefficient."
|
|
143
|
+
)
|
|
144
|
+
xlo, xhi = _pair(geom.xlim, "xlim", (-2.0, 2.0))
|
|
145
|
+
ylo, yhi = _pair(geom.ylim, "ylim", (-2.0, 2.0))
|
|
146
|
+
count = max(2, int(geom.n))
|
|
147
|
+
xs = _linspace(xlo, xhi, count)
|
|
148
|
+
ys = _linspace(ylo, yhi, count)
|
|
149
|
+
xx, yy = np.meshgrid(xs, ys)
|
|
150
|
+
dx = _component(formula, "dx", {"x": xx, "y": yy})
|
|
151
|
+
dy = _component(formula, "dy", {"x": xx, "y": yy})
|
|
152
|
+
cell = min((xhi - xlo) / (count - 1), (yhi - ylo) / (count - 1))
|
|
153
|
+
if geom.stream:
|
|
154
|
+
rows, groups = _streamlines(
|
|
155
|
+
formula, xs, ys, cell, (xlo, xhi), (ylo, yhi)
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
rows, groups = _arrows(xx, yy, dx, dy, 0.72 * cell)
|
|
159
|
+
if not rows:
|
|
160
|
+
raise ExprError(
|
|
161
|
+
"geom_vector_field() has no arrows on this domain. "
|
|
162
|
+
"Try a wider xlim= and ylim="
|
|
163
|
+
)
|
|
164
|
+
frame = pd.DataFrame(
|
|
165
|
+
{"x": [row[0] for row in rows], "y": [row[1] for row in rows]}
|
|
166
|
+
)
|
|
167
|
+
out = geom_path(
|
|
168
|
+
aes(x="x", y="y"),
|
|
169
|
+
linewidth=1.5 if geom.linewidth is None else geom.linewidth,
|
|
170
|
+
color=geom.const_color,
|
|
171
|
+
alpha=geom.alpha if geom.alpha is not None else 0.95,
|
|
172
|
+
)
|
|
173
|
+
out.data_override = frame
|
|
174
|
+
out._groups = groups
|
|
175
|
+
out._replace_mapping = True
|
|
176
|
+
out.sort_x = False
|
|
177
|
+
_stamp_formula(out, geom, formula)
|
|
178
|
+
out._axis_labels = {"x": "x", "y": "y"}
|
|
179
|
+
return [out]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _expand_parametric(geom, formula: Formula):
|
|
183
|
+
if formula.parameter == "":
|
|
184
|
+
raise ExprError(
|
|
185
|
+
'a parametric curve needs one parameter, for example '
|
|
186
|
+
'"x = cos(t), y = sin(t)"'
|
|
187
|
+
)
|
|
188
|
+
lo, hi = _parameter_domain(geom)
|
|
189
|
+
count = _sample_count(geom, grid=False)
|
|
190
|
+
samples = _linspace(lo, hi, count)
|
|
191
|
+
env = {formula.parameter: samples}
|
|
192
|
+
columns = {
|
|
193
|
+
name: _component(formula, name, env) for name, _code in formula.components
|
|
194
|
+
}
|
|
195
|
+
has_z = "z" in columns
|
|
196
|
+
xs = np.asarray(columns["x"], dtype=np.float64).reshape(-1)
|
|
197
|
+
ys = np.asarray(columns["y"], dtype=np.float64).reshape(-1)
|
|
198
|
+
zs = np.asarray(columns["z"], dtype=np.float64).reshape(-1) if has_z else None
|
|
199
|
+
finite = np.isfinite(xs) & np.isfinite(ys)
|
|
200
|
+
if zs is not None:
|
|
201
|
+
finite = finite & np.isfinite(zs)
|
|
202
|
+
if int(np.count_nonzero(finite)) < 2:
|
|
203
|
+
raise ExprError("geom_function() needs at least two points on this domain")
|
|
204
|
+
keep = np.flatnonzero(finite)
|
|
205
|
+
data = {"x": xs[keep], "y": ys[keep]}
|
|
206
|
+
mapping = {"x": "x", "y": "y"}
|
|
207
|
+
if zs is not None:
|
|
208
|
+
data["z"] = zs[keep]
|
|
209
|
+
mapping["z"] = "z"
|
|
210
|
+
out = geom_path(
|
|
211
|
+
aes(**mapping),
|
|
212
|
+
linewidth=2.0 if geom.linewidth is None else geom.linewidth,
|
|
213
|
+
color=geom.const_color,
|
|
214
|
+
alpha=geom.alpha,
|
|
215
|
+
)
|
|
216
|
+
out.data_override = pd.DataFrame(data)
|
|
217
|
+
out._groups = _runs(keep)
|
|
218
|
+
out._replace_mapping = True
|
|
219
|
+
out.sort_x = False
|
|
220
|
+
_stamp_formula(out, geom, formula)
|
|
221
|
+
labels = {"x": "x", "y": "y"}
|
|
222
|
+
if has_z:
|
|
223
|
+
labels["z"] = "z"
|
|
224
|
+
out._axis_labels = labels
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parameter_domain(geom) -> tuple[float, float]:
|
|
229
|
+
chosen = getattr(geom, "tlim", None)
|
|
230
|
+
if chosen is None:
|
|
231
|
+
chosen = getattr(geom, "xlim", None)
|
|
232
|
+
if chosen is None:
|
|
233
|
+
return _DEFAULT_DOMAIN
|
|
234
|
+
return _pair(chosen, "tlim", _DEFAULT_DOMAIN)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _expand_polar(geom, formula: Formula):
|
|
238
|
+
if formula.mode != "explicit" or formula.dependent != "r":
|
|
239
|
+
raise ExprError(
|
|
240
|
+
'coord_polar() plots r = f(theta). For example '
|
|
241
|
+
'geom_function("r = 1 + cos(theta)") + coord_polar()'
|
|
242
|
+
)
|
|
243
|
+
if len(formula.variables) > 1:
|
|
244
|
+
raise ExprError(
|
|
245
|
+
"coord_polar() needs one angle, for example r = 1 + cos(theta)"
|
|
246
|
+
)
|
|
247
|
+
lo, hi = _POLAR_DOMAIN
|
|
248
|
+
if getattr(geom, "tlim", None) is not None:
|
|
249
|
+
lo, hi = _pair(geom.tlim, "tlim", _POLAR_DOMAIN)
|
|
250
|
+
count = _sample_count(geom, grid=False)
|
|
251
|
+
theta = _linspace(lo, hi, count)
|
|
252
|
+
name = formula.variables[0] if formula.variables else "theta"
|
|
253
|
+
radius = _call_formula(formula, {name: theta})
|
|
254
|
+
finite = np.isfinite(theta) & np.isfinite(radius)
|
|
255
|
+
if int(np.count_nonzero(finite)) < 2:
|
|
256
|
+
raise ExprError("geom_function() needs at least two points on this domain")
|
|
257
|
+
keep = np.flatnonzero(finite)
|
|
258
|
+
angle = theta[keep]
|
|
259
|
+
radial = radius[keep]
|
|
260
|
+
frame = pd.DataFrame(
|
|
261
|
+
{
|
|
262
|
+
"x": radial * np.cos(angle),
|
|
263
|
+
"y": radial * np.sin(angle),
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
out = geom_path(
|
|
267
|
+
aes(x="x", y="y"),
|
|
268
|
+
linewidth=2.0 if geom.linewidth is None else geom.linewidth,
|
|
269
|
+
color=geom.const_color,
|
|
270
|
+
alpha=geom.alpha,
|
|
271
|
+
)
|
|
272
|
+
out.data_override = frame
|
|
273
|
+
out._groups = _runs(keep)
|
|
274
|
+
out._replace_mapping = True
|
|
275
|
+
out.sort_x = False
|
|
276
|
+
_stamp_formula(out, geom, formula)
|
|
277
|
+
out._axis_labels = {"x": "x", "y": "y"}
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _expand_inequality(geom, formula: Formula, domains) -> list:
|
|
282
|
+
if formula.dependent in {"x", "y"}:
|
|
283
|
+
return _curve_inequality(geom, formula, domains)
|
|
284
|
+
return _region_inequality(geom, formula, domains)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _curve_inequality(geom, formula: Formula, domains) -> list:
|
|
288
|
+
dependent = formula.dependent or "y"
|
|
289
|
+
if dependent == "y":
|
|
290
|
+
(lo, hi), _source = _domain_for(geom, "x", domains)
|
|
291
|
+
else:
|
|
292
|
+
(lo, hi), _source = _domain_for(geom, "y", domains)
|
|
293
|
+
count = _sample_count(geom, grid=False)
|
|
294
|
+
samples = _linspace(lo, hi, count)
|
|
295
|
+
if formula.variables:
|
|
296
|
+
boundary = _call_formula(formula, {formula.variables[0]: samples})
|
|
297
|
+
else:
|
|
298
|
+
raw = evaluate(formula, {})
|
|
299
|
+
number = float(np.asarray(raw, dtype=np.float64).reshape(-1)[0])
|
|
300
|
+
boundary = np.full(samples.shape, number, dtype=np.float64)
|
|
301
|
+
finite = np.isfinite(samples) & np.isfinite(boundary)
|
|
302
|
+
if int(np.count_nonzero(finite)) < 2:
|
|
303
|
+
raise ExprError("geom_function() is undefined everywhere on this domain")
|
|
304
|
+
keep = np.flatnonzero(finite)
|
|
305
|
+
samples = samples[keep]
|
|
306
|
+
boundary = boundary[keep]
|
|
307
|
+
above = formula.relation in {">", ">="}
|
|
308
|
+
groups = _runs(keep)
|
|
309
|
+
if dependent == "y":
|
|
310
|
+
view = _limit_or_none(getattr(geom, "ylim", None), "ylim")
|
|
311
|
+
baseline, lock = _open_baseline(boundary, above, view)
|
|
312
|
+
shade = _area_layer(
|
|
313
|
+
samples,
|
|
314
|
+
boundary,
|
|
315
|
+
baseline,
|
|
316
|
+
groups,
|
|
317
|
+
color=geom.const_color,
|
|
318
|
+
alpha=0.35,
|
|
319
|
+
)
|
|
320
|
+
shade._axis_lock = {"y": lock}
|
|
321
|
+
curve_x, curve_y = samples, boundary
|
|
322
|
+
else:
|
|
323
|
+
view = _limit_or_none(getattr(geom, "xlim", None), "xlim")
|
|
324
|
+
baseline, lock = _open_baseline(boundary, above, view)
|
|
325
|
+
shade = _vertical_shade(boundary, samples, baseline, color=geom.const_color)
|
|
326
|
+
shade._axis_lock = {"x": lock}
|
|
327
|
+
curve_x, curve_y = boundary, samples
|
|
328
|
+
line = geom_path(
|
|
329
|
+
aes(x="x", y="y"),
|
|
330
|
+
linewidth=2.0 if geom.linewidth is None else geom.linewidth,
|
|
331
|
+
color=geom.const_color,
|
|
332
|
+
alpha=geom.alpha,
|
|
333
|
+
)
|
|
334
|
+
line.data_override = pd.DataFrame({"x": curve_x, "y": curve_y})
|
|
335
|
+
line._groups = groups if dependent == "y" else [[0, int(samples.size)]]
|
|
336
|
+
line._replace_mapping = True
|
|
337
|
+
line.sort_x = False
|
|
338
|
+
_stamp_formula(line, geom, formula)
|
|
339
|
+
line._axis_labels = {"x": "x", "y": "y"}
|
|
340
|
+
if dependent == "y":
|
|
341
|
+
line._axis_lock = {"y": lock}
|
|
342
|
+
else:
|
|
343
|
+
line._axis_lock = {"x": lock}
|
|
344
|
+
return [shade, line]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _open_baseline(values, above: bool, view):
|
|
348
|
+
finite = values[np.isfinite(values)]
|
|
349
|
+
ymin = float(np.min(finite))
|
|
350
|
+
ymax = float(np.max(finite))
|
|
351
|
+
if view is not None:
|
|
352
|
+
lo, hi = view
|
|
353
|
+
baseline = hi if above else lo
|
|
354
|
+
return baseline, (min(lo, ymin), max(hi, ymax))
|
|
355
|
+
span = max(ymax - ymin, 1.0)
|
|
356
|
+
pad = 0.22 * span
|
|
357
|
+
if above:
|
|
358
|
+
baseline = ymax + pad
|
|
359
|
+
return baseline, (ymin - 0.08 * span, baseline)
|
|
360
|
+
baseline = ymin - pad
|
|
361
|
+
return baseline, (baseline, ymax + 0.08 * span)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _vertical_shade(xs, ys, baseline, *, color):
|
|
365
|
+
"""Shade from a sideways boundary to a vertical baseline."""
|
|
366
|
+
forward = list(zip(np.asarray(xs, dtype=np.float64), np.asarray(ys, dtype=np.float64)))
|
|
367
|
+
back = [(float(baseline), float(y)) for _x, y in reversed(forward)]
|
|
368
|
+
points = forward + back
|
|
369
|
+
frame = pd.DataFrame(
|
|
370
|
+
{"x": [point[0] for point in points], "y": [point[1] for point in points]}
|
|
371
|
+
)
|
|
372
|
+
out = _Geom(aes(x="x", y="y"), color=color, alpha=0.35)
|
|
373
|
+
out.kind = "poly"
|
|
374
|
+
out.data_override = frame
|
|
375
|
+
out._groups = [[0, len(points)]]
|
|
376
|
+
out._replace_mapping = True
|
|
377
|
+
out._inherit_color = True
|
|
378
|
+
out._is_formula = True
|
|
379
|
+
out.linewidth = 0.0
|
|
380
|
+
out.const_color = color
|
|
381
|
+
out.alpha = 0.35
|
|
382
|
+
return out
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _region_inequality(geom, formula: Formula, domains) -> list:
|
|
386
|
+
from plot3.contour import _contour_lines
|
|
387
|
+
|
|
388
|
+
count = _sample_count(geom, grid=True)
|
|
389
|
+
(xlo, xhi), _xs = _domain_for(geom, "x", domains)
|
|
390
|
+
(ylo, yhi), _ys = _domain_for(geom, "y", domains)
|
|
391
|
+
xs = _linspace(xlo, xhi, count)
|
|
392
|
+
ys = _linspace(ylo, yhi, count)
|
|
393
|
+
# Cell centers decide the fill. The contour uses the same corner grid.
|
|
394
|
+
xx, yy = np.meshgrid(xs, ys)
|
|
395
|
+
names = list(formula.variables)
|
|
396
|
+
field = _call_formula(formula, {names[0]: xx, names[1]: yy})
|
|
397
|
+
# evaluate() follows variable order, but _call_formula names them.
|
|
398
|
+
# Meshgrid is (y, x). variables may be (x, y) or (y, x). Name them.
|
|
399
|
+
named = {}
|
|
400
|
+
if set(names) >= {"x", "y"}:
|
|
401
|
+
named = {"x": xx, "y": yy}
|
|
402
|
+
field = _call_formula(formula, named)
|
|
403
|
+
cx = 0.5 * (xs[:-1] + xs[1:])
|
|
404
|
+
cy = 0.5 * (ys[:-1] + ys[1:])
|
|
405
|
+
cxx, cyy = np.meshgrid(cx, cy)
|
|
406
|
+
centers = _call_formula(formula, {"x": cxx, "y": cyy} if "x" in names else {
|
|
407
|
+
names[0]: cxx, names[1]: cyy
|
|
408
|
+
})
|
|
409
|
+
points: list[tuple[float, float]] = []
|
|
410
|
+
groups: list[list[int]] = []
|
|
411
|
+
inside = np.isfinite(centers) & (centers >= 0)
|
|
412
|
+
for j in range(cy.size):
|
|
413
|
+
for i in range(cx.size):
|
|
414
|
+
if not inside[j, i]:
|
|
415
|
+
continue
|
|
416
|
+
quad = (
|
|
417
|
+
(float(xs[i]), float(ys[j])),
|
|
418
|
+
(float(xs[i + 1]), float(ys[j])),
|
|
419
|
+
(float(xs[i + 1]), float(ys[j + 1])),
|
|
420
|
+
(float(xs[i]), float(ys[j + 1])),
|
|
421
|
+
)
|
|
422
|
+
# Paired strip: left edge bottom→top, right edge top→bottom.
|
|
423
|
+
ordered = (quad[0], quad[3], quad[2], quad[1])
|
|
424
|
+
start = len(points)
|
|
425
|
+
points.extend(ordered)
|
|
426
|
+
groups.append([start, 4])
|
|
427
|
+
if not points:
|
|
428
|
+
raise ExprError(
|
|
429
|
+
"geom_function() found no region where the inequality holds. "
|
|
430
|
+
"Try a wider xlim= and ylim="
|
|
431
|
+
)
|
|
432
|
+
shade = _Geom(aes(x="x", y="y"), color=geom.const_color, alpha=0.35)
|
|
433
|
+
shade.kind = "poly"
|
|
434
|
+
shade.data_override = pd.DataFrame(
|
|
435
|
+
{"x": [p[0] for p in points], "y": [p[1] for p in points]}
|
|
436
|
+
)
|
|
437
|
+
shade._groups = groups
|
|
438
|
+
shade._replace_mapping = True
|
|
439
|
+
shade._inherit_color = True
|
|
440
|
+
shade._is_formula = True
|
|
441
|
+
shade.linewidth = 0.0
|
|
442
|
+
shade.const_color = geom.const_color
|
|
443
|
+
shade.alpha = 0.35
|
|
444
|
+
polylines = _contour_lines(xs, ys, field, 0.0)
|
|
445
|
+
rows_x: list[float] = []
|
|
446
|
+
rows_y: list[float] = []
|
|
447
|
+
line_groups: list[list[int]] = []
|
|
448
|
+
for poly in polylines or []:
|
|
449
|
+
if len(poly) < 2:
|
|
450
|
+
continue
|
|
451
|
+
start = len(rows_x)
|
|
452
|
+
for x_val, y_val in poly:
|
|
453
|
+
rows_x.append(float(x_val))
|
|
454
|
+
rows_y.append(float(y_val))
|
|
455
|
+
line_groups.append([start, len(rows_x) - start])
|
|
456
|
+
layers = [shade]
|
|
457
|
+
if line_groups:
|
|
458
|
+
line = geom_path(
|
|
459
|
+
aes(x="x", y="y"),
|
|
460
|
+
linewidth=2.0 if geom.linewidth is None else geom.linewidth,
|
|
461
|
+
color=geom.const_color,
|
|
462
|
+
alpha=geom.alpha,
|
|
463
|
+
)
|
|
464
|
+
line.data_override = pd.DataFrame({"x": rows_x, "y": rows_y})
|
|
465
|
+
line._groups = line_groups
|
|
466
|
+
line._replace_mapping = True
|
|
467
|
+
line.sort_x = False
|
|
468
|
+
_stamp_formula(line, geom, formula)
|
|
469
|
+
line._axis_labels = {"x": "x", "y": "y"}
|
|
470
|
+
layers.append(line)
|
|
471
|
+
else:
|
|
472
|
+
_stamp_formula(shade, geom, formula)
|
|
473
|
+
shade._inherit_color = False
|
|
474
|
+
shade._axis_labels = {"x": "x", "y": "y"}
|
|
475
|
+
return layers
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _integral_layer(geom, formula: Formula, axes, addon, primary=None):
|
|
479
|
+
if formula.mode == "inequality":
|
|
480
|
+
raise ExprError('area() integrates a curve y = f(x), not an inequality')
|
|
481
|
+
if axes.kind != "curve" or axes.computed == "x":
|
|
482
|
+
raise ExprError(
|
|
483
|
+
'area() integrates a curve y = f(x). For example '
|
|
484
|
+
'geom_function("y = x^2") + area(0, 2)'
|
|
485
|
+
)
|
|
486
|
+
asked_lo, asked_hi = float(addon.lo), float(addon.hi)
|
|
487
|
+
baseline = float(addon.baseline)
|
|
488
|
+
count = _sample_count(geom, grid=False)
|
|
489
|
+
if (count - 1) % 2 == 1:
|
|
490
|
+
count += 1
|
|
491
|
+
edge_lo, edge_hi = _curve_edges(primary)
|
|
492
|
+
lo, hi = asked_lo, asked_hi
|
|
493
|
+
if not math.isfinite(lo) or not math.isfinite(hi):
|
|
494
|
+
# area(-inf, -1.96) runs to the edge of the drawn curve.
|
|
495
|
+
if edge_lo is None:
|
|
496
|
+
raise ExprError("area() with an infinite limit needs the curve it shades")
|
|
497
|
+
lo = max(lo, edge_lo)
|
|
498
|
+
hi = min(hi, edge_hi)
|
|
499
|
+
if hi <= lo:
|
|
500
|
+
raise ExprError(
|
|
501
|
+
f"area({_num(asked_lo)}, {_num(asked_hi)}) misses the curve, "
|
|
502
|
+
f"which is drawn on ({_num(edge_lo)}, {_num(edge_hi)})"
|
|
503
|
+
)
|
|
504
|
+
xs = _linspace(lo, hi, count)
|
|
505
|
+
ys = _curve_values(formula, axes, xs)
|
|
506
|
+
if ys.shape != xs.shape or not np.all(np.isfinite(ys)):
|
|
507
|
+
raise ExprError(
|
|
508
|
+
f"area() is undefined on ({_num(lo)}, {_num(hi)})"
|
|
509
|
+
)
|
|
510
|
+
signed = _simpson(xs, ys) - baseline * (float(xs[-1]) - float(xs[0]))
|
|
511
|
+
if baseline == 0.0 and _is_density(formula, axes, edge_lo, edge_hi, count):
|
|
512
|
+
name = formula.variables[0] if formula.variables else "x"
|
|
513
|
+
# The shading stops at the drawn edge; the probability does not.
|
|
514
|
+
# Student's t keeps 1.5% of its mass beyond +-5.
|
|
515
|
+
width = edge_hi - edge_lo
|
|
516
|
+
if not math.isfinite(asked_lo):
|
|
517
|
+
signed += _tail_mass(formula, axes, edge_lo, -1.0, width)
|
|
518
|
+
if not math.isfinite(asked_hi):
|
|
519
|
+
signed += _tail_mass(formula, axes, edge_hi, 1.0, width)
|
|
520
|
+
text = _probability_text(name, asked_lo, asked_hi, signed)
|
|
521
|
+
elif math.isfinite(asked_lo) and math.isfinite(asked_hi):
|
|
522
|
+
text = _integral_text(signed)
|
|
523
|
+
else:
|
|
524
|
+
# Not a density: the infinite limit stopped at the edge of the view.
|
|
525
|
+
text = _integral_text(signed).replace("∫ = ", "∫ ≈ ", 1)
|
|
526
|
+
xc, yc = _centroid(xs, ys, baseline)
|
|
527
|
+
# A thin area (a 2.5% tail) has its centroid on the axis, under the
|
|
528
|
+
# label's backing. Put the label just above the shading instead.
|
|
529
|
+
peak = _curve_peak(primary)
|
|
530
|
+
if peak is not None and baseline == 0.0:
|
|
531
|
+
top = float(np.max(ys))
|
|
532
|
+
if 0.0 <= top < 0.25 * peak:
|
|
533
|
+
yc = top + 0.12 * peak
|
|
534
|
+
shade = _area_layer(
|
|
535
|
+
xs, ys, baseline, [[0, int(xs.size)]], color=geom.const_color, alpha=0.35
|
|
536
|
+
)
|
|
537
|
+
shade._annotations = [{"x": float(xc), "y": float(yc), "text": text}]
|
|
538
|
+
return shade
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _area_layer(xs, ys, baseline, groups, *, color, alpha):
|
|
542
|
+
out = _Geom(aes(x="x", y="y"), color=color, alpha=alpha)
|
|
543
|
+
out.kind = "area"
|
|
544
|
+
out.data_override = pd.DataFrame(
|
|
545
|
+
{"x": np.asarray(xs, dtype=np.float64), "y": np.asarray(ys, dtype=np.float64)}
|
|
546
|
+
)
|
|
547
|
+
out._groups = groups
|
|
548
|
+
out._replace_mapping = True
|
|
549
|
+
out._baseline = float(baseline)
|
|
550
|
+
out._inherit_color = True
|
|
551
|
+
out._is_formula = True
|
|
552
|
+
out.linewidth = 1.5
|
|
553
|
+
out.const_color = color
|
|
554
|
+
out.alpha = alpha
|
|
555
|
+
out.sort_x = False
|
|
556
|
+
return out
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _tangent_layers(geom, formula, axes, primary, addon) -> list:
|
|
560
|
+
if axes.kind != "curve" or axes.computed == "x":
|
|
561
|
+
raise ExprError(
|
|
562
|
+
'tangent() is for a curve y = f(x). For example '
|
|
563
|
+
'geom_function("y = x^2") + tangent(at=1)'
|
|
564
|
+
)
|
|
565
|
+
x0 = float(addon.at)
|
|
566
|
+
y0 = _scalar(formula, axes, x0)
|
|
567
|
+
if not np.isfinite(y0):
|
|
568
|
+
raise ExprError(f"tangent() is undefined at x={_num(x0)}")
|
|
569
|
+
slope = _slope(formula, axes, x0)
|
|
570
|
+
if not np.isfinite(slope):
|
|
571
|
+
raise ExprError(f"tangent() is undefined at x={_num(x0)}")
|
|
572
|
+
frame = primary.data_override
|
|
573
|
+
xs = np.asarray(frame["x"], dtype=np.float64)
|
|
574
|
+
finite = xs[np.isfinite(xs)]
|
|
575
|
+
xlo = float(np.min(finite))
|
|
576
|
+
xhi = float(np.max(finite))
|
|
577
|
+
line_x = np.array([xlo, xhi], dtype=np.float64)
|
|
578
|
+
line_y = y0 + slope * (line_x - x0)
|
|
579
|
+
line = geom_line(
|
|
580
|
+
aes(x="x", y="y"),
|
|
581
|
+
linewidth=1.75,
|
|
582
|
+
alpha=0.95,
|
|
583
|
+
)
|
|
584
|
+
line.data_override = pd.DataFrame({"x": line_x, "y": line_y})
|
|
585
|
+
line._groups = [[0, 2]]
|
|
586
|
+
line._replace_mapping = True
|
|
587
|
+
line._legend_label = "tangent"
|
|
588
|
+
line._is_formula = True
|
|
589
|
+
token = f"tangent-{id(line)}"
|
|
590
|
+
line._color_key = token
|
|
591
|
+
point = geom_point(aes(x="x", y="y"), size=7, alpha=1)
|
|
592
|
+
point.data_override = pd.DataFrame({"x": [x0], "y": [y0]})
|
|
593
|
+
point._replace_mapping = True
|
|
594
|
+
point._inherit_color = True
|
|
595
|
+
point._is_formula = True
|
|
596
|
+
point._inherit_from = token
|
|
597
|
+
return [line, point]
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _derivative_layer(geom, formula, axes, primary):
|
|
601
|
+
if axes.kind != "curve" or axes.computed == "x":
|
|
602
|
+
raise ExprError(
|
|
603
|
+
"derivative() is for a curve y = f(x). "
|
|
604
|
+
'For example geom_function("y = x^2") + derivative()'
|
|
605
|
+
)
|
|
606
|
+
frame = primary.data_override
|
|
607
|
+
xs = np.asarray(frame["x"], dtype=np.float64)
|
|
608
|
+
var = formula.variables[0] if formula.variables else "x"
|
|
609
|
+
tree = differentiate(formula, var)
|
|
610
|
+
if tree is not None:
|
|
611
|
+
values = _eval_tree(formula, tree, {var: xs})
|
|
612
|
+
legend = _prime_texts(formula, tree)
|
|
613
|
+
else:
|
|
614
|
+
values = _numeric_derivative(formula, axes, xs)
|
|
615
|
+
legend = None
|
|
616
|
+
out = geom_line(
|
|
617
|
+
aes(x="x", y="y"),
|
|
618
|
+
linewidth=2.0 if geom.linewidth is None else geom.linewidth,
|
|
619
|
+
alpha=geom.alpha if geom.alpha is not None else 0.95,
|
|
620
|
+
)
|
|
621
|
+
out.data_override = pd.DataFrame({"x": xs, "y": np.asarray(values, dtype=np.float64)})
|
|
622
|
+
out._groups = [[0, int(xs.size)]]
|
|
623
|
+
out._replace_mapping = True
|
|
624
|
+
out._is_formula = True
|
|
625
|
+
if legend is not None:
|
|
626
|
+
out._legend_label = legend["pretty"]
|
|
627
|
+
out._legend_latex = legend["latex"]
|
|
628
|
+
out._tip_pretty = legend["pretty"]
|
|
629
|
+
out._tip_latex = legend["latex"]
|
|
630
|
+
else:
|
|
631
|
+
out._legend_label = "y'"
|
|
632
|
+
out._tip_pretty = "y'"
|
|
633
|
+
out._tip_latex = "y'"
|
|
634
|
+
return out
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _mark_layers(geom, formula, axes, primary, marks) -> list:
|
|
638
|
+
if axes.kind != "curve":
|
|
639
|
+
raise ExprError(
|
|
640
|
+
"mark='roots' and mark='extrema' are for a curve y = f(x)"
|
|
641
|
+
)
|
|
642
|
+
frame = primary.data_override
|
|
643
|
+
if axes.computed == "x":
|
|
644
|
+
samples = np.asarray(frame["y"], dtype=np.float64)
|
|
645
|
+
values = np.asarray(frame["x"], dtype=np.float64)
|
|
646
|
+
sample_at = lambda value: _scalar_swapped(formula, axes, value)
|
|
647
|
+
else:
|
|
648
|
+
samples = np.asarray(frame["x"], dtype=np.float64)
|
|
649
|
+
values = np.asarray(frame["y"], dtype=np.float64)
|
|
650
|
+
sample_at = lambda value: _scalar(formula, axes, value)
|
|
651
|
+
layers = []
|
|
652
|
+
if "roots" in marks:
|
|
653
|
+
roots = _roots(samples, values, sample_at)
|
|
654
|
+
if roots:
|
|
655
|
+
layers.append(_point_mark(
|
|
656
|
+
samples_at(samples, values, roots, axes),
|
|
657
|
+
"roots",
|
|
658
|
+
))
|
|
659
|
+
if "extrema" in marks:
|
|
660
|
+
spots = _extrema(samples, values, sample_at)
|
|
661
|
+
if spots:
|
|
662
|
+
layers.append(_point_mark(
|
|
663
|
+
samples_at(samples, values, spots, axes),
|
|
664
|
+
"extrema",
|
|
665
|
+
))
|
|
666
|
+
return layers
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def samples_at(samples, values, places, axes) -> pd.DataFrame:
|
|
670
|
+
xs = []
|
|
671
|
+
ys = []
|
|
672
|
+
for place in places:
|
|
673
|
+
if axes.computed == "x":
|
|
674
|
+
xs.append(float(_interp(samples, values, place)))
|
|
675
|
+
ys.append(place)
|
|
676
|
+
else:
|
|
677
|
+
xs.append(place)
|
|
678
|
+
ys.append(float(_interp(samples, values, place)))
|
|
679
|
+
return pd.DataFrame({"x": xs, "y": ys})
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _point_mark(frame: pd.DataFrame, label: str):
|
|
683
|
+
out = geom_point(aes(x="x", y="y"), size=8, alpha=1)
|
|
684
|
+
out.data_override = frame
|
|
685
|
+
out._replace_mapping = True
|
|
686
|
+
out._legend_label = label
|
|
687
|
+
out._is_formula = True
|
|
688
|
+
return out
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _roots(samples, values, f) -> list[float]:
|
|
692
|
+
finite = values[np.isfinite(values)]
|
|
693
|
+
if finite.size == 0:
|
|
694
|
+
return []
|
|
695
|
+
scale = max(float(np.max(np.abs(finite))), 1.0)
|
|
696
|
+
tol = 1e-8 * scale
|
|
697
|
+
found: list[float] = []
|
|
698
|
+
for index, value in enumerate(values):
|
|
699
|
+
if np.isfinite(value) and abs(float(value)) <= tol:
|
|
700
|
+
found.append(float(samples[index]))
|
|
701
|
+
for index in range(values.size - 1):
|
|
702
|
+
left, right = float(values[index]), float(values[index + 1])
|
|
703
|
+
if not np.isfinite(left) or not np.isfinite(right) or left * right >= 0:
|
|
704
|
+
continue
|
|
705
|
+
found.append(_bisect(f, float(samples[index]), float(samples[index + 1])))
|
|
706
|
+
return _unique(found)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _extrema(samples, values, f) -> list[float]:
|
|
710
|
+
found: list[float] = []
|
|
711
|
+
|
|
712
|
+
def slope(x: float) -> float:
|
|
713
|
+
step = 1e-5 * max(1.0, abs(x))
|
|
714
|
+
return f(x + step) - f(x - step)
|
|
715
|
+
|
|
716
|
+
slopes = np.array(
|
|
717
|
+
[slope(float(x)) if np.isfinite(x) else np.nan for x in samples],
|
|
718
|
+
dtype=np.float64,
|
|
719
|
+
)
|
|
720
|
+
finite_s = slopes[np.isfinite(slopes)]
|
|
721
|
+
scale = float(np.max(np.abs(finite_s))) if finite_s.size else 1.0
|
|
722
|
+
tol = 1e-6 * max(scale, 1e-12)
|
|
723
|
+
# A sample that lands on the vertex has slope zero, so the brackets
|
|
724
|
+
# on either side do not change sign. Keep it when it is a local min or max.
|
|
725
|
+
for index in range(1, int(samples.size) - 1):
|
|
726
|
+
if not np.isfinite(values[index]) or not np.isfinite(slopes[index]):
|
|
727
|
+
continue
|
|
728
|
+
if abs(float(slopes[index])) > tol:
|
|
729
|
+
continue
|
|
730
|
+
left = float(values[index - 1])
|
|
731
|
+
mid = float(values[index])
|
|
732
|
+
right = float(values[index + 1])
|
|
733
|
+
if not (np.isfinite(left) and np.isfinite(right)):
|
|
734
|
+
continue
|
|
735
|
+
low = (mid < left and mid <= right) or (mid <= left and mid < right)
|
|
736
|
+
high = (mid > left and mid >= right) or (mid >= left and mid > right)
|
|
737
|
+
if low or high:
|
|
738
|
+
found.append(float(samples[index]))
|
|
739
|
+
for index in range(int(samples.size) - 1):
|
|
740
|
+
a = float(samples[index])
|
|
741
|
+
b = float(samples[index + 1])
|
|
742
|
+
if not np.isfinite(values[index]) or not np.isfinite(values[index + 1]):
|
|
743
|
+
continue
|
|
744
|
+
da, db = float(slopes[index]), float(slopes[index + 1])
|
|
745
|
+
if not np.isfinite(da) or not np.isfinite(db) or da * db >= 0:
|
|
746
|
+
continue
|
|
747
|
+
if abs(da) <= tol or abs(db) <= tol:
|
|
748
|
+
continue
|
|
749
|
+
found.append(_bisect(slope, a, b))
|
|
750
|
+
return _unique(found)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _bisect(f, lo: float, hi: float) -> float:
|
|
754
|
+
a, b = lo, hi
|
|
755
|
+
fa, fb = f(a), f(b)
|
|
756
|
+
if not np.isfinite(fa) or not np.isfinite(fb):
|
|
757
|
+
return 0.5 * (lo + hi)
|
|
758
|
+
for _ in range(60):
|
|
759
|
+
mid = 0.5 * (a + b)
|
|
760
|
+
fm = f(mid)
|
|
761
|
+
if not np.isfinite(fm) or abs(b - a) < 1e-12 * max(1.0, abs(mid)):
|
|
762
|
+
return mid
|
|
763
|
+
if fa * fm <= 0:
|
|
764
|
+
b, fb = mid, fm
|
|
765
|
+
else:
|
|
766
|
+
a, fa = mid, fm
|
|
767
|
+
return 0.5 * (a + b)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _unique(values: list[float], tol: float = 1e-6) -> list[float]:
|
|
771
|
+
ordered = sorted(values)
|
|
772
|
+
out: list[float] = []
|
|
773
|
+
for value in ordered:
|
|
774
|
+
if not out or abs(value - out[-1]) > tol * max(1.0, abs(value)):
|
|
775
|
+
out.append(value)
|
|
776
|
+
return out
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _interp(samples, values, at: float) -> float:
|
|
780
|
+
"""Value of the sampled series at ``at`` along the sample axis."""
|
|
781
|
+
if at <= float(samples[0]):
|
|
782
|
+
return float(values[0])
|
|
783
|
+
if at >= float(samples[-1]):
|
|
784
|
+
return float(values[-1])
|
|
785
|
+
index = int(np.searchsorted(samples, at))
|
|
786
|
+
left, right = float(samples[index - 1]), float(samples[index])
|
|
787
|
+
span = right - left
|
|
788
|
+
if span == 0:
|
|
789
|
+
return float(values[index])
|
|
790
|
+
weight = (at - left) / span
|
|
791
|
+
return float(values[index - 1] + weight * (values[index] - values[index - 1]))
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _crossings(left, right) -> list[tuple[float, float]]:
|
|
795
|
+
ax = np.asarray(left.data_override["x"], dtype=np.float64)
|
|
796
|
+
ay = np.asarray(left.data_override["y"], dtype=np.float64)
|
|
797
|
+
bx = np.asarray(right.data_override["x"], dtype=np.float64)
|
|
798
|
+
by = np.asarray(right.data_override["y"], dtype=np.float64)
|
|
799
|
+
if not _increasing(ax) or not _increasing(bx):
|
|
800
|
+
return []
|
|
801
|
+
lo = max(float(ax[0]), float(bx[0]))
|
|
802
|
+
hi = min(float(ax[-1]), float(bx[-1]))
|
|
803
|
+
if hi <= lo:
|
|
804
|
+
return []
|
|
805
|
+
grid = _linspace(lo, hi, 401)
|
|
806
|
+
diff = np.interp(grid, ax, ay) - np.interp(grid, bx, by)
|
|
807
|
+
found: list[tuple[float, float]] = []
|
|
808
|
+
scale = max(float(np.nanmax(np.abs(diff))), 1.0)
|
|
809
|
+
for index, value in enumerate(diff):
|
|
810
|
+
if np.isfinite(value) and abs(float(value)) <= 1e-8 * scale:
|
|
811
|
+
x = float(grid[index])
|
|
812
|
+
y = float(np.interp(x, ax, ay))
|
|
813
|
+
found.append((x, y))
|
|
814
|
+
for index in range(diff.size - 1):
|
|
815
|
+
a, b = float(diff[index]), float(diff[index + 1])
|
|
816
|
+
if not np.isfinite(a) or not np.isfinite(b) or a * b >= 0:
|
|
817
|
+
continue
|
|
818
|
+
if abs(a) <= 1e-8 * scale or abs(b) <= 1e-8 * scale:
|
|
819
|
+
continue
|
|
820
|
+
weight = abs(a) / (abs(a) + abs(b))
|
|
821
|
+
x = float(grid[index] + weight * (grid[index + 1] - grid[index]))
|
|
822
|
+
y = float(np.interp(x, ax, ay))
|
|
823
|
+
found.append((x, y))
|
|
824
|
+
unique: list[tuple[float, float]] = []
|
|
825
|
+
for point in found:
|
|
826
|
+
if any(abs(point[0] - kept[0]) <= 1e-5 * max(1.0, abs(point[0])) for kept in unique):
|
|
827
|
+
continue
|
|
828
|
+
unique.append(point)
|
|
829
|
+
return unique
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _increasing(values: np.ndarray) -> bool:
|
|
833
|
+
if values.size < 2:
|
|
834
|
+
return False
|
|
835
|
+
delta = np.diff(values)
|
|
836
|
+
good = delta[np.isfinite(delta)]
|
|
837
|
+
return bool(good.size) and bool(np.all(good > 0))
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _is_function_curve(layer) -> bool:
|
|
841
|
+
if not getattr(layer, "_formula_primary", False):
|
|
842
|
+
return False
|
|
843
|
+
if getattr(layer, "kind", "") not in {"line", ""}:
|
|
844
|
+
return False
|
|
845
|
+
frame = getattr(layer, "data_override", None)
|
|
846
|
+
if frame is None or "x" not in getattr(frame, "columns", []):
|
|
847
|
+
return False
|
|
848
|
+
if "z" in frame.columns:
|
|
849
|
+
return False
|
|
850
|
+
return True
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _arrows(xx, yy, dx, dy, length: float):
|
|
854
|
+
rows: list[tuple[float, float]] = []
|
|
855
|
+
groups: list[list[int]] = []
|
|
856
|
+
flat_x = np.asarray(xx, dtype=np.float64).ravel()
|
|
857
|
+
flat_y = np.asarray(yy, dtype=np.float64).ravel()
|
|
858
|
+
flat_dx = np.asarray(dx, dtype=np.float64).ravel()
|
|
859
|
+
flat_dy = np.asarray(dy, dtype=np.float64).ravel()
|
|
860
|
+
for x, y, vx, vy in zip(flat_x, flat_y, flat_dx, flat_dy):
|
|
861
|
+
arrow = _arrow(float(x), float(y), float(vx), float(vy), length)
|
|
862
|
+
if arrow is None:
|
|
863
|
+
continue
|
|
864
|
+
start = len(rows)
|
|
865
|
+
rows.extend(arrow)
|
|
866
|
+
groups.append([start, len(arrow)])
|
|
867
|
+
return rows, groups
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _arrow(x, y, dx, dy, length: float):
|
|
871
|
+
mag = float(np.hypot(dx, dy))
|
|
872
|
+
if not np.isfinite(mag) or mag == 0.0 or not np.isfinite(x) or not np.isfinite(y):
|
|
873
|
+
return None
|
|
874
|
+
ux, uy = dx / mag, dy / mag
|
|
875
|
+
tail = (x - 0.5 * length * ux, y - 0.5 * length * uy)
|
|
876
|
+
head = (x + 0.5 * length * ux, y + 0.5 * length * uy)
|
|
877
|
+
back = 0.28 * length
|
|
878
|
+
wing = 0.16 * length
|
|
879
|
+
bx = head[0] - back * ux
|
|
880
|
+
by = head[1] - back * uy
|
|
881
|
+
barb1 = (bx - wing * uy, by + wing * ux)
|
|
882
|
+
barb2 = (bx + wing * uy, by - wing * ux)
|
|
883
|
+
return [tail, head, barb1, head, barb2]
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _streamlines(formula, xs, ys, cell, xlim, ylim):
|
|
887
|
+
stride = max(1, int(len(xs) / 6))
|
|
888
|
+
seeds = [
|
|
889
|
+
(float(xs[i]), float(ys[j]))
|
|
890
|
+
for j in range(0, len(ys), stride)
|
|
891
|
+
for i in range(0, len(xs), stride)
|
|
892
|
+
]
|
|
893
|
+
step = 0.45 * cell
|
|
894
|
+
rows: list[tuple[float, float]] = []
|
|
895
|
+
groups: list[list[int]] = []
|
|
896
|
+
for sx, sy in seeds:
|
|
897
|
+
for direction in (1.0, -1.0):
|
|
898
|
+
points = [(sx, sy)]
|
|
899
|
+
x, y = sx, sy
|
|
900
|
+
for _ in range(48):
|
|
901
|
+
vx, vy = _field_at(formula, x, y)
|
|
902
|
+
mag = float(np.hypot(vx, vy))
|
|
903
|
+
if not np.isfinite(mag) or mag < 1e-12:
|
|
904
|
+
break
|
|
905
|
+
x += direction * step * vx / mag
|
|
906
|
+
y += direction * step * vy / mag
|
|
907
|
+
if x < xlim[0] or x > xlim[1] or y < ylim[0] or y > ylim[1]:
|
|
908
|
+
break
|
|
909
|
+
points.append((x, y))
|
|
910
|
+
if len(points) < 2:
|
|
911
|
+
continue
|
|
912
|
+
start = len(rows)
|
|
913
|
+
rows.extend(points)
|
|
914
|
+
groups.append([start, len(points)])
|
|
915
|
+
return rows, groups
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _field_at(formula, x: float, y: float) -> tuple[float, float]:
|
|
919
|
+
env = {"x": np.array([x]), "y": np.array([y])}
|
|
920
|
+
dx = float(np.asarray(_component(formula, "dx", env)).reshape(-1)[0])
|
|
921
|
+
dy = float(np.asarray(_component(formula, "dy", env)).reshape(-1)[0])
|
|
922
|
+
return dx, dy
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _component(formula: Formula, name: str, variables: dict[str, np.ndarray]):
|
|
926
|
+
code = dict(formula.components)[name]
|
|
927
|
+
env: dict[str, Any] = {"__builtins__": {}}
|
|
928
|
+
env.update(formula.namespace)
|
|
929
|
+
env.update(variables)
|
|
930
|
+
with np.errstate(all="ignore"):
|
|
931
|
+
try:
|
|
932
|
+
value = eval(code, env) # noqa: S307
|
|
933
|
+
except Exception as exc:
|
|
934
|
+
raise ExprError(f"could not evaluate formula: {exc}") from exc
|
|
935
|
+
return np.asarray(value, dtype=np.float64)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _eval_tree(formula: Formula, tree, variables: dict[str, np.ndarray]):
|
|
939
|
+
code = _compile_tree(tree)
|
|
940
|
+
env: dict[str, Any] = {"__builtins__": {}}
|
|
941
|
+
env.update(formula.namespace)
|
|
942
|
+
env.update(variables)
|
|
943
|
+
with np.errstate(all="ignore"):
|
|
944
|
+
value = eval(code, env) # noqa: S307
|
|
945
|
+
return np.asarray(value, dtype=np.float64)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _scalar(formula, axes, x: float) -> float:
|
|
949
|
+
values = _curve_values(formula, axes, np.array([x], dtype=np.float64))
|
|
950
|
+
return float(np.asarray(values, dtype=np.float64).reshape(-1)[0])
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _scalar_swapped(formula, axes, y: float) -> float:
|
|
954
|
+
"""Value of a sideways ``x = f(y)`` at one y."""
|
|
955
|
+
del axes
|
|
956
|
+
if formula.variables:
|
|
957
|
+
values = _call_formula(formula, {formula.variables[0]: np.array([y])})
|
|
958
|
+
else:
|
|
959
|
+
values = evaluate(formula, {})
|
|
960
|
+
return float(np.asarray(values, dtype=np.float64).reshape(-1)[0])
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _slope(formula, axes, x0: float) -> float:
|
|
964
|
+
var = formula.variables[0] if formula.variables else "x"
|
|
965
|
+
tree = differentiate(formula, var)
|
|
966
|
+
if tree is not None:
|
|
967
|
+
value = _eval_tree(formula, tree, {var: np.array([x0])})
|
|
968
|
+
return float(np.asarray(value).reshape(-1)[0])
|
|
969
|
+
step = 1e-5 * max(1.0, abs(x0))
|
|
970
|
+
return (_scalar(formula, axes, x0 + step) - _scalar(formula, axes, x0 - step)) / (2 * step)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _numeric_derivative(formula, axes, xs: np.ndarray) -> np.ndarray:
|
|
974
|
+
span = float(xs[-1] - xs[0]) if xs.size else 1.0
|
|
975
|
+
step = max(span / max(xs.size, 2) * 0.25, 1e-5)
|
|
976
|
+
above = _curve_values(formula, axes, xs + step)
|
|
977
|
+
below = _curve_values(formula, axes, xs - step)
|
|
978
|
+
return (above - below) / (2 * step)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _prime_texts(formula: Formula, tree) -> dict[str, str]:
|
|
982
|
+
dependent = formula.dependent or "y"
|
|
983
|
+
raw = formula_texts(
|
|
984
|
+
__import__("ast").Name(id=dependent, ctx=__import__("ast").Load()),
|
|
985
|
+
tree,
|
|
986
|
+
mode="explicit",
|
|
987
|
+
dependent=dependent,
|
|
988
|
+
)
|
|
989
|
+
primed = {}
|
|
990
|
+
for key, text in raw.items():
|
|
991
|
+
primed[key] = text.replace(f"{dependent} =", f"{dependent}' =", 1)
|
|
992
|
+
return primed
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _simpson(xs: np.ndarray, ys: np.ndarray) -> float:
|
|
996
|
+
intervals = int(ys.size - 1)
|
|
997
|
+
if intervals < 2 or intervals % 2 == 1:
|
|
998
|
+
raise ExprError("area() needs an even number of steps")
|
|
999
|
+
step = (float(xs[-1]) - float(xs[0])) / intervals
|
|
1000
|
+
total = (
|
|
1001
|
+
float(ys[0])
|
|
1002
|
+
+ float(ys[-1])
|
|
1003
|
+
+ 4.0 * float(np.sum(ys[1:-1:2]))
|
|
1004
|
+
+ 2.0 * float(np.sum(ys[2:-1:2]))
|
|
1005
|
+
)
|
|
1006
|
+
return step / 3.0 * total
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _trap(values: np.ndarray, step: float) -> float:
|
|
1010
|
+
if values.size == 1:
|
|
1011
|
+
return float(values[0])
|
|
1012
|
+
return float(step * (0.5 * values[0] + 0.5 * values[-1] + np.sum(values[1:-1])))
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _centroid(xs, ys, baseline: float) -> tuple[float, float]:
|
|
1016
|
+
step = float(xs[1] - xs[0])
|
|
1017
|
+
height = ys - baseline
|
|
1018
|
+
weight = np.abs(height)
|
|
1019
|
+
mass = _trap(weight, step)
|
|
1020
|
+
if mass < 1e-14:
|
|
1021
|
+
return float(0.5 * (xs[0] + xs[-1])), float(baseline)
|
|
1022
|
+
xc = _trap(xs * weight, step) / mass
|
|
1023
|
+
yc = _trap((baseline + 0.5 * height) * weight, step) / mass
|
|
1024
|
+
return float(xc), float(yc)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _curve_peak(primary) -> float | None:
|
|
1028
|
+
frame = getattr(primary, "data_override", None)
|
|
1029
|
+
if frame is None or "y" not in frame:
|
|
1030
|
+
return None
|
|
1031
|
+
ys = np.asarray(frame["y"], dtype=np.float64)
|
|
1032
|
+
ys = ys[np.isfinite(ys)]
|
|
1033
|
+
if ys.size == 0 or float(ys.max()) <= 0.0:
|
|
1034
|
+
return None
|
|
1035
|
+
return float(ys.max())
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _curve_edges(primary) -> tuple[float | None, float | None]:
|
|
1039
|
+
frame = getattr(primary, "data_override", None)
|
|
1040
|
+
if frame is None or "x" not in frame:
|
|
1041
|
+
return None, None
|
|
1042
|
+
xs = np.asarray(frame["x"], dtype=np.float64)
|
|
1043
|
+
xs = xs[np.isfinite(xs)]
|
|
1044
|
+
if xs.size == 0:
|
|
1045
|
+
return None, None
|
|
1046
|
+
return float(xs.min()), float(xs.max())
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _is_density(formula, axes, lo, hi, count) -> bool:
|
|
1050
|
+
"""Non-negative and integrates to 1, counting the tails past the view."""
|
|
1051
|
+
if lo is None or hi is None or hi <= lo:
|
|
1052
|
+
return False
|
|
1053
|
+
xs = _linspace(lo, hi, count)
|
|
1054
|
+
ys = _curve_values(formula, axes, xs)
|
|
1055
|
+
if ys.shape != xs.shape or not np.all(np.isfinite(ys)):
|
|
1056
|
+
return False
|
|
1057
|
+
if float(np.min(ys)) < -1e-12:
|
|
1058
|
+
return False
|
|
1059
|
+
width = hi - lo
|
|
1060
|
+
total = (
|
|
1061
|
+
_simpson(xs, ys)
|
|
1062
|
+
+ _tail_mass(formula, axes, lo, -1.0, width)
|
|
1063
|
+
+ _tail_mass(formula, axes, hi, 1.0, width)
|
|
1064
|
+
)
|
|
1065
|
+
return abs(total - 1.0) <= 0.005
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _tail_mass(formula, axes, edge: float, direction: float, width: float) -> float:
|
|
1069
|
+
"""Integral from ``edge`` outward, in doubling steps, until it stops adding.
|
|
1070
|
+
|
|
1071
|
+
Returns 0 for a curve that is not finite and non-negative out there,
|
|
1072
|
+
so an ordinary function never gains a spurious tail.
|
|
1073
|
+
"""
|
|
1074
|
+
total = 0.0
|
|
1075
|
+
start = float(edge)
|
|
1076
|
+
span = max(float(width), 1e-9)
|
|
1077
|
+
for _step in range(40):
|
|
1078
|
+
stop = start + direction * span
|
|
1079
|
+
xs = _linspace(min(start, stop), max(start, stop), 201)
|
|
1080
|
+
ys = _curve_values(formula, axes, xs)
|
|
1081
|
+
if ys.shape != xs.shape or not np.all(np.isfinite(ys)) or float(np.min(ys)) < 0.0:
|
|
1082
|
+
return total
|
|
1083
|
+
piece = _simpson(xs, ys)
|
|
1084
|
+
total += piece
|
|
1085
|
+
if piece <= 1e-9 * max(total, 1e-12) or piece < 1e-12:
|
|
1086
|
+
break
|
|
1087
|
+
start = stop
|
|
1088
|
+
span *= 2.0
|
|
1089
|
+
return total
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _prob_number(value: float) -> str:
|
|
1093
|
+
value = min(max(value, 0.0), 1.0)
|
|
1094
|
+
if value == 0.0 or value >= 1e-3:
|
|
1095
|
+
text = f"{value:.3g}"
|
|
1096
|
+
else:
|
|
1097
|
+
mantissa, power = f"{value:.2e}".split("e")
|
|
1098
|
+
text = f"{mantissa} × 10^{int(power)}"
|
|
1099
|
+
return _pretty_minus(text.replace("^", "", 1).replace(
|
|
1100
|
+
str(int(power)), _superscript(int(power)), 1))
|
|
1101
|
+
return text
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _superscript(power: int) -> str:
|
|
1105
|
+
table = str.maketrans("0123456789-", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻")
|
|
1106
|
+
return str(power).translate(table)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _pretty_minus(text: str) -> str:
|
|
1110
|
+
return text.replace("-", "−")
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _probability_text(name: str, lo: float, hi: float, value: float) -> str:
|
|
1114
|
+
"""P(0.2 ≤ X ≤ 0.5) = 0.546, or a one-sided P(X ≤ −1.96) = 0.025."""
|
|
1115
|
+
var = name.upper() if len(name) == 1 else name
|
|
1116
|
+
if not math.isfinite(lo) and not math.isfinite(hi):
|
|
1117
|
+
event = f"−∞ < {var} < ∞"
|
|
1118
|
+
elif not math.isfinite(lo):
|
|
1119
|
+
event = f"{var} ≤ {_pretty_minus(f'{hi:.4g}')}"
|
|
1120
|
+
elif not math.isfinite(hi):
|
|
1121
|
+
event = f"{var} ≥ {_pretty_minus(f'{lo:.4g}')}"
|
|
1122
|
+
else:
|
|
1123
|
+
event = f"{_pretty_minus(f'{lo:.4g}')} ≤ {var} ≤ {_pretty_minus(f'{hi:.4g}')}"
|
|
1124
|
+
return f"P({event}) = {_prob_number(value)}"
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _integral_text(value: float) -> str:
|
|
1128
|
+
"""A short fraction such as 8/3, or a decimal when the fraction is not simple.
|
|
1129
|
+
|
|
1130
|
+
Simpson's error on a Beta density is within 1e-6 of 1965/3599, which is
|
|
1131
|
+
not an exact result. Denominators up to 12 keep 1/2, 1/3, and 8/3.
|
|
1132
|
+
"""
|
|
1133
|
+
frac = Fraction(value).limit_denominator(12)
|
|
1134
|
+
close = abs(float(frac) - value) <= 1e-6 * max(1.0, abs(value))
|
|
1135
|
+
if close:
|
|
1136
|
+
if frac.denominator == 1:
|
|
1137
|
+
body = str(frac.numerator)
|
|
1138
|
+
else:
|
|
1139
|
+
body = f"{frac.numerator}/{frac.denominator}"
|
|
1140
|
+
else:
|
|
1141
|
+
body = f"{value:.4g}"
|
|
1142
|
+
return f"∫ = {body.replace('-', '−')}"
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _runs(index: np.ndarray) -> list[list[int]]:
|
|
1146
|
+
if index.size == 0:
|
|
1147
|
+
return []
|
|
1148
|
+
groups = []
|
|
1149
|
+
start = 0
|
|
1150
|
+
for position in range(1, int(index.size)):
|
|
1151
|
+
if int(index[position]) != int(index[position - 1]) + 1:
|
|
1152
|
+
groups.append([start, position - start])
|
|
1153
|
+
start = position
|
|
1154
|
+
groups.append([start, int(index.size) - start])
|
|
1155
|
+
return groups
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _pair(value, name: str, default: tuple[float, float]) -> tuple[float, float]:
|
|
1159
|
+
if value is None:
|
|
1160
|
+
return default
|
|
1161
|
+
try:
|
|
1162
|
+
lo, hi = float(value[0]), float(value[1])
|
|
1163
|
+
except (TypeError, ValueError, IndexError) as exc:
|
|
1164
|
+
raise ExprError(f"{name} must be a pair of numbers, for example (-2, 2)") from exc
|
|
1165
|
+
if hi < lo:
|
|
1166
|
+
lo, hi = hi, lo
|
|
1167
|
+
if hi == lo:
|
|
1168
|
+
hi = lo + 1.0
|
|
1169
|
+
return lo, hi
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _limit_or_none(value, name: str):
|
|
1173
|
+
if value is None:
|
|
1174
|
+
return None
|
|
1175
|
+
return _pair(value, name, _DEFAULT_DOMAIN)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _num(value: float) -> str:
|
|
1179
|
+
return f"{value:.6g}"
|