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.
@@ -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))