multiple-integrate 2.0.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.
- multiple_integrate/__init__.py +45 -0
- multiple_integrate/core.py +2330 -0
- multiple_integrate/regions.py +1125 -0
- multiple_integrate-2.0.0.dist-info/METADATA +396 -0
- multiple_integrate-2.0.0.dist-info/RECORD +7 -0
- multiple_integrate-2.0.0.dist-info/WHEEL +4 -0
- multiple_integrate-2.0.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import sympy as sp
|
|
6
|
+
from sympy.core.relational import Relational
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _normalize_ranges_input(ranges):
|
|
10
|
+
if isinstance(ranges, Region):
|
|
11
|
+
return ranges
|
|
12
|
+
if isinstance(ranges, tuple) and len(ranges) == 1 and isinstance(ranges[0], Region):
|
|
13
|
+
return ranges[0]
|
|
14
|
+
if isinstance(ranges, tuple):
|
|
15
|
+
return list(ranges)
|
|
16
|
+
return ranges
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _clean_expr(expr):
|
|
20
|
+
expr = sp.sympify(expr)
|
|
21
|
+
try:
|
|
22
|
+
return sp.simplify(expr)
|
|
23
|
+
except Exception:
|
|
24
|
+
return expr
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _split_dependence(
|
|
28
|
+
expr: sp.Expr, radial_vars: tuple[sp.Symbol, ...], angular_vars: tuple[sp.Symbol, ...]
|
|
29
|
+
):
|
|
30
|
+
"""Split a transformed expression into radial and angular factors when possible."""
|
|
31
|
+
expr = _clean_expr(expr)
|
|
32
|
+
radial_set = set(radial_vars)
|
|
33
|
+
angular_set = set(angular_vars)
|
|
34
|
+
if expr.is_Mul:
|
|
35
|
+
radial = sp.Integer(1)
|
|
36
|
+
angular = sp.Integer(1)
|
|
37
|
+
for factor in expr.args:
|
|
38
|
+
fsyms = factor.free_symbols
|
|
39
|
+
if fsyms & radial_set and not (fsyms & angular_set):
|
|
40
|
+
radial *= factor
|
|
41
|
+
elif fsyms & angular_set and not (fsyms & radial_set):
|
|
42
|
+
angular *= factor
|
|
43
|
+
elif not fsyms & (radial_set | angular_set):
|
|
44
|
+
radial *= factor
|
|
45
|
+
else:
|
|
46
|
+
return None
|
|
47
|
+
return _clean_expr(radial), _clean_expr(angular)
|
|
48
|
+
fsyms = expr.free_symbols
|
|
49
|
+
if fsyms & radial_set and not (fsyms & angular_set):
|
|
50
|
+
return _clean_expr(expr), sp.Integer(1)
|
|
51
|
+
if fsyms & angular_set and not (fsyms & radial_set):
|
|
52
|
+
return sp.Integer(1), _clean_expr(expr)
|
|
53
|
+
if not fsyms & (radial_set | angular_set):
|
|
54
|
+
return _clean_expr(expr), sp.Integer(1)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Region:
|
|
60
|
+
"""Base region class for structured multiple-integration domains."""
|
|
61
|
+
|
|
62
|
+
ranges: tuple[tuple, ...] = field(default_factory=tuple)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def variables(self) -> tuple[sp.Symbol, ...]:
|
|
66
|
+
return tuple(r[0] for r in self.ranges)
|
|
67
|
+
|
|
68
|
+
def normalized_ranges(self) -> tuple[tuple, ...]:
|
|
69
|
+
return tuple((v, _clean_expr(lo), _clean_expr(hi)) for v, lo, hi in self.ranges)
|
|
70
|
+
|
|
71
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def radial_integral(self, expr: sp.Expr) -> sp.Expr | None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def is_reflection_invariant(self, var: sp.Symbol) -> bool:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def symmetric_range(self, var: sp.Symbol) -> tuple[sp.Expr, sp.Expr] | None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class BoxRegion(Region):
|
|
89
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
90
|
+
vol = sp.Integer(1)
|
|
91
|
+
vars_set = set(self.variables)
|
|
92
|
+
for _, lo, hi in self.ranges:
|
|
93
|
+
lo_s = sp.sympify(lo)
|
|
94
|
+
hi_s = sp.sympify(hi)
|
|
95
|
+
if (lo_s.free_symbols | hi_s.free_symbols) & vars_set:
|
|
96
|
+
return None
|
|
97
|
+
vol *= hi_s - lo_s
|
|
98
|
+
return sp.simplify(vol)
|
|
99
|
+
|
|
100
|
+
def is_reflection_invariant(self, var: sp.Symbol) -> bool:
|
|
101
|
+
return self.symmetric_range(var) is not None
|
|
102
|
+
|
|
103
|
+
def symmetric_range(self, var: sp.Symbol) -> tuple[sp.Expr, sp.Expr] | None:
|
|
104
|
+
for v, lo, hi in self.ranges:
|
|
105
|
+
if v == var:
|
|
106
|
+
lo_s = sp.sympify(lo)
|
|
107
|
+
hi_s = sp.sympify(hi)
|
|
108
|
+
if sp.simplify(lo_s + hi_s) == 0:
|
|
109
|
+
return lo_s, hi_s
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class IteratedRegion(Region):
|
|
115
|
+
def symmetric_range(self, var: sp.Symbol) -> tuple[sp.Expr, sp.Expr] | None:
|
|
116
|
+
for idx, (v, lo, hi) in enumerate(self.ranges):
|
|
117
|
+
if v != var:
|
|
118
|
+
continue
|
|
119
|
+
lo_s = sp.sympify(lo)
|
|
120
|
+
hi_s = sp.sympify(hi)
|
|
121
|
+
if sp.simplify(lo_s + hi_s) != 0:
|
|
122
|
+
return None
|
|
123
|
+
for _, later_lo, later_hi in self.ranges[idx + 1 :]:
|
|
124
|
+
if (
|
|
125
|
+
var in sp.sympify(later_lo).free_symbols
|
|
126
|
+
or var in sp.sympify(later_hi).free_symbols
|
|
127
|
+
):
|
|
128
|
+
return None
|
|
129
|
+
return lo_s, hi_s
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def is_reflection_invariant(self, var: sp.Symbol) -> bool:
|
|
133
|
+
return self.symmetric_range(var) is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class GraphRegion(IteratedRegion):
|
|
138
|
+
outer_var: sp.Symbol | None = None
|
|
139
|
+
inner_var: sp.Symbol | None = None
|
|
140
|
+
|
|
141
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
142
|
+
if len(self.ranges) != 2:
|
|
143
|
+
return None
|
|
144
|
+
(y, lo, hi), (x, a, b) = self.ranges
|
|
145
|
+
lo_s = sp.sympify(lo)
|
|
146
|
+
hi_s = sp.sympify(hi)
|
|
147
|
+
if lo_s.free_symbols - {x} or hi_s.free_symbols - {x}:
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
return sp.simplify(sp.integrate(hi_s - lo_s, (x, sp.sympify(a), sp.sympify(b))))
|
|
151
|
+
except Exception:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def _linear_data(self):
|
|
155
|
+
if len(self.ranges) != 2:
|
|
156
|
+
return None
|
|
157
|
+
(y, lo, hi), (x, a, b) = self.ranges
|
|
158
|
+
a = sp.sympify(a)
|
|
159
|
+
b = sp.sympify(b)
|
|
160
|
+
lo = sp.expand(sp.sympify(lo))
|
|
161
|
+
hi = sp.expand(sp.sympify(hi))
|
|
162
|
+
if a.free_symbols | b.free_symbols:
|
|
163
|
+
return None
|
|
164
|
+
if lo.free_symbols - {x} or hi.free_symbols - {x}:
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
plo = sp.Poly(lo, x)
|
|
168
|
+
phi = sp.Poly(hi, x)
|
|
169
|
+
except sp.PolynomialError:
|
|
170
|
+
return None
|
|
171
|
+
if plo.degree() > 1 or phi.degree() > 1:
|
|
172
|
+
return None
|
|
173
|
+
m1 = plo.nth(1) if plo.degree() >= 1 else sp.Integer(0)
|
|
174
|
+
c1 = plo.nth(0)
|
|
175
|
+
m2 = phi.nth(1) if phi.degree() >= 1 else sp.Integer(0)
|
|
176
|
+
c2 = phi.nth(0)
|
|
177
|
+
return x, y, a, b, m1, c1, m2, c2
|
|
178
|
+
|
|
179
|
+
def reversed_pieces(self) -> list[list[tuple]] | None:
|
|
180
|
+
data = self._linear_data()
|
|
181
|
+
if data is None:
|
|
182
|
+
return None
|
|
183
|
+
x, y, a, b, m1, c1, m2, c2 = data
|
|
184
|
+
|
|
185
|
+
def y_at(m, c, xv):
|
|
186
|
+
return sp.simplify(m * xv + c)
|
|
187
|
+
|
|
188
|
+
pts = [y_at(m1, c1, a), y_at(m1, c1, b), y_at(m2, c2, a), y_at(m2, c2, b)]
|
|
189
|
+
try:
|
|
190
|
+
if sp.simplify(m1 - m2) != 0:
|
|
191
|
+
x_cross = sp.simplify((c2 - c1) / (m1 - m2))
|
|
192
|
+
if float(sp.N(a)) - 1e-12 <= float(sp.N(x_cross)) <= float(sp.N(b)) + 1e-12:
|
|
193
|
+
pts.append(y_at(m1, c1, x_cross))
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
uniq = []
|
|
198
|
+
for p in pts:
|
|
199
|
+
p = sp.simplify(p)
|
|
200
|
+
if p not in uniq:
|
|
201
|
+
uniq.append(p)
|
|
202
|
+
try:
|
|
203
|
+
uniq = sorted(uniq, key=lambda t: float(sp.N(t)))
|
|
204
|
+
except Exception:
|
|
205
|
+
return None
|
|
206
|
+
if len(uniq) < 2:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
# Reverse the graph by slicing the y-axis into intervals where the
|
|
210
|
+
# active lower and upper x-bounds stay on the same candidate lines.
|
|
211
|
+
def inv(m, c):
|
|
212
|
+
if sp.simplify(m) == 0:
|
|
213
|
+
return None
|
|
214
|
+
return sp.simplify((y - c) / m)
|
|
215
|
+
|
|
216
|
+
lower_cands = [a]
|
|
217
|
+
upper_cands = [b]
|
|
218
|
+
inv1 = inv(m1, c1)
|
|
219
|
+
inv2 = inv(m2, c2)
|
|
220
|
+
if inv1 is not None:
|
|
221
|
+
if sp.N(m1) > 0:
|
|
222
|
+
upper_cands.append(inv1)
|
|
223
|
+
else:
|
|
224
|
+
lower_cands.append(inv1)
|
|
225
|
+
if inv2 is not None:
|
|
226
|
+
if sp.N(m2) > 0:
|
|
227
|
+
lower_cands.append(inv2)
|
|
228
|
+
else:
|
|
229
|
+
upper_cands.append(inv2)
|
|
230
|
+
|
|
231
|
+
pieces = []
|
|
232
|
+
for left, right in zip(uniq[:-1], uniq[1:], strict=False):
|
|
233
|
+
if sp.simplify(left - right) == 0:
|
|
234
|
+
continue
|
|
235
|
+
mid = sp.simplify((left + right) / 2)
|
|
236
|
+
|
|
237
|
+
def choose(cands, kind, midpoint=mid):
|
|
238
|
+
best = None
|
|
239
|
+
best_val = None
|
|
240
|
+
for cand in cands:
|
|
241
|
+
val = float(sp.N(cand.subs(y, midpoint) if hasattr(cand, "subs") else cand))
|
|
242
|
+
if (
|
|
243
|
+
best is None
|
|
244
|
+
or kind == "max"
|
|
245
|
+
and val > best_val + 1e-12
|
|
246
|
+
or kind == "min"
|
|
247
|
+
and val < best_val - 1e-12
|
|
248
|
+
):
|
|
249
|
+
best, best_val = cand, val
|
|
250
|
+
return sp.simplify(best)
|
|
251
|
+
|
|
252
|
+
xlo = choose(lower_cands, "max")
|
|
253
|
+
xhi = choose(upper_cands, "min")
|
|
254
|
+
try:
|
|
255
|
+
if float(sp.N(xlo.subs(y, mid))) <= float(sp.N(xhi.subs(y, mid))) + 1e-12:
|
|
256
|
+
pieces.append([(x, xlo, xhi), (y, left, right)])
|
|
257
|
+
except Exception:
|
|
258
|
+
return None
|
|
259
|
+
return pieces or None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(frozen=True)
|
|
263
|
+
class SimplexRegion(IteratedRegion):
|
|
264
|
+
dimension: int = 0
|
|
265
|
+
|
|
266
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
267
|
+
return sp.simplify(sp.Integer(1) / sp.factorial(self.dimension)) if self.dimension else None
|
|
268
|
+
|
|
269
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
270
|
+
vars_ = list(self.variables)
|
|
271
|
+
try:
|
|
272
|
+
poly = sp.Poly(sp.expand(expr), *vars_)
|
|
273
|
+
except sp.PolynomialError:
|
|
274
|
+
return None
|
|
275
|
+
total = sp.Integer(0)
|
|
276
|
+
for monom, coeff in poly.terms():
|
|
277
|
+
deg_sum = sum(monom)
|
|
278
|
+
numer = sp.Integer(1)
|
|
279
|
+
for a in monom:
|
|
280
|
+
numer *= sp.factorial(a)
|
|
281
|
+
denom = sp.factorial(self.dimension + deg_sum)
|
|
282
|
+
total += coeff * numer / denom
|
|
283
|
+
return sp.simplify(total)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class AffineSimplexRegion(Region):
|
|
288
|
+
shifts: tuple[sp.Expr, ...] = field(default_factory=tuple)
|
|
289
|
+
scales: tuple[sp.Expr, ...] = field(default_factory=tuple)
|
|
290
|
+
dimension: int = 0
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def variables(self) -> tuple[sp.Symbol, ...]:
|
|
294
|
+
return tuple(v for v, _, _ in self.ranges)
|
|
295
|
+
|
|
296
|
+
def normalized_ranges(self) -> tuple[tuple, ...]:
|
|
297
|
+
return (
|
|
298
|
+
"AffineSimplexRegion",
|
|
299
|
+
tuple(sp.simplify(s) for s in self.shifts),
|
|
300
|
+
tuple(sp.simplify(s) for s in self.scales),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
304
|
+
if not self.dimension or len(self.scales) != self.dimension:
|
|
305
|
+
return None
|
|
306
|
+
scale = sp.Integer(1)
|
|
307
|
+
for s in self.scales:
|
|
308
|
+
scale *= sp.Abs(sp.sympify(s))
|
|
309
|
+
return sp.simplify(scale / sp.factorial(self.dimension))
|
|
310
|
+
|
|
311
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
312
|
+
vars_ = self.variables
|
|
313
|
+
if len(vars_) != self.dimension:
|
|
314
|
+
return None
|
|
315
|
+
uvars = sp.symbols(f"_u0:{self.dimension}", real=True)
|
|
316
|
+
subs = {
|
|
317
|
+
v: sp.sympify(a) + sp.sympify(s) * u
|
|
318
|
+
for v, a, s, u in zip(vars_, self.shifts, self.scales, uvars, strict=True)
|
|
319
|
+
}
|
|
320
|
+
jac = sp.Integer(1)
|
|
321
|
+
for s in self.scales:
|
|
322
|
+
jac *= sp.Abs(sp.sympify(s))
|
|
323
|
+
transformed = sp.expand(sp.sympify(expr).subs(subs) * jac)
|
|
324
|
+
simplex_ranges = tuple((u, 0, 1 - sum(uvars[:i])) for i, u in enumerate(uvars))
|
|
325
|
+
simplex = SimplexRegion(simplex_ranges, dimension=self.dimension)
|
|
326
|
+
return simplex.polynomial_moment(transformed)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@dataclass(frozen=True)
|
|
330
|
+
class DiskRegion(IteratedRegion):
|
|
331
|
+
radius: sp.Expr = sp.Integer(1)
|
|
332
|
+
|
|
333
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
334
|
+
return sp.simplify(sp.pi * self.radius**2)
|
|
335
|
+
|
|
336
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
337
|
+
vars_ = list(self.variables)
|
|
338
|
+
try:
|
|
339
|
+
poly = sp.Poly(sp.expand(expr), *vars_)
|
|
340
|
+
except sp.PolynomialError:
|
|
341
|
+
return None
|
|
342
|
+
total = sp.Integer(0)
|
|
343
|
+
dim = 2
|
|
344
|
+
for monom, coeff in poly.terms():
|
|
345
|
+
if any(a % 2 for a in monom):
|
|
346
|
+
continue
|
|
347
|
+
deg_sum = sum(monom)
|
|
348
|
+
numer = sp.Integer(1)
|
|
349
|
+
for a in monom:
|
|
350
|
+
numer *= sp.gamma(sp.Rational(a + 1, 2))
|
|
351
|
+
denom = sp.gamma(sp.Rational(deg_sum + dim, 2) + 1)
|
|
352
|
+
total += coeff * self.radius ** (deg_sum + dim) * numer / denom
|
|
353
|
+
return sp.simplify(total)
|
|
354
|
+
|
|
355
|
+
def radial_integral(self, expr: sp.Expr) -> sp.Expr | None:
|
|
356
|
+
x, y = self.variables
|
|
357
|
+
r = sp.Symbol("_r", nonnegative=True, real=True)
|
|
358
|
+
theta = sp.Symbol("_theta", real=True)
|
|
359
|
+
# Convert to polar coordinates and split only when the transformed
|
|
360
|
+
# integrand separates into a pure radial factor and a pure angular factor.
|
|
361
|
+
polar_expr = _clean_expr(
|
|
362
|
+
sp.trigsimp(
|
|
363
|
+
sp.expand_trig(sp.sympify(expr).subs({x: r * sp.cos(theta), y: r * sp.sin(theta)}))
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
pieces = _split_dependence(polar_expr, (r,), (theta,))
|
|
367
|
+
if pieces is None:
|
|
368
|
+
return None
|
|
369
|
+
radial_part, angular_part = pieces
|
|
370
|
+
angular_val = sp.integrate(angular_part, (theta, 0, 2 * sp.pi))
|
|
371
|
+
radial_val = sp.integrate(sp.simplify(radial_part * r), (r, 0, self.radius))
|
|
372
|
+
return _clean_expr(angular_val * radial_val)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@dataclass(frozen=True)
|
|
376
|
+
class AnnulusRegion(Region):
|
|
377
|
+
variables_xy: tuple[sp.Symbol, sp.Symbol] = field(default_factory=tuple)
|
|
378
|
+
inner_radius: sp.Expr = sp.Integer(0)
|
|
379
|
+
outer_radius: sp.Expr = sp.Integer(1)
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def variables(self) -> tuple[sp.Symbol, ...]:
|
|
383
|
+
return self.variables_xy
|
|
384
|
+
|
|
385
|
+
def normalized_ranges(self) -> tuple[tuple, ...]:
|
|
386
|
+
return ("AnnulusRegion", sp.simplify(self.inner_radius), sp.simplify(self.outer_radius))
|
|
387
|
+
|
|
388
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
389
|
+
return sp.simplify(sp.pi * (self.outer_radius**2 - self.inner_radius**2))
|
|
390
|
+
|
|
391
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
392
|
+
x, y = self.variables_xy
|
|
393
|
+
outer = DiskRegion(
|
|
394
|
+
(
|
|
395
|
+
(x, -self.outer_radius, self.outer_radius),
|
|
396
|
+
(y, -sp.sqrt(self.outer_radius**2 - x**2), sp.sqrt(self.outer_radius**2 - x**2)),
|
|
397
|
+
),
|
|
398
|
+
radius=self.outer_radius,
|
|
399
|
+
)
|
|
400
|
+
inner = DiskRegion(
|
|
401
|
+
(
|
|
402
|
+
(x, -self.inner_radius, self.inner_radius),
|
|
403
|
+
(y, -sp.sqrt(self.inner_radius**2 - x**2), sp.sqrt(self.inner_radius**2 - x**2)),
|
|
404
|
+
),
|
|
405
|
+
radius=self.inner_radius,
|
|
406
|
+
)
|
|
407
|
+
out = outer.polynomial_moment(expr)
|
|
408
|
+
inn = inner.polynomial_moment(expr)
|
|
409
|
+
if out is None or inn is None:
|
|
410
|
+
return None
|
|
411
|
+
return sp.simplify(out - inn)
|
|
412
|
+
|
|
413
|
+
def radial_integral(self, expr: sp.Expr) -> sp.Expr | None:
|
|
414
|
+
x, y = self.variables_xy
|
|
415
|
+
outer = DiskRegion(
|
|
416
|
+
(
|
|
417
|
+
(x, -self.outer_radius, self.outer_radius),
|
|
418
|
+
(y, -sp.sqrt(self.outer_radius**2 - x**2), sp.sqrt(self.outer_radius**2 - x**2)),
|
|
419
|
+
),
|
|
420
|
+
radius=self.outer_radius,
|
|
421
|
+
)
|
|
422
|
+
inner = DiskRegion(
|
|
423
|
+
(
|
|
424
|
+
(x, -self.inner_radius, self.inner_radius),
|
|
425
|
+
(y, -sp.sqrt(self.inner_radius**2 - x**2), sp.sqrt(self.inner_radius**2 - x**2)),
|
|
426
|
+
),
|
|
427
|
+
radius=self.inner_radius,
|
|
428
|
+
)
|
|
429
|
+
out = outer.radial_integral(expr)
|
|
430
|
+
inn = inner.radial_integral(expr)
|
|
431
|
+
if out is None or inn is None:
|
|
432
|
+
return None
|
|
433
|
+
return sp.simplify(out - inn)
|
|
434
|
+
|
|
435
|
+
def is_reflection_invariant(self, var: sp.Symbol) -> bool:
|
|
436
|
+
return var in self.variables_xy
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass(frozen=True)
|
|
440
|
+
class BallRegion(IteratedRegion):
|
|
441
|
+
radius: sp.Expr = sp.Integer(1)
|
|
442
|
+
dimension: int = 0
|
|
443
|
+
|
|
444
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
445
|
+
dim = self.dimension
|
|
446
|
+
return sp.simplify(
|
|
447
|
+
sp.pi ** (sp.Rational(dim, 2)) * self.radius**dim / sp.gamma(sp.Rational(dim, 2) + 1)
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
451
|
+
vars_ = list(self.variables)
|
|
452
|
+
try:
|
|
453
|
+
poly = sp.Poly(sp.expand(expr), *vars_)
|
|
454
|
+
except sp.PolynomialError:
|
|
455
|
+
return None
|
|
456
|
+
total = sp.Integer(0)
|
|
457
|
+
dim = self.dimension
|
|
458
|
+
for monom, coeff in poly.terms():
|
|
459
|
+
if any(a % 2 for a in monom):
|
|
460
|
+
continue
|
|
461
|
+
deg_sum = sum(monom)
|
|
462
|
+
numer = sp.Integer(1)
|
|
463
|
+
for a in monom:
|
|
464
|
+
numer *= sp.gamma(sp.Rational(a + 1, 2))
|
|
465
|
+
denom = sp.gamma(sp.Rational(deg_sum + dim, 2) + 1)
|
|
466
|
+
total += coeff * self.radius ** (deg_sum + dim) * numer / denom
|
|
467
|
+
return sp.simplify(total)
|
|
468
|
+
|
|
469
|
+
def radial_integral(self, expr: sp.Expr) -> sp.Expr | None:
|
|
470
|
+
vars_ = self.variables
|
|
471
|
+
dim = self.dimension
|
|
472
|
+
if dim != 3 or len(vars_) != 3:
|
|
473
|
+
t = sp.Symbol("_rho2", nonnegative=True, real=True)
|
|
474
|
+
probes = []
|
|
475
|
+
for v in vars_:
|
|
476
|
+
subs = {w: sp.Integer(0) for w in vars_}
|
|
477
|
+
subs[v] = sp.sqrt(t)
|
|
478
|
+
probes.append(_clean_expr(expr.subs(subs)))
|
|
479
|
+
first = probes[0]
|
|
480
|
+
if any(sp.simplify(p - first) != 0 for p in probes[1:]):
|
|
481
|
+
return None
|
|
482
|
+
if set(vars_) & first.free_symbols:
|
|
483
|
+
return None
|
|
484
|
+
r = sp.Symbol("_r", nonnegative=True, real=True)
|
|
485
|
+
sphere_area = 2 * sp.pi ** (sp.Rational(dim, 2)) / sp.gamma(sp.Rational(dim, 2))
|
|
486
|
+
radial_expr = _clean_expr(first.subs(t, r**2) * sphere_area * r ** (dim - 1))
|
|
487
|
+
return sp.integrate(radial_expr, (r, 0, self.radius))
|
|
488
|
+
|
|
489
|
+
x, y, z = vars_
|
|
490
|
+
r = sp.Symbol("_r", nonnegative=True, real=True)
|
|
491
|
+
phi = sp.Symbol("_phi", real=True)
|
|
492
|
+
theta = sp.Symbol("_theta", real=True)
|
|
493
|
+
# The 3D case uses spherical coordinates and keeps only products that
|
|
494
|
+
# separate cleanly into radial and angular pieces.
|
|
495
|
+
spherical_expr = _clean_expr(
|
|
496
|
+
sp.trigsimp(
|
|
497
|
+
sp.expand_trig(
|
|
498
|
+
sp.sympify(expr).subs(
|
|
499
|
+
{
|
|
500
|
+
x: r * sp.sin(phi) * sp.cos(theta),
|
|
501
|
+
y: r * sp.sin(phi) * sp.sin(theta),
|
|
502
|
+
z: r * sp.cos(phi),
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
pieces = _split_dependence(spherical_expr, (r,), (theta, phi))
|
|
509
|
+
if pieces is None:
|
|
510
|
+
return None
|
|
511
|
+
radial_part, angular_part = pieces
|
|
512
|
+
angular_val = sp.integrate(
|
|
513
|
+
angular_part * sp.sin(phi), (theta, 0, 2 * sp.pi), (phi, 0, sp.pi)
|
|
514
|
+
)
|
|
515
|
+
radial_val = sp.integrate(_clean_expr(radial_part * r**2), (r, 0, self.radius))
|
|
516
|
+
return _clean_expr(angular_val * radial_val)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@dataclass(frozen=True)
|
|
520
|
+
class EllipsoidRegion(Region):
|
|
521
|
+
variables_nd: tuple[sp.Symbol, ...] = field(default_factory=tuple)
|
|
522
|
+
axes: tuple[sp.Expr, ...] = field(default_factory=tuple)
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def variables(self) -> tuple[sp.Symbol, ...]:
|
|
526
|
+
return self.variables_nd
|
|
527
|
+
|
|
528
|
+
def normalized_ranges(self) -> tuple[tuple, ...]:
|
|
529
|
+
return ("EllipsoidRegion", tuple(sp.simplify(a) for a in self.axes))
|
|
530
|
+
|
|
531
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
532
|
+
dim = len(self.axes)
|
|
533
|
+
scale = sp.Integer(1)
|
|
534
|
+
for a in self.axes:
|
|
535
|
+
scale *= sp.Abs(sp.sympify(a))
|
|
536
|
+
return sp.simplify(
|
|
537
|
+
scale * sp.pi ** (sp.Rational(dim, 2)) / sp.gamma(sp.Rational(dim, 2) + 1)
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
541
|
+
vars_ = self.variables_nd
|
|
542
|
+
uvars = sp.symbols(f"_u0:{len(vars_)}", real=True)
|
|
543
|
+
jac = sp.Integer(1)
|
|
544
|
+
subs = {}
|
|
545
|
+
for v, a, u in zip(vars_, self.axes, uvars, strict=True):
|
|
546
|
+
subs[v] = sp.sympify(a) * u
|
|
547
|
+
jac *= sp.Abs(sp.sympify(a))
|
|
548
|
+
transformed = sp.expand(sp.sympify(expr).subs(subs) * jac)
|
|
549
|
+
ball = BallRegion(
|
|
550
|
+
tuple((u, -1, 1) for u in uvars), radius=sp.Integer(1), dimension=len(vars_)
|
|
551
|
+
)
|
|
552
|
+
return ball.polynomial_moment(transformed)
|
|
553
|
+
|
|
554
|
+
def radial_integral(self, expr: sp.Expr) -> sp.Expr | None:
|
|
555
|
+
vars_ = self.variables_nd
|
|
556
|
+
t = sp.Symbol("_rho2", nonnegative=True, real=True)
|
|
557
|
+
probes = []
|
|
558
|
+
for v, a in zip(vars_, self.axes, strict=True):
|
|
559
|
+
subs = {w: sp.Integer(0) for w in vars_}
|
|
560
|
+
subs[v] = sp.sympify(a) * sp.sqrt(t)
|
|
561
|
+
probes.append(sp.simplify(expr.subs(subs)))
|
|
562
|
+
first = probes[0]
|
|
563
|
+
if any(sp.simplify(p - first) != 0 for p in probes[1:]):
|
|
564
|
+
return None
|
|
565
|
+
if set(vars_) & first.free_symbols:
|
|
566
|
+
return None
|
|
567
|
+
dim = len(vars_)
|
|
568
|
+
r = sp.Symbol("_r", nonnegative=True, real=True)
|
|
569
|
+
scale = sp.Integer(1)
|
|
570
|
+
for a in self.axes:
|
|
571
|
+
scale *= sp.Abs(sp.sympify(a))
|
|
572
|
+
sphere_area = 2 * sp.pi ** (sp.Rational(dim, 2)) / sp.gamma(sp.Rational(dim, 2))
|
|
573
|
+
radial_expr = sp.simplify(first.subs(t, r**2) * scale * sphere_area * r ** (dim - 1))
|
|
574
|
+
return sp.integrate(radial_expr, (r, 0, 1))
|
|
575
|
+
|
|
576
|
+
def is_reflection_invariant(self, var: sp.Symbol) -> bool:
|
|
577
|
+
return var in self.variables_nd
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@dataclass(frozen=True)
|
|
581
|
+
class SphericalShellRegion(Region):
|
|
582
|
+
variables_nd: tuple[sp.Symbol, ...] = field(default_factory=tuple)
|
|
583
|
+
inner_radius: sp.Expr = sp.Integer(0)
|
|
584
|
+
outer_radius: sp.Expr = sp.Integer(1)
|
|
585
|
+
|
|
586
|
+
@property
|
|
587
|
+
def variables(self) -> tuple[sp.Symbol, ...]:
|
|
588
|
+
return self.variables_nd
|
|
589
|
+
|
|
590
|
+
def normalized_ranges(self) -> tuple[tuple, ...]:
|
|
591
|
+
return (
|
|
592
|
+
"SphericalShellRegion",
|
|
593
|
+
sp.simplify(self.inner_radius),
|
|
594
|
+
sp.simplify(self.outer_radius),
|
|
595
|
+
len(self.variables_nd),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
599
|
+
dim = len(self.variables_nd)
|
|
600
|
+
return sp.simplify(
|
|
601
|
+
sp.pi ** (sp.Rational(dim, 2))
|
|
602
|
+
* (self.outer_radius**dim - self.inner_radius**dim)
|
|
603
|
+
/ sp.gamma(sp.Rational(dim, 2) + 1)
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
607
|
+
outer = BallRegion(
|
|
608
|
+
tuple((v, -self.outer_radius, self.outer_radius) for v in self.variables_nd),
|
|
609
|
+
radius=self.outer_radius,
|
|
610
|
+
dimension=len(self.variables_nd),
|
|
611
|
+
)
|
|
612
|
+
inner = BallRegion(
|
|
613
|
+
tuple((v, -self.inner_radius, self.inner_radius) for v in self.variables_nd),
|
|
614
|
+
radius=self.inner_radius,
|
|
615
|
+
dimension=len(self.variables_nd),
|
|
616
|
+
)
|
|
617
|
+
out = outer.polynomial_moment(expr)
|
|
618
|
+
inn = inner.polynomial_moment(expr)
|
|
619
|
+
if out is None or inn is None:
|
|
620
|
+
return None
|
|
621
|
+
return sp.simplify(out - inn)
|
|
622
|
+
|
|
623
|
+
def radial_integral(self, expr: sp.Expr) -> sp.Expr | None:
|
|
624
|
+
outer = BallRegion(
|
|
625
|
+
tuple((v, -self.outer_radius, self.outer_radius) for v in self.variables_nd),
|
|
626
|
+
radius=self.outer_radius,
|
|
627
|
+
dimension=len(self.variables_nd),
|
|
628
|
+
)
|
|
629
|
+
inner = BallRegion(
|
|
630
|
+
tuple((v, -self.inner_radius, self.inner_radius) for v in self.variables_nd),
|
|
631
|
+
radius=self.inner_radius,
|
|
632
|
+
dimension=len(self.variables_nd),
|
|
633
|
+
)
|
|
634
|
+
out = outer.radial_integral(expr)
|
|
635
|
+
inn = inner.radial_integral(expr)
|
|
636
|
+
if out is None or inn is None:
|
|
637
|
+
return None
|
|
638
|
+
return sp.simplify(out - inn)
|
|
639
|
+
|
|
640
|
+
def is_reflection_invariant(self, var: sp.Symbol) -> bool:
|
|
641
|
+
return var in self.variables_nd
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@dataclass(frozen=True)
|
|
645
|
+
class UnionRegion(Region):
|
|
646
|
+
pieces: tuple[Region, ...] = field(default_factory=tuple)
|
|
647
|
+
|
|
648
|
+
@property
|
|
649
|
+
def variables(self) -> tuple[sp.Symbol, ...]:
|
|
650
|
+
return self.pieces[0].variables if self.pieces else tuple()
|
|
651
|
+
|
|
652
|
+
def normalized_ranges(self) -> tuple[tuple, ...]:
|
|
653
|
+
return tuple((type(reg).__name__, reg.normalized_ranges()) for reg in self.pieces)
|
|
654
|
+
|
|
655
|
+
def constant_volume(self) -> sp.Expr | None:
|
|
656
|
+
total = sp.Integer(0)
|
|
657
|
+
for reg in self.pieces:
|
|
658
|
+
vol = reg.constant_volume()
|
|
659
|
+
if vol is None:
|
|
660
|
+
return None
|
|
661
|
+
total += vol
|
|
662
|
+
return sp.simplify(total)
|
|
663
|
+
|
|
664
|
+
def polynomial_moment(self, expr: sp.Expr) -> sp.Expr | None:
|
|
665
|
+
total = sp.Integer(0)
|
|
666
|
+
for reg in self.pieces:
|
|
667
|
+
val = reg.polynomial_moment(expr)
|
|
668
|
+
if val is None:
|
|
669
|
+
return None
|
|
670
|
+
total += val
|
|
671
|
+
return sp.simplify(total)
|
|
672
|
+
|
|
673
|
+
def radial_integral(self, expr: sp.Expr) -> sp.Expr | None:
|
|
674
|
+
total = sp.Integer(0)
|
|
675
|
+
for reg in self.pieces:
|
|
676
|
+
val = reg.radial_integral(expr)
|
|
677
|
+
if val is None:
|
|
678
|
+
return None
|
|
679
|
+
total += val
|
|
680
|
+
return sp.simplify(total)
|
|
681
|
+
|
|
682
|
+
def is_reflection_invariant(self, var: sp.Symbol) -> bool:
|
|
683
|
+
return all(reg.is_reflection_invariant(var) for reg in self.pieces)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _poly_is_affine(expr: sp.Expr, var: sp.Symbol) -> bool:
|
|
687
|
+
try:
|
|
688
|
+
poly = sp.Poly(sp.expand(expr), var)
|
|
689
|
+
except sp.PolynomialError:
|
|
690
|
+
return False
|
|
691
|
+
return poly.degree() <= 1
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _structural_ranges(ranges: list[tuple]) -> list[tuple]:
|
|
695
|
+
"""Convert public inner-first integration ranges to outer-first structural order."""
|
|
696
|
+
return list(reversed(ranges))
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _structural_range_candidates(ranges: list[tuple]):
|
|
700
|
+
"""Yield plausible outer-first structural orders and canonical inner-first ranges.
|
|
701
|
+
|
|
702
|
+
Public integration APIs use SymPy's inner-first range convention. Region
|
|
703
|
+
classifiers also accept an outer-first structural description because region
|
|
704
|
+
construction and classification utilities are useful independently of an
|
|
705
|
+
iterated integral. Recognized regions are always canonicalized back to
|
|
706
|
+
inner-first ranges.
|
|
707
|
+
"""
|
|
708
|
+
public = list(ranges)
|
|
709
|
+
candidates = [list(reversed(public))]
|
|
710
|
+
if public != candidates[0]:
|
|
711
|
+
candidates.append(public)
|
|
712
|
+
for structural in candidates:
|
|
713
|
+
yield structural, tuple(reversed(structural))
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def match_standard_simplex(ranges: list[tuple]) -> SimplexRegion | None:
|
|
717
|
+
seen = []
|
|
718
|
+
for idx, (var, lo, hi) in enumerate(_structural_ranges(ranges)):
|
|
719
|
+
lo_s = sp.sympify(lo)
|
|
720
|
+
hi_s = sp.expand(sp.sympify(hi))
|
|
721
|
+
if lo_s != 0:
|
|
722
|
+
return None
|
|
723
|
+
if idx == 0:
|
|
724
|
+
if sp.simplify(hi_s - 1) != 0:
|
|
725
|
+
return None
|
|
726
|
+
else:
|
|
727
|
+
expected = 1 - sum(seen)
|
|
728
|
+
if sp.simplify(hi_s - expected) != 0:
|
|
729
|
+
return None
|
|
730
|
+
seen.append(var)
|
|
731
|
+
return SimplexRegion(tuple(ranges), dimension=len(ranges))
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def match_affine_simplex(ranges: list[tuple]) -> AffineSimplexRegion | None:
|
|
735
|
+
if len(ranges) < 2:
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
for sranges, canonical_ranges in _structural_range_candidates(ranges):
|
|
739
|
+
# This matcher is intentionally limited to affine simplex bounds.
|
|
740
|
+
if any(hi is None for _, _, hi in sranges):
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
shifts = []
|
|
744
|
+
scales = []
|
|
745
|
+
prev_vars = []
|
|
746
|
+
matched = True
|
|
747
|
+
for idx, (var, lo, hi) in enumerate(sranges):
|
|
748
|
+
lo_s = sp.simplify(sp.sympify(lo))
|
|
749
|
+
hi_s = sp.expand(sp.sympify(hi))
|
|
750
|
+
if idx == 0:
|
|
751
|
+
if hi_s.free_symbols or lo_s.free_symbols:
|
|
752
|
+
matched = False
|
|
753
|
+
break
|
|
754
|
+
scale = sp.simplify(hi_s - lo_s)
|
|
755
|
+
if scale == 0:
|
|
756
|
+
matched = False
|
|
757
|
+
break
|
|
758
|
+
shifts.append(lo_s)
|
|
759
|
+
scales.append(scale)
|
|
760
|
+
else:
|
|
761
|
+
if lo_s.free_symbols:
|
|
762
|
+
matched = False
|
|
763
|
+
break
|
|
764
|
+
if hi_s.free_symbols - set(prev_vars):
|
|
765
|
+
matched = False
|
|
766
|
+
break
|
|
767
|
+
if any(not _poly_is_affine(hi_s, pv) for pv in prev_vars):
|
|
768
|
+
matched = False
|
|
769
|
+
break
|
|
770
|
+
target = sp.Integer(1)
|
|
771
|
+
for pv, sh, sc in zip(prev_vars, shifts, scales, strict=True):
|
|
772
|
+
target -= sp.simplify((pv - sh) / sc)
|
|
773
|
+
if target == 0:
|
|
774
|
+
matched = False
|
|
775
|
+
break
|
|
776
|
+
sc_i = sp.simplify((hi_s - lo_s) / target)
|
|
777
|
+
if sc_i.free_symbols:
|
|
778
|
+
matched = False
|
|
779
|
+
break
|
|
780
|
+
if sp.simplify(lo_s + sc_i * target - hi_s) != 0:
|
|
781
|
+
matched = False
|
|
782
|
+
break
|
|
783
|
+
shifts.append(lo_s)
|
|
784
|
+
scales.append(sc_i)
|
|
785
|
+
prev_vars.append(var)
|
|
786
|
+
|
|
787
|
+
if not matched:
|
|
788
|
+
continue
|
|
789
|
+
if all(sp.simplify(sh) == 0 for sh in shifts) and all(
|
|
790
|
+
sp.simplify(sc - 1) == 0 for sc in scales
|
|
791
|
+
):
|
|
792
|
+
continue
|
|
793
|
+
return AffineSimplexRegion(
|
|
794
|
+
canonical_ranges,
|
|
795
|
+
shifts=tuple(reversed(shifts)),
|
|
796
|
+
scales=tuple(reversed(scales)),
|
|
797
|
+
dimension=len(ranges),
|
|
798
|
+
)
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def match_graph_region(ranges: list[tuple]) -> GraphRegion | None:
|
|
803
|
+
if len(ranges) != 2:
|
|
804
|
+
return None
|
|
805
|
+
|
|
806
|
+
for sranges, canonical_ranges in _structural_range_candidates(ranges):
|
|
807
|
+
(x, a, b), (y, lo, hi) = sranges
|
|
808
|
+
a_s = sp.sympify(a)
|
|
809
|
+
b_s = sp.sympify(b)
|
|
810
|
+
lo_s = sp.sympify(lo)
|
|
811
|
+
hi_s = sp.sympify(hi)
|
|
812
|
+
if a_s.free_symbols or b_s.free_symbols:
|
|
813
|
+
continue
|
|
814
|
+
if lo_s.free_symbols - {x} or hi_s.free_symbols - {x}:
|
|
815
|
+
continue
|
|
816
|
+
if not ((lo_s.free_symbols | hi_s.free_symbols) & {x}):
|
|
817
|
+
continue
|
|
818
|
+
if not (_poly_is_affine(lo_s, x) and _poly_is_affine(hi_s, x)):
|
|
819
|
+
continue
|
|
820
|
+
return GraphRegion(canonical_ranges, outer_var=x, inner_var=y)
|
|
821
|
+
return None
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def match_standard_disk(ranges: list[tuple]) -> DiskRegion | None:
|
|
825
|
+
if len(ranges) != 2:
|
|
826
|
+
return None
|
|
827
|
+
(y, lo, hi), (x, a, b) = ranges
|
|
828
|
+
a_s = sp.sympify(a)
|
|
829
|
+
b_s = sp.sympify(b)
|
|
830
|
+
lo_s = sp.sympify(lo)
|
|
831
|
+
hi_s = sp.sympify(hi)
|
|
832
|
+
if a_s.free_symbols or b_s.free_symbols:
|
|
833
|
+
return None
|
|
834
|
+
if sp.simplify(a_s + b_s) != 0:
|
|
835
|
+
return None
|
|
836
|
+
rad = sp.simplify(b_s)
|
|
837
|
+
target = sp.sqrt(rad**2 - x**2)
|
|
838
|
+
if sp.simplify(lo_s + target) == 0 and sp.simplify(hi_s - target) == 0:
|
|
839
|
+
return DiskRegion(tuple(ranges), radius=rad)
|
|
840
|
+
return None
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def match_standard_ball(ranges: list[tuple]) -> BallRegion | None:
|
|
844
|
+
if len(ranges) < 3:
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
for sranges, canonical_ranges in _structural_range_candidates(ranges):
|
|
848
|
+
x0, a0, b0 = sranges[0]
|
|
849
|
+
a0 = sp.sympify(a0)
|
|
850
|
+
b0 = sp.sympify(b0)
|
|
851
|
+
if a0.free_symbols or b0.free_symbols or sp.simplify(a0 + b0) != 0:
|
|
852
|
+
continue
|
|
853
|
+
rad = sp.simplify(b0)
|
|
854
|
+
sumsq = x0**2
|
|
855
|
+
matched = True
|
|
856
|
+
for var, lo, hi in sranges[1:]:
|
|
857
|
+
lo_s = sp.sympify(lo)
|
|
858
|
+
hi_s = sp.sympify(hi)
|
|
859
|
+
target = sp.sqrt(rad**2 - sumsq)
|
|
860
|
+
if sp.simplify(lo_s + target) != 0 or sp.simplify(hi_s - target) != 0:
|
|
861
|
+
matched = False
|
|
862
|
+
break
|
|
863
|
+
sumsq += var**2
|
|
864
|
+
if matched:
|
|
865
|
+
return BallRegion(canonical_ranges, radius=rad, dimension=len(ranges))
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def match_standard_ellipsoid(ranges: list[tuple]) -> EllipsoidRegion | None:
|
|
870
|
+
if len(ranges) < 2:
|
|
871
|
+
return None
|
|
872
|
+
|
|
873
|
+
for sranges, canonical_ranges in _structural_range_candidates(ranges):
|
|
874
|
+
vars_ = []
|
|
875
|
+
axes = []
|
|
876
|
+
x0, a0, b0 = sranges[0]
|
|
877
|
+
a0 = sp.sympify(a0)
|
|
878
|
+
b0 = sp.sympify(b0)
|
|
879
|
+
if a0.free_symbols or b0.free_symbols or sp.simplify(a0 + b0) != 0:
|
|
880
|
+
continue
|
|
881
|
+
axis0 = sp.simplify(b0)
|
|
882
|
+
vars_.append(x0)
|
|
883
|
+
axes.append(axis0)
|
|
884
|
+
q = x0**2 / axis0**2
|
|
885
|
+
matched = True
|
|
886
|
+
for var, lo, hi in sranges[1:]:
|
|
887
|
+
lo_s = sp.sympify(lo)
|
|
888
|
+
hi_s = sp.sympify(hi)
|
|
889
|
+
ratio = sp.simplify(hi_s / sp.sqrt(1 - q))
|
|
890
|
+
# The next semi-axis must be independent of all region variables.
|
|
891
|
+
if ratio.free_symbols & {r[0] for r in sranges}:
|
|
892
|
+
matched = False
|
|
893
|
+
break
|
|
894
|
+
target = sp.simplify(ratio * sp.sqrt(1 - q))
|
|
895
|
+
if sp.simplify(lo_s + target) != 0 or sp.simplify(hi_s - target) != 0:
|
|
896
|
+
matched = False
|
|
897
|
+
break
|
|
898
|
+
vars_.append(var)
|
|
899
|
+
axes.append(sp.simplify(ratio))
|
|
900
|
+
q += var**2 / axes[-1] ** 2
|
|
901
|
+
if not matched:
|
|
902
|
+
continue
|
|
903
|
+
if all(sp.simplify(a - axes[0]) == 0 for a in axes):
|
|
904
|
+
continue
|
|
905
|
+
return EllipsoidRegion(
|
|
906
|
+
canonical_ranges,
|
|
907
|
+
variables_nd=tuple(reversed(vars_)),
|
|
908
|
+
axes=tuple(reversed(axes)),
|
|
909
|
+
)
|
|
910
|
+
return None
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def boole(cond: sp.Expr) -> sp.Piecewise:
|
|
914
|
+
"""Indicator-style helper analogous to Mathematica's Boole."""
|
|
915
|
+
return sp.Piecewise((sp.Integer(1), sp.sympify(cond)), (sp.Integer(0), True))
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def indicator_condition(expr: sp.Expr) -> sp.Expr | None:
|
|
919
|
+
"""Return the condition for an indicator-like Piecewise, else None."""
|
|
920
|
+
expr = sp.sympify(expr)
|
|
921
|
+
if not isinstance(expr, sp.Piecewise) or len(expr.args) != 2:
|
|
922
|
+
return None
|
|
923
|
+
(a1, c1), (a2, c2) = expr.args
|
|
924
|
+
if c2 not in (True, sp.true):
|
|
925
|
+
return None
|
|
926
|
+
a1s = sp.sympify(a1)
|
|
927
|
+
a2s = sp.sympify(a2)
|
|
928
|
+
if a1s == 1 and a2s == 0:
|
|
929
|
+
return sp.sympify(c1)
|
|
930
|
+
if a1s == 0 and a2s == 1:
|
|
931
|
+
return sp.Not(sp.sympify(c1))
|
|
932
|
+
return None
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _extract_rel_bound(cond: sp.Expr):
|
|
936
|
+
cond = sp.sympify(cond)
|
|
937
|
+
if not isinstance(cond, Relational):
|
|
938
|
+
return None
|
|
939
|
+
lhs = sp.sympify(cond.lhs)
|
|
940
|
+
rhs = sp.sympify(cond.rhs)
|
|
941
|
+
return cond.rel_op, lhs, rhs
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _restrict_interval(region: Region, cond: sp.Expr) -> Region | None:
|
|
945
|
+
if len(region.ranges) != 1:
|
|
946
|
+
return None
|
|
947
|
+
x, lo, hi = region.ranges[0]
|
|
948
|
+
lo = sp.sympify(lo)
|
|
949
|
+
hi = sp.sympify(hi)
|
|
950
|
+
data = _extract_rel_bound(cond)
|
|
951
|
+
if data is None:
|
|
952
|
+
return None
|
|
953
|
+
op, lhs, rhs = data
|
|
954
|
+
if lhs == x and not rhs.free_symbols:
|
|
955
|
+
if op in ("<", "<="):
|
|
956
|
+
hi = sp.Min(hi, rhs)
|
|
957
|
+
elif op in (">", ">="):
|
|
958
|
+
lo = sp.Max(lo, rhs)
|
|
959
|
+
else:
|
|
960
|
+
return None
|
|
961
|
+
elif rhs == x and not lhs.free_symbols:
|
|
962
|
+
if op in ("<", "<="):
|
|
963
|
+
lo = sp.Max(lo, lhs)
|
|
964
|
+
elif op in (">", ">="):
|
|
965
|
+
hi = sp.Min(hi, lhs)
|
|
966
|
+
else:
|
|
967
|
+
return None
|
|
968
|
+
else:
|
|
969
|
+
return None
|
|
970
|
+
if sp.simplify(lo - hi) == 0:
|
|
971
|
+
return BoxRegion(((x, lo, hi),))
|
|
972
|
+
if lo.has(sp.Max) or hi.has(sp.Min):
|
|
973
|
+
# Keep a conservative box if the symbolic ordering is not decidable.
|
|
974
|
+
return BoxRegion(((x, lo, hi),))
|
|
975
|
+
if lo.is_real and hi.is_real and (lo.is_number and hi.is_number) and lo > hi:
|
|
976
|
+
return None
|
|
977
|
+
return BoxRegion(((x, lo, hi),))
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _restrict_box_2d(region: BoxRegion, cond: sp.Expr) -> Region | None:
|
|
981
|
+
if len(region.ranges) != 2:
|
|
982
|
+
return None
|
|
983
|
+
(x, xlo, xhi), (y, ylo, yhi) = region.ranges
|
|
984
|
+
xlo = sp.sympify(xlo)
|
|
985
|
+
xhi = sp.sympify(xhi)
|
|
986
|
+
ylo = sp.sympify(ylo)
|
|
987
|
+
yhi = sp.sympify(yhi)
|
|
988
|
+
data = _extract_rel_bound(cond)
|
|
989
|
+
if data is not None:
|
|
990
|
+
op, lhs, rhs = data
|
|
991
|
+
# Conditions only on x or only on y keep us in a box.
|
|
992
|
+
if lhs == x and not rhs.free_symbols:
|
|
993
|
+
reg = _restrict_interval(BoxRegion(((x, xlo, xhi),)), cond)
|
|
994
|
+
if reg is None:
|
|
995
|
+
return None
|
|
996
|
+
x, xlo, xhi = reg.ranges[0]
|
|
997
|
+
return BoxRegion(((x, xlo, xhi), (y, ylo, yhi)))
|
|
998
|
+
if lhs == y and not rhs.free_symbols:
|
|
999
|
+
reg = _restrict_interval(BoxRegion(((y, ylo, yhi),)), cond)
|
|
1000
|
+
if reg is None:
|
|
1001
|
+
return None
|
|
1002
|
+
y, ylo, yhi = reg.ranges[0]
|
|
1003
|
+
return BoxRegion(((x, xlo, xhi), (y, ylo, yhi)))
|
|
1004
|
+
# y <= affine(x) / y >= affine(x) become graph regions.
|
|
1005
|
+
if lhs == y and (rhs.free_symbols <= {x}):
|
|
1006
|
+
if op in ("<", "<="):
|
|
1007
|
+
upper = rhs if sp.simplify(rhs - yhi) != 0 else yhi
|
|
1008
|
+
return GraphRegion(((y, ylo, upper), (x, xlo, xhi)), outer_var=x, inner_var=y)
|
|
1009
|
+
if op in (">", ">="):
|
|
1010
|
+
lower = rhs if sp.simplify(rhs - ylo) != 0 else ylo
|
|
1011
|
+
return GraphRegion(((y, lower, yhi), (x, xlo, xhi)), outer_var=x, inner_var=y)
|
|
1012
|
+
if rhs == y and (lhs.free_symbols <= {x}):
|
|
1013
|
+
if op in ("<", "<="):
|
|
1014
|
+
lower = lhs if sp.simplify(lhs - ylo) != 0 else ylo
|
|
1015
|
+
return GraphRegion(((y, lower, yhi), (x, xlo, xhi)), outer_var=x, inner_var=y)
|
|
1016
|
+
if op in (">", ">="):
|
|
1017
|
+
upper = lhs if sp.simplify(lhs - yhi) != 0 else yhi
|
|
1018
|
+
return GraphRegion(((y, ylo, upper), (x, xlo, xhi)), outer_var=x, inner_var=y)
|
|
1019
|
+
# Standard centered disk/annulus restrictions on a square.
|
|
1020
|
+
r2 = sp.expand(x**2 + y**2)
|
|
1021
|
+
if lhs == r2 and not rhs.free_symbols:
|
|
1022
|
+
if op in ("<", "<="):
|
|
1023
|
+
return DiskRegion(
|
|
1024
|
+
(
|
|
1025
|
+
(y, -sp.sqrt(rhs - x**2), sp.sqrt(rhs - x**2)),
|
|
1026
|
+
(x, -sp.sqrt(rhs), sp.sqrt(rhs)),
|
|
1027
|
+
),
|
|
1028
|
+
radius=sp.sqrt(rhs),
|
|
1029
|
+
)
|
|
1030
|
+
if op in (">", ">=") and xlo == -xhi and ylo == -yhi and xlo == -1 * sp.sympify(xhi):
|
|
1031
|
+
return AnnulusRegion(
|
|
1032
|
+
(x, y), inner_radius=sp.sqrt(rhs), outer_radius=sp.sympify(xhi)
|
|
1033
|
+
)
|
|
1034
|
+
if rhs == r2 and not lhs.free_symbols and op in (">", ">="):
|
|
1035
|
+
return DiskRegion(
|
|
1036
|
+
(
|
|
1037
|
+
(y, -sp.sqrt(lhs - x**2), sp.sqrt(lhs - x**2)),
|
|
1038
|
+
(x, -sp.sqrt(lhs), sp.sqrt(lhs)),
|
|
1039
|
+
),
|
|
1040
|
+
radius=sp.sqrt(lhs),
|
|
1041
|
+
)
|
|
1042
|
+
return None
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def restrict_region(region: Region, cond: sp.Expr) -> Region | None:
|
|
1046
|
+
"""Restrict a supported region by a simple Boolean condition.
|
|
1047
|
+
|
|
1048
|
+
This is intentionally conservative and only supports conditions that can be
|
|
1049
|
+
represented by the current region model.
|
|
1050
|
+
"""
|
|
1051
|
+
cond = sp.sympify(cond)
|
|
1052
|
+
if cond in (True, sp.true):
|
|
1053
|
+
return region
|
|
1054
|
+
if cond in (False, sp.false):
|
|
1055
|
+
return None
|
|
1056
|
+
if isinstance(cond, sp.Or):
|
|
1057
|
+
pieces = []
|
|
1058
|
+
for arg in cond.args:
|
|
1059
|
+
reg = restrict_region(region, arg)
|
|
1060
|
+
if reg is not None:
|
|
1061
|
+
pieces.append(reg)
|
|
1062
|
+
if not pieces:
|
|
1063
|
+
return None
|
|
1064
|
+
if len(pieces) == 1:
|
|
1065
|
+
return pieces[0]
|
|
1066
|
+
return UnionRegion(tuple(pieces))
|
|
1067
|
+
if isinstance(cond, sp.And):
|
|
1068
|
+
cur = region
|
|
1069
|
+
for arg in cond.args:
|
|
1070
|
+
cur = restrict_region(cur, arg)
|
|
1071
|
+
if cur is None:
|
|
1072
|
+
return None
|
|
1073
|
+
return cur
|
|
1074
|
+
if isinstance(region, BoxRegion):
|
|
1075
|
+
if len(region.ranges) == 1:
|
|
1076
|
+
return _restrict_interval(region, cond)
|
|
1077
|
+
if len(region.ranges) == 2:
|
|
1078
|
+
return _restrict_box_2d(region, cond)
|
|
1079
|
+
# Allow restrictions on plain iterated 1D/2D regions by reusing the same logic.
|
|
1080
|
+
if isinstance(region, IteratedRegion) and not isinstance(
|
|
1081
|
+
region, (SimplexRegion, GraphRegion, DiskRegion, BallRegion)
|
|
1082
|
+
):
|
|
1083
|
+
box_like = BoxRegion(tuple(region.ranges))
|
|
1084
|
+
reg = restrict_region(box_like, cond)
|
|
1085
|
+
if reg is not None:
|
|
1086
|
+
return reg
|
|
1087
|
+
return None
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def region_from_ranges(ranges) -> Region:
|
|
1091
|
+
norm = _normalize_ranges_input(ranges)
|
|
1092
|
+
if isinstance(norm, Region):
|
|
1093
|
+
return norm
|
|
1094
|
+
ranges = norm
|
|
1095
|
+
|
|
1096
|
+
simplex = match_standard_simplex(ranges)
|
|
1097
|
+
if simplex is not None:
|
|
1098
|
+
return simplex
|
|
1099
|
+
disk = match_standard_disk(ranges)
|
|
1100
|
+
if disk is not None:
|
|
1101
|
+
return disk
|
|
1102
|
+
ball = match_standard_ball(ranges)
|
|
1103
|
+
if ball is not None:
|
|
1104
|
+
return ball
|
|
1105
|
+
ellipsoid = match_standard_ellipsoid(ranges)
|
|
1106
|
+
if ellipsoid is not None:
|
|
1107
|
+
return ellipsoid
|
|
1108
|
+
affine_simplex = match_affine_simplex(ranges)
|
|
1109
|
+
if affine_simplex is not None:
|
|
1110
|
+
return affine_simplex
|
|
1111
|
+
graph = match_graph_region(ranges)
|
|
1112
|
+
if graph is not None:
|
|
1113
|
+
return graph
|
|
1114
|
+
|
|
1115
|
+
vars_set = {r[0] for r in ranges}
|
|
1116
|
+
is_box = True
|
|
1117
|
+
for _, lo, hi in ranges:
|
|
1118
|
+
lo_s = sp.sympify(lo)
|
|
1119
|
+
hi_s = sp.sympify(hi)
|
|
1120
|
+
if (lo_s.free_symbols | hi_s.free_symbols) & vars_set:
|
|
1121
|
+
is_box = False
|
|
1122
|
+
break
|
|
1123
|
+
if is_box:
|
|
1124
|
+
return BoxRegion(tuple(ranges))
|
|
1125
|
+
return IteratedRegion(tuple(ranges))
|