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/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}"