qmlmathplot 0.4.3__py3-none-any.whl → 0.5.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.
qmlmathplot/curve.py CHANGED
@@ -3,8 +3,8 @@
3
3
  A curve knows nothing about the view: the camera decides where we look, the curve decides what is
4
4
  drawn. One curve = one fragment shader pass, so ``visible`` is the lever that costs nothing.
5
5
 
6
- Two artists share the class: ``Curve`` draws ``y = f(x)`` (see ``model.FRAGMENT_TEMPLATE``) and
7
- ``ImplicitCurve`` draws the level set ``F(x, y) = 0`` (``model.IMPLICIT_FRAGMENT_TEMPLATE``).
6
+ Two artists share the class: ``Curve`` draws ``y = f(x)`` (see ``shaders/oned_sampled.frag``) and
7
+ ``ImplicitCurve`` draws the level set ``F(x, y) = 0`` (``shaders/implicit.frag``).
8
8
  They differ only in the shader *sources* — the uniform block, the model roles and the QML side
9
9
  are the same — so ``curve_for()`` can pick one from the expression's free symbols.
10
10
  """
@@ -27,6 +27,7 @@ from PySide6.QtCore import (
27
27
  )
28
28
  from PySide6.QtGui import QColor
29
29
 
30
+ from .interval import compute_supported
30
31
  from .model import implicit_shader_sources, shader_sources
31
32
  from .qsb import bake
32
33
 
@@ -310,6 +311,8 @@ class Curve(QObject):
310
311
  def _get_fragment_shader(self) -> QUrl:
311
312
  return self._fragment
312
313
 
314
+ #: Empty when the compute renderer draws this expression: the RHI generates its path
315
+ #: from `expr`. Only the sampled fallback and implicit curves bake these shaders.
313
316
  vertexShader: QUrl = Property(QUrl, _get_vertex_shader, notify=shadersChanged)
314
317
  fragmentShader: QUrl = Property(QUrl, _get_fragment_shader, notify=shadersChanged)
315
318
 
@@ -340,8 +343,13 @@ class Curve(QObject):
340
343
  "first"
341
344
  )
342
345
  implicit = self._force_implicit if force_implicit is None else force_implicit
343
- return implicit_shader_sources(expr) if (implicit or "y" in names) \
344
- else shader_sources(expr)
346
+ if implicit or "y" in names:
347
+ return implicit_shader_sources(expr)
348
+ if compute_supported(expr):
349
+ # The compute renderer generates its shader from `expr`; baking the sampled
350
+ # fallback here would be dead work (qsb + the edge analysis, ~130 ms/curve).
351
+ return "", ""
352
+ return shader_sources(expr)
345
353
 
346
354
  def _start_bake(
347
355
  self,
@@ -364,6 +372,11 @@ class Curve(QObject):
364
372
  self._error = f"{type(exc).__name__}: {exc}"
365
373
  self.errorChanged.emit()
366
374
  return
375
+ if not vertex_source and not fragment_source:
376
+ self._pending_expr = expr
377
+ self._pending_implicit = force_implicit
378
+ self._on_bake_done(generation, (None, None))
379
+ return
367
380
  self._pending_expr = expr
368
381
  self._pending_implicit = force_implicit
369
382
  if synchronous or not self.bake_async or QCoreApplication.instance() is None:
@@ -397,13 +410,16 @@ class Curve(QObject):
397
410
  return
398
411
  if expr is None:
399
412
  return
400
- vertex, fragment = result # type: ignore[misc]
413
+ if result == (None, None):
414
+ vertex = fragment = None
415
+ else:
416
+ vertex, fragment = result # type: ignore[misc]
401
417
  self._expr = expr
402
418
  if implicit is not None:
403
419
  self._force_implicit = bool(implicit)
404
420
  self._error = ""
405
- self._vertex = QUrl.fromLocalFile(str(vertex))
406
- self._fragment = QUrl.fromLocalFile(str(fragment))
421
+ self._vertex = QUrl() if vertex is None else QUrl.fromLocalFile(str(vertex))
422
+ self._fragment = QUrl() if fragment is None else QUrl.fromLocalFile(str(fragment))
407
423
  self.exprChanged.emit()
408
424
  self.errorChanged.emit()
409
425
  self.shadersChanged.emit()
@@ -422,7 +438,7 @@ class ImplicitCurve(Curve):
422
438
 
423
439
 
424
440
  class CurveListModel(QAbstractListModel):
425
- """The plot's curves, as a model so QML can put one ShaderEffect behind each of them."""
441
+ """The plot's curves, as a model the QRhi renderer and QML chrome read."""
426
442
 
427
443
  CurveRole = int(Qt.ItemDataRole.UserRole) + 1
428
444
  ExpressionRole = CurveRole + 1
@@ -0,0 +1,55 @@
1
+ # SPDX-FileCopyrightText: 2007-2026 GeoGebra GmbH
2
+ # SPDX-License-Identifier: GPL-3.0-only
3
+ # Portions derived from GeoGebra (EUPL-1.2); see THIRD_PARTY_NOTICES.md.
4
+ """GLSL generation for the sequential GPU port of GGB's interval path.
5
+
6
+ One compute invocation walks the screen columns in order, evaluates ``FI`` (the interval of
7
+ the function on the column), and runs the same state machine as
8
+ :mod:`qmlmathplot.ggb_path`: ``IntervalPath`` (break/join), ``DrawInterval``
9
+ (joined/independent/whole), ``DrawInvertedInterval`` (gap rays) and ``JoinLines``
10
+ (neighbour-aware completion). The emitted segments are written into a storage buffer that a
11
+ tiny vertex/fragment pair draws; no CPU sampling is involved.
12
+
13
+ The neighbour state is taken from the previously evaluated column and a one-column prefetch,
14
+ so the whole pass pays one ``FI`` per column plus one. No scalar ``F`` evaluations are used:
15
+ the path is the interval's own endpoints, which is what GGB draws.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sympy as sp
21
+
22
+ from .shaderbuild import render, shader_source
23
+ from .interval import has_sign_pole, interval_glsl, interval_lib_for
24
+ from .model import _domain_is_pole_only, _non_negative, _pole_glsl, _pole_spacing
25
+
26
+ __all__ = ["PATH_VERTEX", "PATH_FRAGMENT", "path_compute_source", "path_subsamples"]
27
+
28
+ PATH_VERTEX = shader_source("path.vert")
29
+
30
+ PATH_FRAGMENT = shader_source("path.frag")
31
+
32
+
33
+ def path_subsamples(expression: str | sp.Basic) -> int:
34
+ # GGB FunctionSampler: one interval per screen column.
35
+ return 1
36
+
37
+
38
+
39
+
40
+ def path_compute_source(expression: str | sp.Basic) -> str:
41
+ expr = expression if isinstance(expression, sp.Basic) else sp.sympify(expression)
42
+ pole = has_sign_pole(expr)
43
+ intervals = interval_glsl(expr)
44
+ return render(
45
+ "interval_path.comp",
46
+ SUBSAMPLES=path_subsamples(expression),
47
+ POLE=_pole_glsl(expr, "u") if pole else "1.0",
48
+ HAS_POLE="1" if pole else "0",
49
+ POLE_SPACING=f"{_pole_spacing(expr):.17g}",
50
+ UNIFORM_FILL="1" if (
51
+ pole and _non_negative(expr) and _domain_is_pole_only(expr)
52
+ ) else "0",
53
+ INTERVAL_LIB=interval_lib_for(intervals),
54
+ INTERVALS=intervals,
55
+ )
@@ -0,0 +1,354 @@
1
+ # SPDX-FileCopyrightText: 2007-2026 GeoGebra GmbH
2
+ # SPDX-License-Identifier: GPL-3.0-only
3
+ # Portions derived from GeoGebra (EUPL-1.2); see THIRD_PARTY_NOTICES.md.
4
+ """GeoGebra's interval path as a state machine over column topologies.
5
+
6
+ This is the semantic core of ``org.geogebra.common.euclidian.plot.interval``:
7
+ ``IntervalPath`` (break/join state), ``DrawInterval`` (joined/independent/whole),
8
+ ``DrawInvertedInterval`` (gap rays) and ``JoinLines`` (neighbour-aware completion of the
9
+ inverted and infinite cases). It contains **no interval arithmetic and no sampling**: the
10
+ input is one y-topology per screen column (empty / connected / inverted / whole), the output
11
+ is screen-space segments.
12
+
13
+ Keeping the decisions here rather than inline in the GLSL gives the GPU path a host-testable
14
+ specification: synthetic topologies can exercise every branch without a GPU and without a
15
+ second interval evaluator. ``ggb_reference`` feeds it from ``mpmath.iv``; ``ggb_compute``
16
+ mirrors it in the compute shader, consuming the float32 ``INTERVAL_LIB`` topology.
17
+
18
+ Coordinates are screen pixels (y down), matching GGB's path. The y interval is stored in
19
+ world units; the conversions happen at the edges.
20
+
21
+ Both ``JoinLines`` comparisons are ported literally, because GGB's own plotter output
22
+ shows they matter: the top-edge equality is a **tolerance** (``MAX_PRECISION = 1e-12`` in
23
+ double; a float-safe relative tolerance on the GPU), and ``toBottom`` compares differences
24
+ **after** ``toScreenCoordYd``, which reverses their order. ``VERTICAL_MARGIN`` is not
25
+ reproduced; the GPU clips the segment to the viewport anyway.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from dataclasses import dataclass
32
+ from typing import Literal
33
+
34
+ __all__ = ["IVal", "Column", "PathPoint", "IntervalPath", "IINF"]
35
+
36
+ Kind = Literal["empty", "connected", "inverted", "whole"]
37
+
38
+ #: The GLSL library's infinity sentinel (``IINF``); real infinities are avoided on the GPU.
39
+ IINF = 1e30
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class IVal:
44
+ """A conservative y value: a connected range, a gap, the whole line, or empty."""
45
+
46
+ lo: float
47
+ hi: float
48
+ kind: Kind = "connected"
49
+
50
+ @property
51
+ def empty(self) -> bool:
52
+ return self.kind == "empty"
53
+
54
+ @property
55
+ def whole(self) -> bool:
56
+ return self.kind == "whole"
57
+
58
+ @property
59
+ def inverted(self) -> bool:
60
+ return self.kind == "inverted"
61
+
62
+ def has_infinity(self) -> bool:
63
+ if self.kind == "empty":
64
+ return False
65
+ if self.kind == "whole":
66
+ return True
67
+ return self.lo <= -IINF or self.hi >= IINF
68
+
69
+ # Payload edges: an inverted value stores the excluded gap, a whole value is unbounded.
70
+ @property
71
+ def payload_lo(self) -> float:
72
+ if self.kind == "whole":
73
+ return -IINF
74
+ return self.lo
75
+
76
+ @property
77
+ def payload_hi(self) -> float:
78
+ if self.kind == "whole":
79
+ return IINF
80
+ return self.hi
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Column:
85
+ x_lo: float
86
+ x_hi: float
87
+ y: IVal
88
+
89
+
90
+ @dataclass
91
+ class PathPoint:
92
+ x: float
93
+ y: float
94
+
95
+
96
+ def _near(a: float, b: float, fraction: float = 1e-9) -> bool:
97
+ """``DoubleUtil.isEqual(0, |a-b|, MAX_PRECISION)`` with a scale-aware epsilon.
98
+
99
+ The double version uses 1e-12; the GPU needs a larger relative epsilon for float32.
100
+ """
101
+ return abs(a - b) <= fraction * max(1.0, abs(a), abs(b))
102
+
103
+
104
+ class IntervalPath:
105
+ """Turns per-column topologies into a path, the way GGB's ``IntervalPath`` does.
106
+
107
+ ``columns`` must be in screen-column order. The x interval of a column is its world
108
+ span; ``width``/``height`` are the view's pixel size and the ``x_*``/``y_*`` arguments
109
+ are its world bounds.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ columns: list[Column],
115
+ width: float,
116
+ height: float,
117
+ x_lo: float,
118
+ x_hi: float,
119
+ y_lo: float,
120
+ y_hi: float,
121
+ ) -> None:
122
+ self.columns = columns
123
+ self.width = float(width)
124
+ self.height = float(height)
125
+ self.x_lo = float(x_lo)
126
+ self.x_hi = float(x_hi)
127
+ self.y_lo = float(y_lo)
128
+ self.y_hi = float(y_hi)
129
+ self.segments: list[tuple[PathPoint, PathPoint]] = []
130
+ self._point: PathPoint | None = None
131
+ self._last: IVal | None = None
132
+
133
+ # ------------------------------------------------------------------ driver
134
+ def update(self) -> list[tuple[PathPoint, PathPoint]]:
135
+ """Reset and rebuild the path; returns screen-space segments."""
136
+ del self.segments[:]
137
+ self._point = None
138
+ self._last = None
139
+ for index in range(len(self.columns)):
140
+ self._draw_at(index)
141
+ return list(self.segments)
142
+
143
+ def _draw_at(self, index: int) -> None:
144
+ column = self.columns[index]
145
+ y = column.y
146
+ if y.empty:
147
+ self._last = None
148
+ return
149
+ if self._last is None:
150
+ self._draw_independent(index)
151
+ elif y.whole:
152
+ self._draw_whole(column)
153
+ elif y.inverted:
154
+ self._draw_inverted(index)
155
+ elif y.has_infinity():
156
+ self._draw_normal_infinity(column)
157
+ else:
158
+ self._draw_joined(column)
159
+
160
+ # ------------------------------------------------------- DrawInterval
161
+ def _draw_joined(self, column: Column) -> None:
162
+ """``DrawInterval.drawUp``/``drawDown``: connect to the previous column edge."""
163
+ assert self._last is not None
164
+ y = column.y
165
+ # GGB compares the *screen* high edges; screen high is the world low edge.
166
+ up = y.lo < self._last.lo
167
+ if up:
168
+ first = (self._sx(column.x_lo), self._sy(y.hi))
169
+ second = (self._sx(column.x_hi), self._sy(y.lo))
170
+ else:
171
+ first = (self._sx(column.x_lo), self._sy(y.lo))
172
+ second = (self._sx(column.x_hi), self._sy(y.hi))
173
+ self._line_to(*first)
174
+ self._line_to(*second)
175
+ self._last = y
176
+
177
+ def _draw_independent(self, index: int) -> None:
178
+ """``IntervalPath.drawTupleIndependent``."""
179
+ column = self.columns[index]
180
+ y = column.y
181
+ if y.whole:
182
+ self._draw_whole(column)
183
+ return
184
+ if y.inverted:
185
+ self._draw_inverted(index)
186
+ return
187
+ # DrawInterval.drawIndependent -> line(x, y): a vertical at the right edge. Infinities
188
+ # are clamped by ``_sy``; the segment runs off screen and is clipped.
189
+ self._segment(
190
+ (self._sx(column.x_hi), self._sy(y.hi)),
191
+ (self._sx(column.x_hi), self._sy(y.lo)),
192
+ )
193
+ self._last = y
194
+
195
+ def _draw_whole(self, column: Column) -> None:
196
+ """``DrawInterval.drawWhole``: one vertical at x.low, then a path break."""
197
+ x = self._sx(column.x_lo)
198
+ self._segment((x, 0.0), (x, self.height))
199
+ self._last = None
200
+
201
+ # ------------------------------------------------ DrawNormalInfinity
202
+ def _draw_normal_infinity(self, column: Column) -> None:
203
+ """``IntervalPath.drawNormalInfinity`` / ``IntervalPathPlotter.leftToTop``."""
204
+ y = column.y
205
+ if y.lo <= -IINF and y.hi >= IINF:
206
+ self._last = None
207
+ return
208
+ if y.hi >= IINF:
209
+ if self.y_lo <= y.lo <= self.y_hi:
210
+ self._segment(
211
+ (self._sx(column.x_lo), self._sy(y.lo)),
212
+ (self._sx((column.x_lo + column.x_hi) * 0.5), 0.0),
213
+ )
214
+ # ``lastY = zero()`` in screen space: the path continues from the top edge.
215
+ self._last = IVal(self.y_hi, self.y_hi, "connected")
216
+ else:
217
+ self._last = None
218
+ return
219
+ if self.y_lo <= y.hi <= self.y_hi:
220
+ self._segment(
221
+ (self._sx(column.x_lo), self._sy(y.hi)),
222
+ (self._sx((column.x_lo + column.x_hi) * 0.5), self.height),
223
+ )
224
+ self._last = IVal(self.y_lo, self.y_lo, "connected")
225
+ else:
226
+ self._last = None
227
+
228
+ # ------------------------------------- DrawInvertedInterval / JoinLines
229
+ def _draw_inverted(self, index: int) -> None:
230
+ """``DrawInvertedInterval.draw`` for both the joined and independent cases.
231
+
232
+ ``drawJoined`` does not use ``lastY`` for the inverted branch; it only resets it
233
+ when the current topology is whole, so the two entry points coincide.
234
+ """
235
+ column = self.columns[index]
236
+ right = self.columns[index + 1] if index + 1 < len(self.columns) else None
237
+ if right is not None and right.y.inverted:
238
+ self._inverted_segments(column)
239
+ else:
240
+ self._inverted_joined(index)
241
+ # DrawInvertedInterval.draw() clears lastYSet when the next column is not
242
+ # inverted, so the following connected column restarts independently. Without
243
+ # this the stale lastY joins the two branches across the pole.
244
+ self._last = None
245
+
246
+ def _inverted_segments(self, column: Column) -> None:
247
+ """``DrawInvertedInterval.drawSegments``: the gap is excluded, so two rays."""
248
+ gap = column.y
249
+ if gap.hi > self.y_lo:
250
+ self._segment(
251
+ (self._sx(column.x_lo), self.height), # world y_min
252
+ (self._sx(column.x_hi), self._sy(gap.lo)),
253
+ )
254
+ if gap.lo < self.y_hi:
255
+ self._segment(
256
+ (self._sx(column.x_hi), 0.0), # world y_max
257
+ (self._sx(column.x_hi), self._sy(gap.hi)),
258
+ )
259
+
260
+ def _inverted_joined(self, index: int) -> None:
261
+ """``JoinLines.inverted`` with the current/left/right tuples."""
262
+ column = self.columns[index]
263
+ gap = column.y
264
+ left = self.columns[index - 1] if index > 0 else None
265
+ right = self.columns[index + 1] if index + 1 < len(self.columns) else None
266
+ left_y = left.y if left is not None else None
267
+ right_y = right.y if right is not None else None
268
+
269
+ if gap.hi < IINF:
270
+ if not self._infinite_boundary(left_y):
271
+ self._to_top(column, left, right)
272
+ if gap.lo > -IINF:
273
+ if self._infinite_boundary(right_y):
274
+ y1 = left_y.lo if left_y is not None and not left_y.empty else gap.lo
275
+ x = self._sx(column.x_lo)
276
+ self._segment((x, self._sy(y1)), (x, self.height))
277
+ else:
278
+ self._to_bottom(column, left, right)
279
+
280
+ @staticmethod
281
+ def _infinite_boundary(y: IVal | None) -> bool:
282
+ return y is not None and not y.empty and y.has_infinity()
283
+
284
+ def _to_top(self, column: Column, left: Column | None, right: Column | None) -> None:
285
+ current = column.y.payload_hi
286
+ left_diff = abs(current - left.y.payload_hi) if left is not None else float("inf")
287
+ right_diff = abs(current - right.y.payload_hi) if right is not None else float("inf")
288
+ # GGB reads ``left/rightXHigh`` without a null check; at a view edge one neighbour is
289
+ # missing, so fall back to this column's own edges (the segment is clipped anyway).
290
+ left_x = self._sx(left.x_hi) if left is not None else self._sx(column.x_lo)
291
+ right_x = self._sx(right.x_hi) if right is not None else self._sx(column.x_hi)
292
+ x_low = self._sx(column.x_lo)
293
+ x_high = self._sx(column.x_hi)
294
+ if left is not None and _near(left_diff, 0.0):
295
+ self._segment((x_low, self._sy(left.y.payload_hi)), (x_low, 0.0))
296
+ elif right is not None and _near(right_diff, 0.0):
297
+ self._segment((x_high, self._sy(right.y.payload_hi)), (x_high, 0.0))
298
+ elif left_diff < right_diff:
299
+ self._segment((right_x, 0.0), (left_x, self._sy(current)))
300
+ else:
301
+ self._segment((right_x, 0.0), (right_x, self._sy(current)))
302
+
303
+ def _screen_difference(self, value: float, neighbour: Column | None) -> float:
304
+ """``JoinLines.getLeftScreenDifference``: the difference in *screen* space.
305
+
306
+ ``toScreenCoordYd`` is decreasing, so this reverses the ordering of a world-space
307
+ comparison. GGB's own tan output depends on it; using raw world differences selected
308
+ the mirrored branch and drew the asymptote on the wrong side.
309
+ """
310
+ if neighbour is None or neighbour.y.empty:
311
+ return -1.0
312
+ if neighbour.y.whole:
313
+ return -math.inf
314
+ return self._sy(value)
315
+
316
+ def _to_bottom(self, column: Column, left: Column | None, right: Column | None) -> None:
317
+ current = column.y.payload_lo
318
+ left_diff = (self._screen_difference(abs(current - left.y.payload_lo), left)
319
+ if left is not None else -1.0)
320
+ right_diff = (self._screen_difference(abs(current - right.y.payload_lo), right)
321
+ if right is not None else -1.0)
322
+ right_x = self._sx(right.x_hi) if right is not None else self._sx(column.x_hi)
323
+ x_low = self._sx(column.x_lo)
324
+ x_high = self._sx(column.x_hi)
325
+ if left_diff == 0.0 and left is not None:
326
+ self._segment((x_low, self._sy(left.y.payload_lo)), (x_low, self.height))
327
+ elif right_diff == 0.0 and right is not None:
328
+ self._segment((x_high, self._sy(right.y.payload_lo)), (x_high, self.height))
329
+ elif left_diff < right_diff:
330
+ # JoinLines.toBottomLeft: to *currentXHigh*, not currentXLow.
331
+ self._segment((right_x, self._sy(current)), (x_high, self.height))
332
+ else:
333
+ self._segment((x_low, self._sy(current)), (x_high, self.height))
334
+
335
+ # ------------------------------------------------------------- primitives
336
+ def _line_to(self, x: float, y: float) -> None:
337
+ if self._point is None:
338
+ self._point = PathPoint(x, y)
339
+ return
340
+ self.segments.append((self._point, PathPoint(x, y)))
341
+ self._point = PathPoint(x, y)
342
+
343
+ def _segment(self, start: tuple[float, float], end: tuple[float, float]) -> None:
344
+ """``IntervalPathPlotter.segment``: starts a new subpath."""
345
+ self.segments.append((PathPoint(*start), PathPoint(*end)))
346
+ self._point = PathPoint(*end)
347
+
348
+ def _sx(self, x: float) -> float:
349
+ return (x - self.x_lo) / (self.x_hi - self.x_lo) * self.width
350
+
351
+ def _sy(self, y: float) -> float:
352
+ # Clamp before mapping: the sentinel infinities would otherwise produce inf/NaN.
353
+ y = max(-1e12, min(1e12, y))
354
+ return (self.y_hi - y) / (self.y_hi - self.y_lo) * self.height
@@ -0,0 +1,77 @@
1
+ # SPDX-FileCopyrightText: 2007-2026 GeoGebra GmbH
2
+ # SPDX-License-Identifier: GPL-3.0-only
3
+ # Portions derived from GeoGebra (EUPL-1.2); see THIRD_PARTY_NOTICES.md.
4
+ """CPU sampler for GeoGebra's interval path, using the shared state machine.
5
+
6
+ ``IntervalSampler`` produces one conservative y interval per screen column with
7
+ ``mpmath.iv`` (the CPU analogue of the GPU's ``INTERVAL_LIB``). The path decisions live in
8
+ :mod:`qmlmathplot.ggb_path`, which is shared with the tests and mirrored by the compute
9
+ shader; this module adds no geometry of its own.
10
+
11
+ ``mpmath.iv`` cannot express a gap: an interval through a pole (``tan``, ``1/x``) comes back
12
+ as ``[-inf, +inf]``, which GGB's ``IntervalSet`` would call *inverted* with a finite gap.
13
+ Such a column is reported as **empty** here — a path break — because drawing it as a whole
14
+ column would bridge the pole. The GPU's ``FI`` does produce the inverted topology, and the
15
+ state machine handles it; the synthetic tests in ``tests/test_ggb_path.py`` cover that
16
+ branch.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import mpmath as mp
22
+ import sympy as sp
23
+
24
+ from .ggb_path import Column, IINF, IntervalPath, IVal, PathPoint
25
+
26
+ __all__ = ["Column", "PathPoint", "IVal", "IntervalSampler", "IntervalPathReference"]
27
+
28
+
29
+ class IntervalSampler:
30
+ """One conservative y interval per screen column (GGB FunctionSampler shape)."""
31
+
32
+ def __init__(self, expression: str | sp.Basic, x_lo: float, x_hi: float,
33
+ width: int) -> None:
34
+ x = sp.Symbol("x")
35
+ self._expr = expression if isinstance(expression, sp.Basic) else sp.sympify(expression)
36
+ self._func = sp.lambdify(x, self._expr, modules=[_iv_module()])
37
+ self._x_lo = x_lo
38
+ self._x_hi = x_hi
39
+ self._width = max(1, int(width))
40
+
41
+ def columns(self) -> list[Column]:
42
+ dx = (self._x_hi - self._x_lo) / self._width
43
+ out: list[Column] = []
44
+ for i in range(self._width):
45
+ lo = self._x_lo + i * dx
46
+ hi = lo + dx
47
+ out.append(Column(lo, hi, self._y_interval(lo, hi)))
48
+ return out
49
+
50
+ def _y_interval(self, lo: float, hi: float) -> IVal:
51
+ try:
52
+ value = self._func(mp.iv.mpf([lo, hi]))
53
+ a, b = float(value.a), float(value.b)
54
+ except (ValueError, ZeroDivisionError, OverflowError):
55
+ return IVal(0.0, 0.0, "empty")
56
+ if not (a == a and b == b) or a > b:
57
+ return IVal(0.0, 0.0, "empty")
58
+ if a <= -IINF and b >= IINF:
59
+ # A pole: the true image is an inverted gap, which mpmath.iv cannot express.
60
+ return IVal(0.0, 0.0, "empty")
61
+ return IVal(max(a, -IINF), min(b, IINF), "connected")
62
+
63
+
64
+ def _iv_module() -> dict[str, object]:
65
+ return {
66
+ "sin": mp.iv.sin,
67
+ "cos": mp.iv.cos,
68
+ "tan": mp.iv.tan,
69
+ "sqrt": mp.iv.sqrt,
70
+ "exp": mp.iv.exp,
71
+ "log": mp.iv.log,
72
+ "Abs": abs,
73
+ }
74
+
75
+
76
+ #: Backwards-compatible name: the CPU backend and its tests import this.
77
+ IntervalPathReference = IntervalPath