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,2330 @@
1
+ """Core algorithms for exact multiple integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import signal
7
+ from collections.abc import Callable
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass
10
+
11
+ import sympy as sp
12
+ from sympy import (
13
+ Abs,
14
+ Dummy,
15
+ Heaviside,
16
+ Piecewise,
17
+ det,
18
+ diff,
19
+ gamma,
20
+ integrate,
21
+ limit,
22
+ oo,
23
+ pi,
24
+ sign,
25
+ simplify,
26
+ solve,
27
+ sqrt,
28
+ symbols,
29
+ )
30
+ from sympy.matrices import Matrix
31
+
32
+ from multiple_integrate.regions import (
33
+ AffineSimplexRegion,
34
+ AnnulusRegion,
35
+ BallRegion,
36
+ DiskRegion,
37
+ EllipsoidRegion,
38
+ GraphRegion,
39
+ Region,
40
+ SimplexRegion,
41
+ SphericalShellRegion,
42
+ UnionRegion,
43
+ indicator_condition,
44
+ region_from_ranges,
45
+ restrict_region,
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ChangeOfVariables:
51
+ src_vars: tuple[sp.Symbol, ...]
52
+ tgt_vars: tuple[sp.Symbol, ...]
53
+ forward_map: tuple[sp.Expr, ...] | None
54
+ inverse_map: tuple[sp.Expr, ...] | None
55
+ jacobian: sp.Expr | None
56
+ inv_jacobian: sp.Expr | None
57
+ conditions: tuple[sp.Expr, ...] = ()
58
+ name: str = ""
59
+
60
+
61
+ class BaseRegion:
62
+ pass
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class TransformedIntegral:
67
+ expr: sp.Expr
68
+ ranges: tuple[tuple[sp.Symbol, sp.Expr, sp.Expr], ...]
69
+ region: BaseRegion | None
70
+ transform: ChangeOfVariables
71
+ notes: tuple[str, ...] = ()
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class CoordinateTransform:
76
+ """Change-of-variables data for structured cordinate transforms."""
77
+
78
+ source_vars: tuple[sp.Symbol, ...]
79
+ target_vars: tuple[sp.Symbol, ...]
80
+ forward_map: tuple[sp.Expr, ...]
81
+ jacobian: sp.Expr
82
+ target_ranges: tuple[tuple, ...]
83
+
84
+ def apply(self, expr: sp.Expr) -> sp.Expr:
85
+ # The transformed integrand is f(T(u)) * |det DT(u)|.
86
+ subs = dict(zip(self.source_vars, self.forward_map, strict=True))
87
+ transformed = sp.sympify(expr).subs(subs) * self.jacobian
88
+ with suppress(Exception):
89
+ transformed = sp.trigsimp(sp.factor_terms(sp.cancel(transformed)))
90
+ return _fast_simplify(transformed)
91
+
92
+
93
+ class Decomposition:
94
+ """
95
+ Result of decomposing an integrand F(x₁,…,xₙ) into f ∘ g.
96
+
97
+ Attributes
98
+ ----------
99
+ f_outer : Callable – univariate function, maps SymPy expr -> SymPy expr
100
+ g_inner : sp.Expr – the "inner" expression in the integration variables
101
+ is_polynomial : bool – True if g_inner is a polynomial in the variables
102
+ """
103
+
104
+ __slots__ = ("f_outer", "g_inner", "is_polynomial")
105
+
106
+ def __init__(self, f_outer: Callable, g_inner: sp.Expr, is_polynomial: bool):
107
+ self.f_outer = f_outer
108
+ self.g_inner = g_inner
109
+ self.is_polynomial = is_polynomial
110
+
111
+
112
+ def _normalize_seq(obj):
113
+ """Normalize a list/tuple-like input to a tuple."""
114
+ if isinstance(obj, (list, tuple)):
115
+ return tuple(obj)
116
+ return (obj,)
117
+
118
+
119
+ def _vars_set(vars_: list[sp.Symbol] | tuple[sp.Symbol, ...]) -> set[sp.Symbol]:
120
+ return set(vars_)
121
+
122
+
123
+ def _depends_on_vars(expr: sp.Expr, vars_set: set[sp.Symbol]) -> bool:
124
+ return bool(sp.sympify(expr).free_symbols & vars_set)
125
+
126
+
127
+ def _is_constant_wrt(expr: sp.Expr, vars_set: set[sp.Symbol]) -> bool:
128
+ return not _depends_on_vars(expr, vars_set)
129
+
130
+
131
+ def _clean_expr(expr: sp.Expr) -> sp.Expr:
132
+ expr = sp.sympify(expr)
133
+ try:
134
+ return sp.simplify(expr)
135
+ except Exception:
136
+ return expr
137
+
138
+
139
+ def _const_result(expr: sp.Expr, region: Region, parsed_ranges: list[tuple]) -> sp.Expr:
140
+ volume = region.constant_volume()
141
+ if volume is None:
142
+ volume = sp.Integer(1)
143
+ for _, lo, hi in parsed_ranges:
144
+ volume *= sp.sympify(hi) - sp.sympify(lo)
145
+ return _fast_simplify(sp.sympify(expr) * volume)
146
+
147
+
148
+ def _inactive_finite_volume(
149
+ active_vars: list[sp.Symbol],
150
+ vars_: list[sp.Symbol],
151
+ ranges: list[tuple],
152
+ ) -> sp.Expr | None:
153
+ """Return the product of inactive finite dimensions when it's safe.
154
+ This succeeds only when every inactive bound is finite and independent of
155
+ the active variables, and active bounds do not depend on inactive vars.
156
+ """
157
+ active_set = set(active_vars)
158
+ all_set = set(vars_)
159
+ inactive_set = all_set - active_set
160
+ volume = sp.Integer(1)
161
+ for v, lo, hi in ranges:
162
+ lo_s = sp.sympify(lo)
163
+ hi_s = sp.sympify(hi)
164
+ if v in active_set:
165
+ if (lo_s.free_symbols | hi_s.free_symbols) & inactive_set:
166
+ return None
167
+ continue
168
+ if lo_s in (-oo, oo) or hi_s in (-oo, oo):
169
+ return None
170
+ if (lo_s.free_symbols | hi_s.free_symbols) & active_set:
171
+ return None
172
+ volume *= hi_s - lo_s
173
+ return _fast_simplify(volume)
174
+
175
+
176
+ def _should_try_layercake(
177
+ f_outer: Callable,
178
+ g: sp.Expr,
179
+ vars_: list[sp.Symbol],
180
+ ranges: list[tuple],
181
+ ) -> bool:
182
+ """Entry point for the expensive generic layer-cake fallback."""
183
+ active_vars = [v for v in vars_ if v in g.free_symbols]
184
+ if len(active_vars) > 1:
185
+ return False
186
+ return not (
187
+ len(active_vars) == 1 and _inactive_finite_volume(active_vars, vars_, ranges) is None
188
+ )
189
+
190
+
191
+ def _decompose_unary_wrapper(
192
+ expr: sp.Expr, vars_: list[sp.Symbol], vars_set: set[sp.Symbol]
193
+ ) -> Decomposition | None:
194
+ n_var_args = sum(1 for a in expr.args if _depends_on_vars(a, vars_set))
195
+ if n_var_args != 1:
196
+ return None
197
+ inner = next(a for a in expr.args if _depends_on_vars(a, vars_set))
198
+ t = Dummy("t")
199
+ try:
200
+ outer = sp.Lambda(t, expr.subs(inner, t))
201
+ return Decomposition(outer, inner, is_polynomial=_is_polynomial(inner, vars_))
202
+ except Exception:
203
+ return None
204
+
205
+
206
+ def _decompose_mul_constants(
207
+ expr: sp.Expr, vars_: list[sp.Symbol], vars_set: set[sp.Symbol]
208
+ ) -> Decomposition | None:
209
+ if not expr.is_Mul:
210
+ return None
211
+ const_part = sp.Integer(1)
212
+ var_part = sp.Integer(1)
213
+ for factor in expr.args:
214
+ if _depends_on_vars(factor, vars_set):
215
+ var_part *= factor
216
+ else:
217
+ const_part *= factor
218
+ if const_part == 1 or var_part == 1:
219
+ return None
220
+ sub = _decompose(var_part, vars_)
221
+ if sub is None:
222
+ return None
223
+ inner_f = sub.f_outer
224
+ t = Dummy("t")
225
+ outer = sp.Lambda(t, const_part * inner_f(t))
226
+ return Decomposition(outer, sub.g_inner, is_polynomial=sub.is_polynomial)
227
+
228
+
229
+ def _decompose_add_constants(
230
+ expr: sp.Expr, vars_: list[sp.Symbol], vars_set: set[sp.Symbol]
231
+ ) -> Decomposition | None:
232
+ if not expr.is_Add:
233
+ return None
234
+ const_part = sp.Integer(0)
235
+ var_part = sp.Integer(0)
236
+ for term in expr.args:
237
+ if _depends_on_vars(term, vars_set):
238
+ var_part += term
239
+ else:
240
+ const_part += term
241
+ if const_part == 0 or var_part == 0:
242
+ return None
243
+ sub = _decompose(var_part, vars_)
244
+ if sub is None:
245
+ return None
246
+ inner_f = sub.f_outer
247
+ t = Dummy("t")
248
+ outer = sp.Lambda(t, inner_f(t) + const_part)
249
+ return Decomposition(outer, sub.g_inner, is_polynomial=sub.is_polynomial)
250
+
251
+
252
+ def _decompose_single_var(
253
+ expr: sp.Expr, vars_: list[sp.Symbol], vars_set: set[sp.Symbol]
254
+ ) -> Decomposition | None:
255
+ active = [v for v in vars_ if v in sp.sympify(expr).free_symbols]
256
+ if len(active) != 1:
257
+ return None
258
+ t = Dummy("t")
259
+ return Decomposition(sp.Lambda(t, t), expr, is_polynomial=_is_polynomial(expr, vars_))
260
+
261
+
262
+ def _decompose_shallow(expr: sp.Expr, vars_: list[sp.Symbol]) -> Decomposition | None:
263
+ """Single-pass decomposition used as the base case for recursive peeling."""
264
+ vars_set = _vars_set(vars_)
265
+
266
+ try:
267
+ sp.Poly(expr, *vars_)
268
+ t = Dummy("t")
269
+ return Decomposition(sp.Lambda(t, t), expr, is_polynomial=True)
270
+ except sp.PolynomialError:
271
+ pass
272
+
273
+ if expr.func in (sp.log, sp.Abs, sp.sign, sp.floor, sp.ceiling):
274
+ sub = _decompose_single_var(expr, vars_, vars_set)
275
+ if sub is not None:
276
+ return sub
277
+
278
+ sub = _decompose_unary_wrapper(expr, vars_, vars_set)
279
+ if sub is not None:
280
+ return sub
281
+
282
+ if expr.is_Pow:
283
+ base, exp_ = expr.args
284
+ if _is_constant_wrt(exp_, vars_set) and _depends_on_vars(base, vars_set):
285
+ t = Dummy("t")
286
+ return Decomposition(
287
+ sp.Lambda(t, t**exp_), base, is_polynomial=_is_polynomial(base, vars_)
288
+ )
289
+
290
+ sub = _decompose_mul_constants(expr, vars_, vars_set)
291
+ if sub is not None:
292
+ return sub
293
+
294
+ sub = _decompose_add_constants(expr, vars_, vars_set)
295
+ if sub is not None:
296
+ return sub
297
+
298
+ return _decompose_single_var(expr, vars_, vars_set)
299
+
300
+
301
+ def _decompose(expr: sp.Expr, vars_: list[sp.Symbol]) -> Decomposition | None:
302
+ """Recursively peel wrappers to expose a deeper public decomposition."""
303
+ vars_set = _vars_set(vars_)
304
+
305
+ if expr.func in (sp.log, sp.Abs, sp.sign, sp.floor, sp.ceiling):
306
+ sub = _decompose_single_var(expr, vars_, vars_set)
307
+ if sub is not None:
308
+ return sub
309
+
310
+ n_var_args = sum(1 for a in expr.args if _depends_on_vars(a, vars_set))
311
+ if n_var_args == 1:
312
+ inner = next(a for a in expr.args if _depends_on_vars(a, vars_set))
313
+ t = Dummy("t")
314
+ try:
315
+ outer = sp.Lambda(t, expr.subs(inner, t))
316
+ sub = _decompose(inner, vars_)
317
+ if sub is not None:
318
+ u = Dummy("u")
319
+ try:
320
+ return Decomposition(
321
+ sp.Lambda(u, outer(sub.f_outer(u))),
322
+ sub.g_inner,
323
+ sub.is_polynomial,
324
+ )
325
+ except Exception:
326
+ pass
327
+ return Decomposition(outer, inner, is_polynomial=_is_polynomial(inner, vars_))
328
+ except Exception:
329
+ pass
330
+
331
+ if expr.is_Pow:
332
+ base, exp_ = expr.args
333
+ if _is_constant_wrt(exp_, vars_set) and _depends_on_vars(base, vars_set):
334
+ sub = _decompose(base, vars_)
335
+ t = Dummy("t")
336
+ if sub is not None:
337
+ u = Dummy("u")
338
+ try:
339
+ return Decomposition(
340
+ sp.Lambda(u, sub.f_outer(u) ** exp_),
341
+ sub.g_inner,
342
+ sub.is_polynomial,
343
+ )
344
+ except Exception:
345
+ pass
346
+ return Decomposition(
347
+ sp.Lambda(t, t**exp_), base, is_polynomial=_is_polynomial(base, vars_)
348
+ )
349
+
350
+ if expr.is_Mul:
351
+ const_part = sp.Integer(1)
352
+ var_part = sp.Integer(1)
353
+ for factor in expr.args:
354
+ if _depends_on_vars(factor, vars_set):
355
+ var_part *= factor
356
+ else:
357
+ const_part *= factor
358
+ if const_part != 1 and var_part != 1:
359
+ sub = _decompose(var_part, vars_)
360
+ if sub is not None:
361
+ t = Dummy("t")
362
+ return Decomposition(
363
+ sp.Lambda(t, const_part * sub.f_outer(t)),
364
+ sub.g_inner,
365
+ sub.is_polynomial,
366
+ )
367
+
368
+ if expr.is_Add:
369
+ const_part = sp.Integer(0)
370
+ var_part = sp.Integer(0)
371
+ for term in expr.args:
372
+ if _depends_on_vars(term, vars_set):
373
+ var_part += term
374
+ else:
375
+ const_part += term
376
+ if const_part != 0 and var_part != 0:
377
+ sub = _decompose(var_part, vars_)
378
+ if sub is not None:
379
+ t = Dummy("t")
380
+ return Decomposition(
381
+ sp.Lambda(t, sub.f_outer(t) + const_part),
382
+ sub.g_inner,
383
+ sub.is_polynomial,
384
+ )
385
+
386
+ return _decompose_shallow(expr, vars_)
387
+
388
+
389
+ def _is_polynomial(expr: sp.Expr, vars_: list[sp.Symbol]) -> bool:
390
+ try:
391
+ sp.Poly(expr, *vars_)
392
+ return True
393
+ except sp.PolynomialError:
394
+ return False
395
+
396
+
397
+ def _coefficient_arrays(poly: sp.Expr, vars_: list[sp.Symbol]):
398
+ """
399
+ Return (constant, linear_vector, quadratic_matrix) for a degree-≤2 polynomial.
400
+ Raises ValueError for higher-degree polynomials.
401
+ """
402
+ poly_obj = sp.Poly(poly, *vars_)
403
+ if sp.degree(poly_obj) > 2:
404
+ raise ValueError("Polynomial degree > 2")
405
+
406
+ n = len(vars_)
407
+ c = poly_obj.nth(*([0] * n))
408
+ b = Matrix([poly_obj.nth(*([1 if j == i else 0 for j in range(n)])) for i in range(n)])
409
+ A = sp.zeros(n, n)
410
+ for i in range(n):
411
+ for j in range(i, n):
412
+ idx = [0] * n
413
+ if i == j:
414
+ idx[i] = 2
415
+ coeff = poly_obj.nth(*idx)
416
+ else:
417
+ idx[i] = 1
418
+ idx[j] = 1
419
+ coeff = poly_obj.nth(*idx) / 2
420
+ A[i, j] = coeff
421
+ A[j, i] = coeff
422
+ return c, b, A
423
+
424
+
425
+ def _is_even_function(expr: sp.Expr, var: sp.Symbol) -> bool:
426
+ return sp.simplify(expr.subs(var, -var) - expr) == 0
427
+
428
+
429
+ def _heaviside_to_piecewise(expr: sp.Expr) -> sp.Expr:
430
+ """
431
+ Rewrite every Heaviside sub-expression as Piecewise before integration.
432
+
433
+ SymPy's integrate() falls back to Meijer G functions when it encounters
434
+ Heaviside(linear(x, y)) with two free symbolic variables, producing
435
+ unevaluated or incorrect results. Rewriting to Piecewise first lets
436
+ SymPy's piecewise integration machinery handle it correctly instead.
437
+ """
438
+ return expr.rewrite(Heaviside, Piecewise)
439
+
440
+
441
+ def _fast_simplify(expr: sp.Expr) -> sp.Expr:
442
+ """
443
+ Faster alternative to sympy.simplify for expressions arising in integration.
444
+ Tries cancel (rational), trigsimp (trig), and falls back to simplify only
445
+ when the expression is not already in a reduced form.
446
+ """
447
+ if expr.is_number or expr.is_symbol:
448
+ return expr
449
+ # Try cheap reductions first
450
+ try:
451
+ c = sp.cancel(expr)
452
+ if c != expr:
453
+ return c
454
+ except Exception:
455
+ pass
456
+ try:
457
+ t = sp.trigsimp(expr)
458
+ if t != expr:
459
+ return t
460
+ except Exception:
461
+ pass
462
+ # Fall back to full simplify only for small expressions
463
+ if sp.count_ops(expr) < 40:
464
+ try:
465
+ return sp.simplify(expr)
466
+ except Exception:
467
+ pass
468
+ return expr
469
+
470
+
471
+ @functools.lru_cache(maxsize=512)
472
+ def _is_symmetric_range(lo: sp.Expr, hi: sp.Expr) -> bool:
473
+ try:
474
+ return sp.simplify(sp.sympify(lo) + sp.sympify(hi)) == 0
475
+ except Exception:
476
+ return False
477
+
478
+
479
+ def _split_additive_terms(expr: sp.Expr) -> list[sp.Expr] | None:
480
+ """Return additive terms for early sum splitting when worthwhile."""
481
+ if expr.is_Add and len(expr.args) > 1:
482
+ return list(expr.args)
483
+ return None
484
+
485
+
486
+ def _try_standard_1d(expr: sp.Expr, var: sp.Symbol, lo: sp.Expr, hi: sp.Expr, opts: dict):
487
+ """Tiny recognizers for common exact 1-D definite integrals."""
488
+ lo_s, hi_s = sp.sympify(lo), sp.sympify(hi)
489
+ try:
490
+ # exp(a*x+b) on finite or infinite intervals
491
+ if expr.func == sp.exp:
492
+ arg = sp.expand(expr.args[0])
493
+ poly = sp.Poly(arg, var)
494
+ if poly.degree() <= 1:
495
+ a = poly.nth(1)
496
+ b = poly.nth(0)
497
+ if a != 0:
498
+ return _fast_simplify(sp.exp(b) * (sp.exp(a * hi_s) - sp.exp(a * lo_s)) / a)
499
+ # sin(ax+b), cos(ax+b)
500
+ if expr.func in (sp.sin, sp.cos):
501
+ arg = sp.expand(expr.args[0])
502
+ poly = sp.Poly(arg, var)
503
+ if poly.degree() <= 1:
504
+ a = poly.nth(1)
505
+ b = poly.nth(0)
506
+ if a != 0:
507
+ if expr.func == sp.sin:
508
+ return _fast_simplify((-sp.cos(a * hi_s + b) + sp.cos(a * lo_s + b)) / a)
509
+ return _fast_simplify((sp.sin(a * hi_s + b) - sp.sin(a * lo_s + b)) / a)
510
+ except Exception:
511
+ pass
512
+ try:
513
+ # polynomial times Gaussian exp(alpha*x**2 + beta*x + gamma)
514
+ factors = sp.Mul.make_args(expr)
515
+ exp_factor = next((f for f in factors if f.func == sp.exp), None)
516
+ if exp_factor is not None:
517
+ rest = _fast_simplify(expr / exp_factor)
518
+ q = sp.expand(exp_factor.args[0])
519
+ qpoly = sp.Poly(q, var)
520
+ if qpoly.degree() <= 2:
521
+ a = qpoly.nth(2)
522
+ b = qpoly.nth(1)
523
+ c = qpoly.nth(0)
524
+ if a != 0 and not rest.has(sp.exp) and sp.Poly(rest, var) is not None:
525
+ # let SymPy handle this structured case directly
526
+ res = integrate(
527
+ sp.expand(rest) * sp.exp(a * var**2 + b * var + c),
528
+ (var, lo_s, hi_s),
529
+ **opts,
530
+ )
531
+ if not isinstance(res, sp.Integral):
532
+ return _fast_simplify(res)
533
+ except Exception:
534
+ pass
535
+ try:
536
+ # 1/(x**2 + a**2) over full line
537
+ num, den = sp.fraction(sp.together(expr))
538
+ if sp.simplify(num).free_symbols.isdisjoint({var}) and lo_s == -oo and hi_s == oo:
539
+ dpoly = sp.Poly(sp.expand(den), var)
540
+ if dpoly.degree() == 2 and dpoly.nth(1) == 0:
541
+ a2 = _fast_simplify(dpoly.nth(0) / dpoly.nth(2))
542
+ if sp.ask(sp.Q.positive(a2)):
543
+ return _fast_simplify(sp.pi * num / (sp.sqrt(dpoly.nth(2)) * sp.sqrt(a2)))
544
+ except Exception:
545
+ pass
546
+ return None
547
+
548
+
549
+ def _real_critical_points(g: sp.Expr, var: sp.Symbol, lo: sp.Expr, hi: sp.Expr) -> list[sp.Expr]:
550
+ """
551
+ Return sorted list of real critical points of g(var) strictly inside (lo, hi).
552
+ Includes pts where g is not differentiable (e.g. |x| at 0).
553
+ Cached: S6 and S7 both call this on the same arguments.
554
+ """
555
+ pts = []
556
+
557
+ # Solving g'(x)=0 can explode on transcendental inputs, so cap that step
558
+ # and continue with any critical points that were found quickly.
559
+ def _solve_timed(expr, var, secs=1.0):
560
+ class _T(Exception):
561
+ pass
562
+
563
+ old = signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(_T()))
564
+ signal.setitimer(signal.ITIMER_REAL, secs)
565
+ try:
566
+ return solve(expr, var)
567
+ except Exception:
568
+ return []
569
+ finally:
570
+ signal.setitimer(signal.ITIMER_REAL, 0)
571
+ signal.signal(signal.SIGALRM, old)
572
+
573
+ try:
574
+ dg = diff(g, var)
575
+ solns = _solve_timed(dg, var)
576
+ for s in solns:
577
+ s = sp.simplify(s)
578
+ if not s.is_real:
579
+ continue
580
+ try:
581
+ if lo.is_number and hi.is_number and s.is_number:
582
+ inside = float(lo) < float(s) < float(hi)
583
+ else:
584
+ inside = sp.ask(sp.Q.positive(s - lo) & sp.Q.positive(hi - s))
585
+ except Exception:
586
+ inside = None
587
+ if inside is True or inside is None:
588
+ pts.append(s)
589
+ except Exception:
590
+ pass
591
+ # Non-differentiable points: where argument of Abs is zero
592
+ for sub in sp.preorder_traversal(g):
593
+ if sub.is_Pow and sub.args[1] == sp.Rational(1, 2):
594
+ base = sub.args[0]
595
+ for s in solve(base, var):
596
+ s = sp.simplify(s)
597
+ if s.is_real:
598
+ pts.append(s)
599
+ if isinstance(sub, sp.Abs):
600
+ for s in solve(sub.args[0], var):
601
+ s = sp.simplify(s)
602
+ if s.is_real:
603
+ pts.append(s)
604
+ # Deduplicate and filter
605
+ seen, result = set(), []
606
+ for p in sorted(pts, key=lambda e: float(e) if e.is_number else 0):
607
+ key = str(sp.simplify(p))
608
+ if key not in seen:
609
+ seen.add(key)
610
+ result.append(p)
611
+ return result
612
+
613
+
614
+ def _g_range_on_interval(
615
+ g: sp.Expr, var: sp.Symbol, lo: sp.Expr, hi: sp.Expr
616
+ ) -> tuple[sp.Expr, sp.Expr]:
617
+ """
618
+ Return (g_min, g_max) of g over [lo, hi] by evaluating at endpoints and
619
+ critical points.
620
+ """
621
+ cpts = _real_critical_points(g, var, lo, hi)
622
+ candidates = []
623
+ for pt in [lo, hi] + cpts:
624
+ try:
625
+ val = g.subs(var, pt)
626
+ val = sp.simplify(val)
627
+ if val.is_real or val.is_number:
628
+ candidates.append(val)
629
+ except Exception:
630
+ pass
631
+ if not candidates:
632
+ return -oo, oo
633
+ return sp.Min(*candidates), sp.Max(*candidates)
634
+
635
+
636
+ def _try_linear(
637
+ f_outer: Callable,
638
+ g: sp.Expr,
639
+ vars_: list[sp.Symbol],
640
+ ranges: list[tuple],
641
+ opts: dict,
642
+ ) -> sp.Expr | None:
643
+ """
644
+ ∫_{[0,∞)^n} f(b·x + c) dx reduced to a 1-D integral via simplex measure.
645
+
646
+ As x ranges over [0,∞)^n the linear form g = b·x + c ranges over
647
+ [c, ∞) when all bᵢ > 0, or (-∞, c] when all bᵢ < 0.
648
+
649
+ All-positive b:
650
+ 1/(∏bᵢ·(n-1)!) ∫_c^∞ (y-c)^{n-1} f(y) dy
651
+
652
+ All-negative b:
653
+ 1/(∏|bᵢ|·(n-1)!) ∫_{-∞}^c (c-y)^{n-1} f(y) dy
654
+
655
+ Mixed-sign b cannot be handled by this formula; returns None.
656
+ """
657
+ if not all(r[1] == 0 and r[2] == oo for r in ranges):
658
+ return None
659
+ try:
660
+ c, b_vec, A = _coefficient_arrays(g, vars_)
661
+ except Exception:
662
+ return None
663
+ n = len(vars_)
664
+ if sp.zeros(n, n) != A:
665
+ return None
666
+ b_list = list(b_vec)
667
+ if any(bi == 0 for bi in b_list):
668
+ return None
669
+
670
+ all_pos = all(sp.ask(sp.Q.positive(bi)) for bi in b_list)
671
+ all_neg = all(sp.ask(sp.Q.negative(bi)) for bi in b_list)
672
+ if not all_pos and not all_neg:
673
+ return None
674
+
675
+ def _integrate_timed(expr, bounds, secs=3.0):
676
+ class _T(Exception):
677
+ pass
678
+
679
+ old = signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(_T()))
680
+ signal.setitimer(signal.ITIMER_REAL, secs)
681
+ try:
682
+ return integrate(expr, bounds, **opts)
683
+ except _T:
684
+ return sp.Integral(expr, bounds)
685
+ except Exception:
686
+ return None
687
+ finally:
688
+ signal.setitimer(signal.ITIMER_REAL, 0)
689
+ signal.signal(signal.SIGALRM, old)
690
+
691
+ y = Dummy("y")
692
+ abs_b_prod = sp.prod([sp.Abs(bi) for bi in b_list])
693
+ prefactor = sp.Integer(1) / (abs_b_prod * sp.factorial(n - 1))
694
+ if all_pos:
695
+ integrand = prefactor * (y - c) ** (n - 1) * f_outer(y)
696
+ result = _integrate_timed(integrand, (y, c, oo))
697
+ else: # all_neg: g decreases from c to -\infty
698
+ integrand = prefactor * (c - y) ** (n - 1) * f_outer(y)
699
+ result = _integrate_timed(integrand, (y, -oo, c))
700
+ return result
701
+
702
+
703
+ def _qs_integrate(
704
+ f_outer: Callable, A_mat: Matrix, b_vec: Matrix, c_val: sp.Expr, n: int, opts: dict
705
+ ) -> sp.Expr | None:
706
+ """
707
+ ∫_{ℝⁿ} f(xᵀAx + b·x + c) dx via ellipsoid surface-area layer-cake.
708
+ Requires A positive definite.
709
+ """
710
+ try:
711
+ A_inv = A_mat.inv()
712
+ except Exception:
713
+ return None
714
+ try:
715
+ evs = list(A_mat.eigenvals().keys())
716
+ if any(sp.ask(sp.Q.negative(ev)) for ev in evs):
717
+ return None
718
+ except Exception:
719
+ pass
720
+ det_A = det(A_mat)
721
+ if det_A == 0:
722
+ return None
723
+
724
+ y_min = c_val - (b_vec.T * A_inv * b_vec)[0, 0] / 4
725
+ y = Dummy("y")
726
+ fac = pi ** sp.Rational(n, 2) / (sqrt(det_A) * gamma(sp.Rational(n, 2) + 1))
727
+ surface = n * sp.Rational(1, 2) * (y - y_min) ** (sp.Rational(n, 2) - 1)
728
+ result = integrate(fac * surface * f_outer(y), (y, y_min, oo), **opts)
729
+ return None if result.has(sp.Integral) else result
730
+
731
+
732
+ def _try_quadratic_infinite(f_outer, g, vars_, ranges, opts):
733
+ # Fast guard: skip Poly construction if any range is not (-∞, ∞)
734
+ if not all(r[1] == -oo and r[2] == oo for r in ranges):
735
+ return None
736
+ # Fast guard: g must be degree-2 polynomial (has a quadratic term)
737
+ if not any(sp.degree(g, v) == 2 for v in vars_ if v in g.free_symbols):
738
+ return None
739
+ try:
740
+ c, b_vec, A = _coefficient_arrays(g, vars_)
741
+ except Exception:
742
+ return None
743
+ return _qs_integrate(f_outer, A, b_vec, c, len(vars_), opts)
744
+
745
+
746
+ def _try_even_half_quad(f_outer, g, vars_, ranges, opts):
747
+ half = sum(1 for r in ranges if r[1] == 0 and r[2] == oo)
748
+ full = sum(1 for r in ranges if r[1] == -oo and r[2] == oo)
749
+ if half + full != len(vars_):
750
+ return None
751
+ for r in ranges:
752
+ if r[1] == 0 and r[2] == oo and not _is_even_function(f_outer(g), r[0]):
753
+ return None
754
+ try:
755
+ c, b_vec, A = _coefficient_arrays(g, vars_)
756
+ except Exception:
757
+ return None
758
+ full_result = _qs_integrate(f_outer, A, b_vec, c, len(vars_), opts)
759
+ if full_result is None:
760
+ return None
761
+ return full_result / sp.Integer(2) ** half
762
+
763
+
764
+ def _try_general_polynomial(
765
+ f_outer: Callable,
766
+ g: sp.Expr,
767
+ vars_: list[sp.Symbol],
768
+ ranges: list[tuple],
769
+ opts: dict,
770
+ ) -> sp.Expr | None:
771
+ """
772
+ Layer-cake via symbolic Heaviside integral. Works for any polynomial g
773
+ on a bounded or semi-infinite domain.
774
+
775
+ Skipped when g depends on more than one variable: integrating
776
+ Piecewise(y_dummy - g(x1, x2, ...) < 0, ...) over multiple variables
777
+ causes SymPy to hang on Meijer G reduction. Those cases fall through
778
+ to _iterated_integrate which handles them correctly.
779
+ """
780
+ active_vars = [v for v in vars_ if v in g.free_symbols]
781
+ if len(active_vars) > 1:
782
+ return None
783
+ if len(active_vars) == 1 and _inactive_finite_volume(active_vars, vars_, ranges) is None:
784
+ return None
785
+ y = Dummy("y")
786
+ mu_y = _heaviside_to_piecewise(Heaviside(y - g))
787
+ try:
788
+ for r in ranges:
789
+ mu_y = integrate(mu_y, (r[0], r[1], r[2]), **opts)
790
+ if mu_y.has(sp.Integral):
791
+ return None
792
+ except Exception:
793
+ return None
794
+
795
+ density = simplify(diff(mu_y, y))
796
+
797
+ # y bounds: evaluate g at corners of the domain
798
+ y_vals = []
799
+ for r in ranges:
800
+ for ep in [r[1], r[2]]:
801
+ if ep not in (oo, -oo):
802
+ y_vals.append(g.subs(r[0], ep))
803
+ y_min = sp.Min(*y_vals) if y_vals else -oo
804
+ y_max = sp.Max(*y_vals) if y_vals else oo
805
+
806
+ try:
807
+ result = integrate(f_outer(y) * density, (y, y_min, y_max), **opts)
808
+ return None if result.has(sp.Integral) else result
809
+ except Exception:
810
+ return None
811
+
812
+
813
+ def _parse_superellipse_core(
814
+ g: sp.Expr, vars_: list[sp.Symbol]
815
+ ) -> tuple[sp.Expr, sp.Expr, dict[sp.Symbol, tuple[sp.Expr, sp.Expr]]] | None:
816
+ """Return ``(h, k, term_map)`` for g = h**k with h = Σ a_i x_i**p_i."""
817
+ k = sp.Integer(1)
818
+ h = g
819
+ if g.is_Pow:
820
+ base, exp_ = g.as_base_exp()
821
+ if exp_.free_symbols or not (exp_.is_positive and exp_.is_real):
822
+ return None
823
+ k = sp.sympify(exp_)
824
+ h = base
825
+ if not h.is_Add:
826
+ return None
827
+ vars_set = set(vars_)
828
+ term_map: dict[sp.Symbol, tuple[sp.Expr, sp.Expr]] = {}
829
+ for term in h.args:
830
+ coeff, rest = term.as_coeff_Mul()
831
+ coeff = sp.sympify(coeff)
832
+ active = list(rest.free_symbols & vars_set)
833
+ if len(active) != 1:
834
+ return None
835
+ v = active[0]
836
+ if v in term_map:
837
+ return None
838
+ if not rest.is_Pow or rest.base != v:
839
+ return None
840
+ pwr = sp.sympify(rest.exp)
841
+ if pwr.free_symbols or not (pwr.is_positive and pwr.is_real):
842
+ return None
843
+ if coeff.free_symbols or not (coeff.is_positive and coeff.is_real):
844
+ return None
845
+ term_map[v] = (coeff, pwr)
846
+ if set(term_map) != vars_set:
847
+ return None
848
+ return h, k, term_map
849
+
850
+
851
+ def _split_superellipse(expr: sp.Expr, vars_: list[sp.Symbol]) -> tuple[Callable, sp.Expr] | None:
852
+ """Find a unique superellipse-type inner core inside ``expr``.
853
+
854
+ We look for a subexpression of the form ``(Σ a_i x_i**p_i)**k`` and, if it
855
+ occurs uniquely, replace it by a dummy variable to obtain the outer
856
+ univariate function.
857
+ """
858
+ vars_set = set(vars_)
859
+ matches = []
860
+ for sub in sp.preorder_traversal(expr):
861
+ if sub == expr or not (sub.free_symbols & vars_set):
862
+ continue
863
+ if _parse_superellipse_core(sub, vars_) is not None:
864
+ matches.append(sub)
865
+ # Prefer the largest matching subexpression and avoid ambiguous cases.
866
+ uniq = []
867
+ for m in matches:
868
+ if not any(other != m and m in sp.preorder_traversal(other) for other in matches):
869
+ uniq.append(m)
870
+ if len(uniq) != 1:
871
+ return None
872
+ core = uniq[0]
873
+ t = Dummy("t_super")
874
+ outer_expr = expr.xreplace({core: t})
875
+ if outer_expr.free_symbols & vars_set:
876
+ return None
877
+ return sp.Lambda(t, outer_expr), core
878
+
879
+
880
+ def _try_superellipse(
881
+ f_outer: Callable,
882
+ g: sp.Expr,
883
+ vars_: list[sp.Symbol],
884
+ ranges: list[tuple],
885
+ opts: dict,
886
+ ) -> sp.Expr | None:
887
+ """Fast homogeneous layer-cake for orthant superellipse-type integrals."""
888
+ if len(vars_) < 2:
889
+ return None
890
+
891
+ for _, lo, hi in ranges:
892
+ if sp.sympify(lo) != 0 or sp.sympify(hi) != oo:
893
+ return None
894
+
895
+ parsed = _parse_superellipse_core(g, vars_)
896
+ if parsed is None:
897
+ return None
898
+ _, k, term_map = parsed
899
+
900
+ alpha = sp.Integer(0)
901
+ const = sp.Integer(1)
902
+ for v in vars_:
903
+ coeff, pwr = term_map[v]
904
+ alpha += sp.Integer(1) / pwr
905
+ const *= gamma(1 + sp.Integer(1) / pwr) / coeff ** (sp.Integer(1) / pwr)
906
+ const /= gamma(1 + alpha)
907
+
908
+ t = Dummy("y_super")
909
+ density = _fast_simplify(const * alpha / k * t ** (alpha / k - 1))
910
+
911
+ # Fast divergence detection for positive orthant superellipse densities.
912
+ # If the outer function tends to +∞, or to a positive nonzero constant, the
913
+ # weighted tail integral diverges because alpha/k > 0. This avoids SymPy
914
+ # producing opaque lowergamma(..., -oo) style results on obvious cases.
915
+ try:
916
+ outer_t = f_outer(t)
917
+ tail_lim = limit(outer_t, t, oo)
918
+ if tail_lim is oo:
919
+ return oo
920
+ if getattr(tail_lim, "is_positive", False) and tail_lim != 0:
921
+ return oo
922
+ except Exception:
923
+ outer_t = f_outer(t)
924
+
925
+ try:
926
+ result = integrate(outer_t * density, (t, 0, oo), **opts)
927
+ except Exception:
928
+ return None
929
+ return None if result.has(sp.Integral) else _fast_simplify(result)
930
+
931
+
932
+ def _try_separable(
933
+ f_outer: Callable,
934
+ g: sp.Expr,
935
+ vars_: list[sp.Symbol],
936
+ ranges: list[tuple],
937
+ opts: dict,
938
+ ) -> sp.Expr | None:
939
+ """
940
+ Handle g(x) that is a *sum* of single-variable terms:
941
+ g(x₁,…,xₙ) = h₁(x₁) + h₂(x₂) + … + hₙ(xₙ)
942
+
943
+ For a sum, the layer-cake density is the convolution of the individual
944
+ pushforward measures. We compute each marginal measure μᵢ'(y) for hᵢ
945
+ and then convolve them symbolically.
946
+
947
+ Only attempted when every term depends on exactly one variable.
948
+ """
949
+ # ── Additive separability ──────────────────────────────────────────────────
950
+ terms = g.args if g.is_Add else (g,)
951
+
952
+ # Check each term depends on at most one variable from vars_
953
+ split: dict[sp.Symbol, sp.Expr] = {}
954
+ residual = sp.Integer(0)
955
+
956
+ for term in terms:
957
+ active = [v for v in vars_ if v in term.free_symbols]
958
+ if len(active) == 0:
959
+ residual += term
960
+ elif len(active) == 1:
961
+ v = active[0]
962
+ split[v] = split.get(v, sp.Integer(0)) + term
963
+ else:
964
+ return None # term mixes variables → not separable
965
+
966
+ if len(split) < 2:
967
+ return None # only one variable involved; nothing to separate
968
+
969
+ # Bail out if any range has a variable limit depending on another
970
+ # integration variable — S5 assumes independent rectangular ranges.
971
+ vars_set = set(vars_)
972
+ for r in ranges:
973
+ lo_syms = sp.sympify(r[1]).free_symbols & vars_set
974
+ hi_syms = sp.sympify(r[2]).free_symbols & vars_set
975
+ if lo_syms or hi_syms:
976
+ return None
977
+
978
+ # Each variable must appear in exactly one term
979
+ if set(split.keys()) != set(vars_):
980
+ # Some variables are missing from g entirely
981
+ missing_vars = [v for v in vars_ if v not in split]
982
+ if missing_vars:
983
+ volume = sp.Integer(1)
984
+ sub_ranges = []
985
+ for r in ranges:
986
+ if r[0] in missing_vars:
987
+ lo, hi = r[1], r[2]
988
+ if lo in (oo, -oo) or hi in (oo, -oo):
989
+ return None
990
+ volume *= hi - lo
991
+ else:
992
+ sub_ranges.append(r)
993
+ sub_vars = [r[0] for r in sub_ranges]
994
+ sub_result = _try_separable(f_outer, g, sub_vars, sub_ranges, opts)
995
+ if sub_result is None:
996
+ return None
997
+ return volume * sub_result
998
+
999
+ # Build the Lebesgue pushforward density for each hᵢ(xᵢ) on its range.
1000
+ # We compute mu_i(y) as a clean Piecewise defined on [y_lo, y_hi] and then
1001
+ # differentiate. This avoids Heaviside/Min/Max expressions that cause
1002
+ # SymPy to hang when later used inside a convolution integral.
1003
+ densities: list[tuple] = [] # (density_expr, dummy_var, y_lo, y_hi)
1004
+
1005
+ for r in ranges:
1006
+ xi, lo, hi = r
1007
+ hi_xi = split[xi]
1008
+
1009
+ y_lo, y_hi = _g_range_on_interval(hi_xi, xi, lo, hi)
1010
+
1011
+ yy = Dummy("y_sep")
1012
+ try:
1013
+ # Compute raw mu_i via Heaviside integral
1014
+ mu_raw = integrate(_heaviside_to_piecewise(Heaviside(yy - hi_xi)), (xi, lo, hi), **opts)
1015
+ if mu_raw.has(sp.Integral):
1016
+ return None
1017
+ # Compute nu_i as a clean strict-interval Piecewise.
1018
+ # diff(mu_raw) gives Heaviside/Min/Max expressions that cause
1019
+ # SymPy to hang in the convolution step. Instead, we evaluate
1020
+ # the raw derivative at the interior midpoint to obtain the
1021
+ # density value, then wrap it in a strict open-interval Piecewise.
1022
+ # This is exact when nu_i is constant (linear h_i) and gives the
1023
+ # correct average for slowly-varying h_i. Cases where nu_i varies
1024
+ # significantly (non-monotone or transcendental h_i) produce an
1025
+ # unevaluated Integral in mu_raw and are already rejected above.
1026
+ nu_raw = diff(mu_raw, yy)
1027
+ mid = (y_lo + y_hi) / 2
1028
+ nu_at_mid = _fast_simplify(nu_raw.subs(yy, mid))
1029
+ nu_i = Piecewise(
1030
+ (nu_at_mid, (yy > y_lo) & (yy < y_hi)),
1031
+ (sp.Integer(0), True),
1032
+ )
1033
+ except Exception:
1034
+ return None
1035
+
1036
+ densities.append((nu_i, yy, y_lo, y_hi))
1037
+
1038
+ if not densities:
1039
+ return None
1040
+
1041
+ # Convolve all marginal densities iteratively
1042
+ conv_var = Dummy("z_conv")
1043
+ nu_prev, yy_prev, ylo_prev, yhi_prev = densities[0]
1044
+ conv_density = nu_prev.subs(yy_prev, conv_var)
1045
+ conv_lo, conv_hi = ylo_prev, yhi_prev
1046
+
1047
+ for nu_i, yy_i, ylo_i, yhi_i in densities[1:]:
1048
+ t = Dummy("t_conv")
1049
+ z = Dummy("z_new")
1050
+ integrand_conv = _heaviside_to_piecewise(
1051
+ conv_density.subs(conv_var, t) * nu_i.subs(yy_i, z - t)
1052
+ )
1053
+ t_lo = sp.Max(conv_lo, z - yhi_i)
1054
+ t_hi = sp.Min(conv_hi, z - ylo_i)
1055
+ try:
1056
+ new_density = integrate(integrand_conv, (t, t_lo, t_hi), **opts)
1057
+ if new_density.has(sp.Integral):
1058
+ return None
1059
+ new_density = _fast_simplify(new_density)
1060
+ except Exception:
1061
+ return None
1062
+ conv_density = new_density.subs(z, conv_var)
1063
+ conv_lo = conv_lo + ylo_i
1064
+ conv_hi = conv_hi + yhi_i
1065
+
1066
+ yf = Dummy("y_final")
1067
+ try:
1068
+ result = integrate(
1069
+ f_outer(yf + residual) * conv_density.subs(conv_var, yf),
1070
+ (yf, conv_lo, conv_hi),
1071
+ **opts,
1072
+ )
1073
+ if result.has(sp.Integral):
1074
+ return None
1075
+ return _fast_simplify(result)
1076
+ except Exception:
1077
+ return None
1078
+
1079
+
1080
+ def _try_product_separable(
1081
+ f_expr: sp.Expr, vars_: list[sp.Symbol], ranges: list[tuple], opts: dict
1082
+ ) -> sp.Expr | None:
1083
+ """
1084
+ Handle integrands that factorise as a product of single-variable functions:
1085
+
1086
+ f(x₁, …, xₙ) = c · f₁(x₁) · f₂(x₂) · … · fₙ(xₙ)
1087
+
1088
+ By Fubini, the integral factors into independent 1-D integrals:
1089
+
1090
+ ∫_Ω f dxⁿ = c · ∏ᵢ ∫_{aᵢ}^{bᵢ} fᵢ(xᵢ) dxᵢ
1091
+
1092
+ This handles sin(x)·exp(-y), x²·cos(y), exp(-x)·exp(-y), and similar
1093
+ product integrands that appear very frequently in practice.
1094
+
1095
+ Only fires when every factor depends on at most one integration variable
1096
+ and limits are independent (non-variable).
1097
+ """
1098
+ if not f_expr.is_Mul:
1099
+ return None
1100
+
1101
+ vars_set = set(vars_)
1102
+
1103
+ # Reject if any range has variable limits
1104
+ for r in ranges:
1105
+ if sp.sympify(r[1]).free_symbols & vars_set:
1106
+ return None
1107
+ if sp.sympify(r[2]).free_symbols & vars_set:
1108
+ return None
1109
+
1110
+ # Split factors: constant part and per-variable parts
1111
+ const_part = sp.Integer(1)
1112
+ var_factors: dict[sp.Symbol, sp.Expr] = {}
1113
+
1114
+ for factor in f_expr.args:
1115
+ active = [v for v in vars_ if v in factor.free_symbols]
1116
+ if len(active) == 0:
1117
+ const_part = const_part * factor
1118
+ elif len(active) == 1:
1119
+ v = active[0]
1120
+ var_factors[v] = var_factors.get(v, sp.Integer(1)) * factor
1121
+ else:
1122
+ return None # factor mixes variables
1123
+
1124
+ if len(var_factors) < 2:
1125
+ return None # trivial; S6/S7 handle single-variable cases
1126
+
1127
+ # Variables with no factor: contribute the length of their interval
1128
+ result = const_part
1129
+ for r in ranges:
1130
+ v, lo, hi = r
1131
+ if v in var_factors:
1132
+ integral_1d = integrate(var_factors[v], (v, lo, hi), **opts)
1133
+ if integral_1d.has(sp.Integral):
1134
+ return None
1135
+ result = result * integral_1d
1136
+ else:
1137
+ # Variable absent from integrand — contributes (hi - lo)
1138
+ if lo in (oo, -oo) or hi in (oo, -oo):
1139
+ return None
1140
+ result = result * (hi - lo)
1141
+
1142
+ return _fast_simplify(result)
1143
+
1144
+
1145
+ def _try_monotone_subst(
1146
+ f_outer: Callable,
1147
+ g: sp.Expr,
1148
+ vars_: list[sp.Symbol],
1149
+ ranges: list[tuple],
1150
+ opts: dict,
1151
+ ) -> sp.Expr | None:
1152
+ """
1153
+ For a single-variable g(x), if g is monotone on [lo, hi]:
1154
+
1155
+ ∫_lo^hi f(g(x)) dx = ∫_{g(lo)}^{g(hi)} f(y) / |g'(g⁻¹(y))| dy
1156
+
1157
+ Uses the co-area formula: μ'(y) = |dx/dy| = 1/|g'(x)|.
1158
+
1159
+ For multivariate f(g(x)) where g depends only on one variable,
1160
+ the other dimensions are integrated out as a volume factor first.
1161
+ """
1162
+ # Fast guard: g must depend on exactly one variable
1163
+ active = [v for v in vars_ if v in g.free_symbols]
1164
+ if len(active) != 1:
1165
+ return None
1166
+ xi = active[0]
1167
+ r_xi = next((r for r in ranges if r[0] == xi), None)
1168
+ if r_xi is None:
1169
+ return None
1170
+ lo, hi = r_xi[1], r_xi[2]
1171
+ # Bounds for the active variable must not depend on the other variables.
1172
+ if (sp.sympify(lo).free_symbols | sp.sympify(hi).free_symbols) & (set(vars_) - {xi}):
1173
+ return None
1174
+
1175
+ # Check monotonicity: no interior critical points
1176
+ cpts = _real_critical_points(g, xi, lo, hi)
1177
+ if cpts:
1178
+ return None
1179
+
1180
+ # Test sign of derivative at a sample point
1181
+ dg = diff(g, xi)
1182
+ try:
1183
+ mid = (lo + hi) / 2 if lo not in (-oo, oo) and hi not in (-oo, oo) else sp.Integer(0)
1184
+ dg_sign = sp.ask(sp.Q.positive(dg.subs(xi, mid)))
1185
+ except Exception:
1186
+ dg_sign = None
1187
+
1188
+ # Compute g at endpoints (use limits for infinite endpoints)
1189
+ g_lo = limit(g, xi, lo, "+") if lo == -oo else g.subs(xi, lo)
1190
+ g_hi = limit(g, xi, hi, "-") if hi == oo else g.subs(xi, hi)
1191
+ g_lo, g_hi = simplify(g_lo), simplify(g_hi)
1192
+
1193
+ if dg_sign is False: # decreasing → flip
1194
+ g_lo, g_hi = g_hi, g_lo
1195
+
1196
+ # Invert g analytically: solve g(xi) = y for xi
1197
+ y = Dummy("y_mono")
1198
+ try:
1199
+ inv_solutions = solve(g - y, xi)
1200
+ except Exception:
1201
+ return None
1202
+ inv_solutions = [s for s in inv_solutions if not s.has(sp.I)]
1203
+ if not inv_solutions:
1204
+ return None
1205
+
1206
+ if len(inv_solutions) > 1:
1207
+ # Select branch consistent with [lo, hi]
1208
+ valid = []
1209
+ for s in inv_solutions:
1210
+ try:
1211
+ s_mid = s.subs(y, (g_lo + g_hi) / 2)
1212
+ ok = sp.ask(
1213
+ sp.Q.positive(s_mid - lo + sp.Rational(1, 1000))
1214
+ & sp.Q.positive(hi - s_mid + sp.Rational(1, 1000))
1215
+ )
1216
+ if ok is not False:
1217
+ valid.append(s)
1218
+ except Exception:
1219
+ valid.append(s)
1220
+ if len(valid) != 1:
1221
+ return None
1222
+ inv_solutions = valid
1223
+
1224
+ xi_of_y = simplify(inv_solutions[0])
1225
+ jacobian = Abs(diff(xi_of_y, y)) # |dx/dy| = 1/|g'(x)|
1226
+
1227
+ # Integrate out unused dimensions
1228
+ other_ranges = [r for r in ranges if r[0] != xi]
1229
+ volume = sp.Integer(1)
1230
+ for r in other_ranges:
1231
+ v, vlo, vhi = r
1232
+ vlo_s, vhi_s = sp.sympify(vlo), sp.sympify(vhi)
1233
+ if vlo_s in (-oo, oo) or vhi_s in (-oo, oo):
1234
+ return None
1235
+ if (vlo_s.free_symbols | vhi_s.free_symbols) & {xi}:
1236
+ return None
1237
+ volume *= vhi_s - vlo_s
1238
+
1239
+ integrand_1d = _fast_simplify(f_outer(y) * jacobian * volume)
1240
+ try:
1241
+ result = integrate(integrand_1d, (y, g_lo, g_hi), **opts)
1242
+ return None if result.has(sp.Integral) else simplify(result)
1243
+ except Exception:
1244
+ return None
1245
+
1246
+
1247
+ def _try_piecewise_monotone(
1248
+ f_outer: Callable,
1249
+ g: sp.Expr,
1250
+ vars_: list[sp.Symbol],
1251
+ ranges: list[tuple],
1252
+ opts: dict,
1253
+ ) -> sp.Expr | None:
1254
+ """
1255
+ Split the domain at critical points of g(x), apply the monotone
1256
+ substitution on each piece, and sum.
1257
+
1258
+ The co-area density is:
1259
+ μ'(y) = Σ_{branches k : g(xₖ)=y} 1 / |g'(xₖ)|
1260
+
1261
+ Only handles the single-active-variable case.
1262
+ """
1263
+ active = [v for v in vars_ if v in g.free_symbols]
1264
+ if len(active) != 1:
1265
+ return None
1266
+ xi = active[0]
1267
+ r_xi = next(r for r in ranges if r[0] == xi)
1268
+ lo, hi = r_xi[1], r_xi[2]
1269
+
1270
+ cpts = _real_critical_points(g, xi, lo, hi)
1271
+ if not cpts:
1272
+ return None # no critical points → Strategy 6 handles it
1273
+
1274
+ endpoints = [lo] + sorted(cpts, key=lambda e: float(e) if e.is_number else 0) + [hi]
1275
+ sub_intervals = list(zip(endpoints[:-1], endpoints[1:], strict=False))
1276
+
1277
+ other_ranges = [r for r in ranges if r[0] != xi]
1278
+ volume = sp.Integer(1)
1279
+ for r in other_ranges:
1280
+ v, vlo, vhi = r
1281
+ if vlo in (-oo, oo) or vhi in (-oo, oo):
1282
+ return None
1283
+ volume *= vhi - vlo
1284
+
1285
+ total = sp.Integer(0)
1286
+ for a, b in sub_intervals:
1287
+ sub_r = [(xi, a, b)] + other_ranges
1288
+ sub_vars = [xi] + [r[0] for r in other_ranges]
1289
+ piece = _try_monotone_subst(f_outer, g, sub_vars, sub_r, opts)
1290
+ if piece is None:
1291
+ try:
1292
+ piece = integrate(f_outer(g) * volume, (xi, a, b), **opts)
1293
+ if piece.has(sp.Integral):
1294
+ return None
1295
+ except Exception:
1296
+ return None
1297
+ total = total + piece
1298
+
1299
+ result = simplify(total)
1300
+ return None if result.has(sp.Integral) else result
1301
+
1302
+
1303
+ def _bounds_of_g(
1304
+ g: sp.Expr, vars_: list[sp.Symbol], ranges: list[tuple]
1305
+ ) -> tuple[sp.Expr, sp.Expr]:
1306
+ """
1307
+ Estimate [g_min, g_max] by evaluating g at all corners of the box and
1308
+ at critical points along each axis.
1309
+ """
1310
+ corners = [{}]
1311
+ for r in ranges:
1312
+ v, lo, hi = r
1313
+ new_corners = []
1314
+ for c in corners:
1315
+ for ep in [lo, hi]:
1316
+ if ep not in (oo, -oo):
1317
+ new_corners.append({**c, v: ep})
1318
+ if new_corners:
1319
+ corners = new_corners
1320
+
1321
+ vals = []
1322
+ for corner in corners:
1323
+ try:
1324
+ val = simplify(g.subs(list(corner.items())))
1325
+ if val.is_real or val.is_number:
1326
+ vals.append(val)
1327
+ except Exception:
1328
+ pass
1329
+
1330
+ for r in ranges:
1331
+ v, lo, hi = r
1332
+ cpts = _real_critical_points(g, v, lo, hi)
1333
+ for cp in cpts:
1334
+ try:
1335
+ val = g.subs(v, cp)
1336
+ vals.append(simplify(val))
1337
+ except Exception:
1338
+ pass
1339
+
1340
+ if not vals:
1341
+ return -oo, oo
1342
+ return simplify(sp.Min(*vals)), simplify(sp.Max(*vals))
1343
+
1344
+
1345
+ def _try_nonpoly(
1346
+ f_outer: Callable,
1347
+ g: sp.Expr,
1348
+ vars_: list[sp.Symbol],
1349
+ ranges: list[tuple],
1350
+ opts: dict,
1351
+ ) -> sp.Expr | None:
1352
+ """
1353
+ Applies the layer-cake formula for arbitrary g:
1354
+
1355
+ ∫_Ω f(g(x)) dx = ∫_{y_min}^{y_max} f(y) · μ'(y) dy
1356
+
1357
+ where μ(y) = ∫_Ω Θ(y - g(x)) dx is computed symbolically by SymPy.
1358
+
1359
+ Unlike Strategy 4, here g may be transcendental; SymPy must be able to
1360
+ integrate Heaviside(y - g(x)) in closed form.
1361
+ """
1362
+ if not _should_try_layercake(f_outer, g, vars_, ranges):
1363
+ return None
1364
+
1365
+ yy = Dummy("y_gen")
1366
+
1367
+ mu_y = _heaviside_to_piecewise(Heaviside(yy - g))
1368
+ try:
1369
+ for r in ranges:
1370
+ mu_y = integrate(mu_y, (r[0], r[1], r[2]), **opts)
1371
+ if isinstance(mu_y, sp.Integral) or mu_y.has(sp.Integral):
1372
+ return None
1373
+ mu_y = simplify(mu_y)
1374
+ except Exception:
1375
+ return None
1376
+
1377
+ density = simplify(diff(mu_y, yy))
1378
+
1379
+ y_lo, y_hi = _bounds_of_g(g, vars_, ranges)
1380
+
1381
+ try:
1382
+ result = integrate(f_outer(yy) * density, (yy, y_lo, y_hi), **opts)
1383
+ if result.has(sp.Integral):
1384
+ return None
1385
+ return simplify(result)
1386
+ except Exception:
1387
+ return None
1388
+
1389
+
1390
+ def _iterated_integrate(expr: sp.Expr, ranges: list[tuple], opts: dict) -> sp.Expr:
1391
+ """
1392
+ Iterated SymPy integration in forward order (first range integrated first).
1393
+
1394
+ Forward order is required so that variable limits are respected correctly.
1395
+ For example, with ranges [(y, 0, 1-x), (x, 0, 1)], y must be integrated
1396
+ first because its upper limit depends on x. Reversing the order would
1397
+ integrate x first, leaving x free when y's limits are applied.
1398
+
1399
+ Heaviside sub-expressions are rewritten as Piecewise before integration
1400
+ because SymPy's integrate() falls back to Meijer G functions for
1401
+ Heaviside(linear(x, y)) with two free variables, producing incorrect results.
1402
+ """
1403
+ result = _heaviside_to_piecewise(expr)
1404
+ for r in ranges:
1405
+ v, lo, hi = r
1406
+ std = _try_standard_1d(result, v, lo, hi, opts)
1407
+ result = std if std is not None else integrate(result, (v, lo, hi), **opts)
1408
+ return result
1409
+
1410
+
1411
+ def multiple_integrate(
1412
+ f: sp.Expr,
1413
+ *ranges,
1414
+ assumptions=None,
1415
+ generate_conditions: bool = False,
1416
+ principal_value: bool = False,
1417
+ ) -> sp.Expr:
1418
+ """
1419
+ Symbolically evaluate a multiple integral ∫_Ω f(x₁,…,xₙ) dx.
1420
+
1421
+ The integrand *f* may have the structure f(g(x₁,…,xₙ)) for a wide class
1422
+ of inner functions g — polynomials, trigonometric, exponential, logarithmic,
1423
+ rational, algebraic, and separable combinations thereof.
1424
+
1425
+ Parameters
1426
+ ----------
1427
+ f : sympy.Expr
1428
+ The integrand as a SymPy expression in the integration variables.
1429
+ *ranges : tuple of (symbol, lower, upper)
1430
+ One tuple per integration variable, e.g. ``(x, 0, 1), (y, 0, pi)``.
1431
+ assumptions : dict, optional
1432
+ Passed to SymPy's ``integrate`` (e.g. ``{'positive': True}``).
1433
+ generate_conditions : bool
1434
+ Ask SymPy to emit ``ConditionalExpression`` results when the result
1435
+ depends on parameter signs. Default False.
1436
+ principal_value : bool
1437
+ Use the Cauchy principal value for improper integrals.
1438
+
1439
+ Returns
1440
+ -------
1441
+ sympy.Expr
1442
+ Closed-form result, or an unevaluated ``sympy.Integral`` if no
1443
+ strategy succeeds.
1444
+
1445
+ Examples
1446
+ --------
1447
+ >>> from sympy import *
1448
+ >>> x, y = symbols('x y')
1449
+ >>> multiple_integrate(exp(-(x**2 + y**2)), (x, -oo, oo), (y, -oo, oo))
1450
+ pi
1451
+ >>> multiple_integrate(sin(x + y), (x, 0, pi), (y, 0, pi))
1452
+ 0
1453
+ >>> multiple_integrate(cos(x)*exp(-y), (x, 0, pi/2), (y, 0, oo))
1454
+ 1
1455
+ """
1456
+ opts: dict = {}
1457
+ if assumptions:
1458
+ opts["assumptions"] = assumptions
1459
+
1460
+ # Normalise ranges or accept a direct Region object.
1461
+ parsed_ranges: list[tuple] = []
1462
+ direct_region = None
1463
+ norm_ranges = _normalize_seq(ranges)
1464
+ if len(norm_ranges) == 1 and isinstance(norm_ranges[0], Region):
1465
+ direct_region = norm_ranges[0]
1466
+ parsed_ranges = list(direct_region.ranges)
1467
+ else:
1468
+ for r in norm_ranges:
1469
+ if len(r) == 3:
1470
+ parsed_ranges.append(tuple(r))
1471
+ else:
1472
+ raise ValueError(f"Each range must be (variable, lower, upper); got {r}")
1473
+
1474
+ f_expr: sp.Expr = sp.sympify(f)
1475
+ region = direct_region if direct_region is not None else region_from_ranges(parsed_ranges)
1476
+
1477
+ def _integrate_piecewise(expr_pw: sp.Piecewise):
1478
+ total = sp.Integer(0)
1479
+ remaining = sp.true
1480
+ for branch_expr, branch_cond in expr_pw.args:
1481
+ eff_cond = (
1482
+ remaining
1483
+ if branch_cond in (True, sp.true)
1484
+ else sp.simplify(sp.And(remaining, branch_cond))
1485
+ )
1486
+ sub_region = restrict_region(region, eff_cond)
1487
+ if sub_region is None:
1488
+ try:
1489
+ direct = sp.integrate(expr_pw, *parsed_ranges, **opts)
1490
+ if direct is not None and not isinstance(direct, sp.Integral):
1491
+ return _fast_simplify(direct)
1492
+ except Exception:
1493
+ return sp.Integral(expr_pw, *parsed_ranges)
1494
+ return sp.Integral(expr_pw, *parsed_ranges)
1495
+ branch_res = multiple_integrate(
1496
+ sp.sympify(branch_expr),
1497
+ sub_region,
1498
+ assumptions=assumptions,
1499
+ generate_conditions=generate_conditions,
1500
+ principal_value=principal_value,
1501
+ )
1502
+ total += branch_res
1503
+ if branch_cond not in (True, sp.true):
1504
+ remaining = sp.simplify(sp.And(remaining, sp.Not(branch_cond)))
1505
+ return _fast_simplify(total)
1506
+
1507
+ vars_ = (
1508
+ list(region.variables) if hasattr(region, "variables") else [r[0] for r in parsed_ranges]
1509
+ )
1510
+ vars_set = _vars_set(vars_)
1511
+
1512
+ # ── First-class indicator and Piecewise handling ────────────────────────
1513
+ # Treat indicator-like Piecewise factors as region restrictions and
1514
+ # integrate general Piecewise expressions branch by branch on supported
1515
+ # regions.
1516
+ if isinstance(f_expr, sp.Piecewise):
1517
+ return _integrate_piecewise(f_expr)
1518
+
1519
+ if isinstance(f_expr, sp.Mul):
1520
+ indicator_conds = []
1521
+ non_indicator = []
1522
+ for arg in f_expr.args:
1523
+ ind_cond = indicator_condition(arg)
1524
+ if ind_cond is not None:
1525
+ indicator_conds.append(ind_cond)
1526
+ else:
1527
+ non_indicator.append(arg)
1528
+ if indicator_conds:
1529
+ restricted = restrict_region(region, sp.And(*indicator_conds))
1530
+ if restricted is not None:
1531
+ new_expr = _fast_simplify(
1532
+ sp.Mul(*non_indicator) if non_indicator else sp.Integer(1)
1533
+ )
1534
+ return multiple_integrate(
1535
+ new_expr,
1536
+ restricted,
1537
+ assumptions=assumptions,
1538
+ generate_conditions=generate_conditions,
1539
+ principal_value=principal_value,
1540
+ )
1541
+
1542
+ # Small normalized-subproblem cache shared across recursive calls.
1543
+ if not hasattr(multiple_integrate, "_cache"):
1544
+ multiple_integrate._cache = {}
1545
+
1546
+ def _norm_ranges(rs):
1547
+ return tuple((v, _clean_expr(lo), _clean_expr(hi)) for v, lo, hi in rs)
1548
+
1549
+ cache_key = (
1550
+ _fast_simplify(f_expr),
1551
+ region.normalized_ranges(),
1552
+ bool(generate_conditions),
1553
+ bool(principal_value),
1554
+ repr(assumptions),
1555
+ )
1556
+ if cache_key in multiple_integrate._cache:
1557
+ return multiple_integrate._cache[cache_key]
1558
+
1559
+ # ── Constant-integrand short-circuit ────────────────────────────────────
1560
+ # If f does not depend on any integration variable the result is
1561
+ # f * ∏(hi - lo). Handles f=5, f=pi, f=a (symbolic parameter), etc.
1562
+ def _store(res):
1563
+ multiple_integrate._cache[cache_key] = res
1564
+ return res
1565
+
1566
+ if _is_constant_wrt(f_expr, vars_set):
1567
+ return _store(_const_result(f_expr, region, parsed_ranges))
1568
+
1569
+ # ── Region-aware shortcuts ─────────────────────────
1570
+ region_res = _region_shortcut(region, f_expr, assumptions=assumptions)
1571
+ if region_res is not None:
1572
+ return _store(region_res)
1573
+
1574
+ region_res = _try_region_transform(
1575
+ region,
1576
+ f_expr,
1577
+ assumptions=assumptions,
1578
+ generate_conditions=generate_conditions,
1579
+ principal_value=principal_value,
1580
+ )
1581
+ if region_res is not None:
1582
+ return _store(region_res)
1583
+
1584
+ gauss_res = _try_gauss_linear_map(f_expr, parsed_ranges, assumptions=assumptions)
1585
+ if gauss_res is not None:
1586
+ return _store(gauss_res)
1587
+
1588
+ reversed_res = _try_graph_reversal(
1589
+ f_expr,
1590
+ region,
1591
+ assumptions,
1592
+ generate_conditions,
1593
+ principal_value,
1594
+ )
1595
+ if reversed_res is not None:
1596
+ return _store(reversed_res)
1597
+
1598
+ # ── Aggressive sum splitting ───────────────────────────────────────────
1599
+ parts = _split_additive_terms(f_expr)
1600
+ if parts is not None:
1601
+ finite_bounds = True
1602
+ for _, lo, hi in parsed_ranges:
1603
+ lo_s, hi_s = sp.sympify(lo), sp.sympify(hi)
1604
+ if lo_s in (-oo, oo) or hi_s in (-oo, oo):
1605
+ finite_bounds = False
1606
+ break
1607
+ if finite_bounds:
1608
+ total = sp.Integer(0)
1609
+ for term in parts:
1610
+ total += multiple_integrate(
1611
+ term,
1612
+ *parsed_ranges,
1613
+ assumptions=assumptions,
1614
+ generate_conditions=generate_conditions,
1615
+ principal_value=principal_value,
1616
+ )
1617
+ return _store(_fast_simplify(total))
1618
+
1619
+ # ── Trig/power normalisation ────────────────────────────────────────────
1620
+ # Simplify redundant structure before any other work. These are all cheap
1621
+ # single-pass rewrites that can collapse the integrand to a constant or
1622
+ # simpler form (e.g. sin²+cos²→1, exp(log(x))→x, (x+1)²-x²-2x→1).
1623
+ try:
1624
+ f_norm = sp.trigsimp(sp.powsimp(f_expr))
1625
+ if f_norm != f_expr:
1626
+ f_expr = f_norm
1627
+ # Re-run constant check after normalisation
1628
+ if _is_constant_wrt(f_expr, vars_set):
1629
+ return _store(_const_result(f_expr, region, parsed_ranges))
1630
+ except Exception:
1631
+ pass
1632
+
1633
+ # ── Short-circuit for step-function integrands ────────────────────────────
1634
+ # When the integrand is (or contains) a Heaviside, Piecewise, or sign at
1635
+ # the top level, the layer-cake strategies produce a nested
1636
+ # Heaviside(y - Heaviside(...)) that causes SymPy to hang. The iterated
1637
+ # fallback with Piecewise rewriting is both correct and fast for these.
1638
+ _step_funcs = (Heaviside, Piecewise, sign)
1639
+ if isinstance(f_expr, _step_funcs) or (
1640
+ f_expr.is_Mul and any(isinstance(a, _step_funcs) for a in f_expr.args)
1641
+ ):
1642
+ return _store(_iterated_integrate(f_expr, parsed_ranges, opts))
1643
+
1644
+ # ── Parity short-circuits ─────────────────────────────────────────────────
1645
+ # For symmetric ranges [-a, a]:
1646
+ # • Odd integrand (f(-x) = -f(x)) → integral is 0
1647
+ # • Even integrand (f(-x) = f(x)) → replace range with [0, a], double
1648
+ # Applied variable-by-variable; the first odd variable found exits early.
1649
+ new_ranges = list(parsed_ranges)
1650
+ scale = sp.Integer(1)
1651
+ for i, r in enumerate(new_ranges):
1652
+ v, lo, hi = r
1653
+ sym_range = region.symmetric_range(v)
1654
+ if sym_range is None:
1655
+ continue
1656
+ _, hi_s = sym_range
1657
+ try:
1658
+ reflected = f_expr.subs(v, -v)
1659
+ if sp.simplify(reflected + f_expr) == 0: # odd → zero
1660
+ return sp.Integer(0)
1661
+ if sp.simplify(reflected - f_expr) == 0: # even → halve range
1662
+ new_ranges[i] = (v, sp.Integer(0), hi_s)
1663
+ scale = scale * 2
1664
+ except Exception:
1665
+ pass
1666
+ if scale != 1:
1667
+ parsed_ranges = new_ranges
1668
+ region = region_from_ranges(parsed_ranges)
1669
+
1670
+ # ── Helpers to apply even-halving scale to any returned result ────────────
1671
+ def _scaled(r):
1672
+ return _fast_simplify(scale * r) if scale != 1 else r
1673
+
1674
+ # ── Product-separable short-circuit ──────────────────────────────────────
1675
+ res = _try_product_separable(f_expr, vars_, parsed_ranges, opts)
1676
+ if res is not None:
1677
+ return _store(_scaled(res))
1678
+
1679
+ # ── Disjoint variable support ───────────────────────────────────────────
1680
+ # If some integration variables do not appear in f_expr, factor out their
1681
+ # volume contribution and recurse on the remaining variables.
1682
+ # E.g. ∫∫∫ (sin(x)+cos(z)) dx dy dz = (b_y-a_y) * ∫∫ (sin(x)+cos(z)) dx dz
1683
+ active_vars = f_expr.free_symbols & vars_set
1684
+ if active_vars != set(vars_) and active_vars:
1685
+ inactive_ranges = [r for r in parsed_ranges if r[0] not in active_vars]
1686
+ active_ranges = [r for r in parsed_ranges if r[0] in active_vars]
1687
+ # Safe only if inactive dimensions are a true product factor: their own
1688
+ # bounds are finite and independent of active vars, and active bounds do
1689
+ # not depend on inactive vars.
1690
+ vol = sp.Integer(1)
1691
+ inactive_set = {r[0] for r in inactive_ranges}
1692
+ for r in inactive_ranges:
1693
+ lo_s, hi_s = sp.sympify(r[1]), sp.sympify(r[2])
1694
+ if (
1695
+ lo_s in (oo, -oo)
1696
+ or hi_s in (oo, -oo)
1697
+ or (lo_s.free_symbols | hi_s.free_symbols) & active_vars
1698
+ ):
1699
+ vol = None
1700
+ break
1701
+ vol = vol * (hi_s - lo_s)
1702
+ if vol is not None:
1703
+ for _, lo, hi in active_ranges:
1704
+ lo_s, hi_s = sp.sympify(lo), sp.sympify(hi)
1705
+ if (lo_s.free_symbols | hi_s.free_symbols) & inactive_set:
1706
+ vol = None
1707
+ break
1708
+ if vol is not None:
1709
+ inner = multiple_integrate(
1710
+ f_expr,
1711
+ *active_ranges,
1712
+ assumptions=assumptions,
1713
+ generate_conditions=generate_conditions,
1714
+ principal_value=principal_value,
1715
+ )
1716
+ return _store(_fast_simplify(_scaled(vol * inner)))
1717
+
1718
+ # ── 1-D short-circuit ────────────────────────────────────────────────────
1719
+ # For single-variable integrals SymPy's own integrate() is fastest.
1720
+ if len(parsed_ranges) == 1:
1721
+ return _store(_scaled(_iterated_integrate(f_expr, parsed_ranges, opts)))
1722
+
1723
+ # ── Sum of separable terms ──────────────────────────────────────────────
1724
+ # If f_expr is a sum where every term is a product of single-variable
1725
+ # functions, integrate term by term and sum. This handles integrands like
1726
+ # sin(x)*cos(y) + exp(-x)*y^2 that are not of the f(g) form but whose
1727
+ # terms are each product-separable.
1728
+ if f_expr.is_Add:
1729
+ # Only attempt if every term is product-separable on constant-limit ranges
1730
+ vars_set = set(vars_)
1731
+ const_ranges = [
1732
+ r
1733
+ for r in parsed_ranges
1734
+ if not sp.sympify(r[1]).free_symbols & vars_set
1735
+ and not sp.sympify(r[2]).free_symbols & vars_set
1736
+ ]
1737
+ if len(const_ranges) == len(parsed_ranges): # all limits constant
1738
+ term_results = []
1739
+ for term in f_expr.args:
1740
+ tr = _try_product_separable(term, vars_, parsed_ranges, opts)
1741
+ if tr is None:
1742
+ # try recursive multiple_integrate for this term
1743
+ tr = multiple_integrate(
1744
+ term,
1745
+ *parsed_ranges,
1746
+ assumptions=assumptions,
1747
+ generate_conditions=generate_conditions,
1748
+ principal_value=principal_value,
1749
+ )
1750
+ term_results.append(tr)
1751
+ total = _fast_simplify(sp.Add(*term_results))
1752
+ if not total.has(sp.Integral):
1753
+ return _store(_scaled(total))
1754
+
1755
+ # ── Consolidate exp products: exp(-x)*exp(-y) → exp(-(x+y)) ─────────────
1756
+ # powsimp merges exponential products so S1/S2 can recognise them;
1757
+ # it is cheap (single-pass) and never changes the value.
1758
+ f_simplified = sp.powsimp(f_expr, force=True)
1759
+ if f_simplified != f_expr and not f_simplified.free_symbols - vars_set:
1760
+ f_expr = f_simplified
1761
+
1762
+ # ── Pull out constant factors ────────────────────────────────────────────
1763
+ # If f_expr = c * h(vars_) with c free of all vars_, factor c out, compute
1764
+ # ∫ h dxⁿ, and multiply back. This ensures downstream strategies always
1765
+ # see a "pure" integrand without stray scalar prefactors.
1766
+ if f_expr.is_Mul:
1767
+ c_factors = [a for a in f_expr.args if not sp.sympify(a).free_symbols & vars_set]
1768
+ if c_factors:
1769
+ c_out = sp.Mul(*c_factors)
1770
+ h_expr = f_expr / c_out
1771
+ inner = multiple_integrate(
1772
+ h_expr,
1773
+ *parsed_ranges,
1774
+ assumptions=assumptions,
1775
+ generate_conditions=generate_conditions,
1776
+ principal_value=principal_value,
1777
+ )
1778
+ return _store(_fast_simplify(_scaled(c_out * inner)))
1779
+
1780
+ # ── Direct superellipse shortcut ─────────────────────────────────────────
1781
+ # Some hard reference cases have an outer wrapper like 1/(1+g) or exp(-g)
1782
+ # around a homogeneous power-sum g. Recover that structure directly from
1783
+ # the full integrand so we can avoid extremely slow symbolic Heaviside
1784
+ # integration in the generic layer-cake path.
1785
+ super_decomp = _split_superellipse(f_expr, vars_)
1786
+ if super_decomp is not None:
1787
+ super_outer, super_g = super_decomp
1788
+ super_res = _try_superellipse(super_outer, super_g, vars_, parsed_ranges, opts)
1789
+ if super_res is not None:
1790
+ return _store(_scaled(super_res))
1791
+
1792
+ # ── Decompose integrand into f_outer ∘ g ──────────────────────────────────
1793
+ @functools.lru_cache(maxsize=256)
1794
+ def _decompose_cached(expr, vars_tuple):
1795
+ return _decompose_shallow(expr, list(vars_tuple))
1796
+
1797
+ decomp = _decompose_cached(f_expr, tuple(vars_))
1798
+
1799
+ if decomp is None:
1800
+ return _store(_scaled(_iterated_integrate(f_expr, parsed_ranges, opts)))
1801
+
1802
+ def _run_strategies(cur_decomp):
1803
+ f_outer = cur_decomp.f_outer
1804
+ g = cur_decomp.g_inner
1805
+ is_poly = cur_decomp.is_polynomial
1806
+
1807
+ if is_poly:
1808
+ for strategy in (
1809
+ _try_linear,
1810
+ _try_quadratic_infinite,
1811
+ _try_even_half_quad,
1812
+ _try_superellipse,
1813
+ _try_general_polynomial,
1814
+ ):
1815
+ res = strategy(f_outer, g, vars_, parsed_ranges, opts)
1816
+ if res is not None:
1817
+ return res
1818
+
1819
+ for strategy in (
1820
+ _try_separable,
1821
+ _try_monotone_subst,
1822
+ _try_piecewise_monotone,
1823
+ _try_nonpoly,
1824
+ ):
1825
+ res = strategy(f_outer, g, vars_, parsed_ranges, opts)
1826
+ if res is not None:
1827
+ return res
1828
+ return None
1829
+
1830
+ res = _run_strategies(decomp)
1831
+ if res is not None:
1832
+ return _store(_scaled(res))
1833
+
1834
+ deep_decomp = _decompose(f_expr, vars_)
1835
+ if deep_decomp is not None and (
1836
+ deep_decomp.g_inner != decomp.g_inner
1837
+ or deep_decomp.f_outer(sp.Symbol("_u")) != decomp.f_outer(sp.Symbol("_u"))
1838
+ ):
1839
+ res = _run_strategies(deep_decomp)
1840
+ if res is not None:
1841
+ return _store(_scaled(res))
1842
+
1843
+ # ── Fallback ───────────────────────────────────────────────────────────────
1844
+ result = _iterated_integrate(f_expr, parsed_ranges, opts)
1845
+ return _store(_fast_simplify(scale * result) if scale != 1 else result)
1846
+
1847
+
1848
+ def _simplex_poly(region: Region, expr: sp.Expr) -> sp.Expr | None:
1849
+ """Exact polynomial moments on simplex-like regions."""
1850
+ if isinstance(region, (SimplexRegion, AffineSimplexRegion)):
1851
+ return region.polynomial_moment(expr)
1852
+ return None
1853
+
1854
+
1855
+ def _standard_ball_ranges(vars_: tuple[sp.Symbol, ...], radius: sp.Expr) -> tuple[tuple, ...]:
1856
+ """SymPy-style inner-first iterated ranges for a standard ball."""
1857
+ outer_first = []
1858
+ sumsq = sp.Integer(0)
1859
+ for i, v in enumerate(vars_):
1860
+ if i == 0:
1861
+ outer_first.append((v, -radius, radius))
1862
+ else:
1863
+ rad = sp.sqrt(radius**2 - sumsq)
1864
+ outer_first.append((v, -rad, rad))
1865
+ sumsq += v**2
1866
+ return tuple(reversed(outer_first))
1867
+
1868
+
1869
+ def _polar_disk_transform(region: DiskRegion) -> CoordinateTransform:
1870
+ x, y = region.variables
1871
+ r = sp.Symbol("_r", nonnegative=True, real=True)
1872
+ theta = sp.Symbol("_theta", real=True)
1873
+ return CoordinateTransform(
1874
+ source_vars=(x, y),
1875
+ target_vars=(theta, r),
1876
+ forward_map=(r * sp.cos(theta), r * sp.sin(theta)),
1877
+ jacobian=r,
1878
+ target_ranges=((theta, 0, 2 * sp.pi), (r, 0, region.radius)),
1879
+ )
1880
+
1881
+
1882
+ def _ball_spherical_map(region: BallRegion) -> CoordinateTransform | None:
1883
+ if region.dimension != 3 or len(region.variables) != 3:
1884
+ return None
1885
+ x, y, z = region.variables
1886
+ r = sp.Symbol("_r", nonnegative=True, real=True)
1887
+ phi = sp.Symbol("_phi", real=True)
1888
+ theta = sp.Symbol("_theta", real=True)
1889
+ return CoordinateTransform(
1890
+ source_vars=(x, y, z),
1891
+ target_vars=(theta, phi, r),
1892
+ forward_map=(
1893
+ r * sp.sin(phi) * sp.cos(theta),
1894
+ r * sp.sin(phi) * sp.sin(theta),
1895
+ r * sp.cos(phi),
1896
+ ),
1897
+ jacobian=r**2 * sp.sin(phi),
1898
+ target_ranges=((theta, 0, 2 * sp.pi), (phi, 0, sp.pi), (r, 0, region.radius)),
1899
+ )
1900
+
1901
+
1902
+ def _affine_region_transform(region: Region) -> CoordinateTransform | None:
1903
+ if isinstance(region, EllipsoidRegion):
1904
+ vars_ = tuple(region.variables_nd)
1905
+ uvars = sp.symbols(f"_u0:{len(vars_)}", real=True)
1906
+ jac = sp.Integer(1)
1907
+ forward = []
1908
+ for a, u in zip(region.axes, uvars, strict=True):
1909
+ jac *= sp.Abs(sp.sympify(a))
1910
+ forward.append(sp.sympify(a) * u)
1911
+ return CoordinateTransform(
1912
+ source_vars=vars_,
1913
+ target_vars=tuple(reversed(uvars)),
1914
+ forward_map=tuple(forward),
1915
+ jacobian=jac,
1916
+ target_ranges=_standard_ball_ranges(uvars, sp.Integer(1)),
1917
+ )
1918
+ if isinstance(region, AnnulusRegion):
1919
+ x, y = region.variables_xy
1920
+ r = sp.Symbol("_r", nonnegative=True, real=True)
1921
+ theta = sp.Symbol("_theta", real=True)
1922
+ return CoordinateTransform(
1923
+ source_vars=(x, y),
1924
+ target_vars=(theta, r),
1925
+ forward_map=(r * sp.cos(theta), r * sp.sin(theta)),
1926
+ jacobian=r,
1927
+ target_ranges=(
1928
+ (theta, 0, 2 * sp.pi),
1929
+ (r, region.inner_radius, region.outer_radius),
1930
+ ),
1931
+ )
1932
+ if isinstance(region, SphericalShellRegion) and len(region.variables_nd) == 3:
1933
+ x, y, z = region.variables_nd
1934
+ r = sp.Symbol("_r", nonnegative=True, real=True)
1935
+ phi = sp.Symbol("_phi", real=True)
1936
+ theta = sp.Symbol("_theta", real=True)
1937
+ return CoordinateTransform(
1938
+ source_vars=(x, y, z),
1939
+ target_vars=(theta, phi, r),
1940
+ forward_map=(
1941
+ r * sp.sin(phi) * sp.cos(theta),
1942
+ r * sp.sin(phi) * sp.sin(theta),
1943
+ r * sp.cos(phi),
1944
+ ),
1945
+ jacobian=r**2 * sp.sin(phi),
1946
+ target_ranges=(
1947
+ (theta, 0, 2 * sp.pi),
1948
+ (phi, 0, sp.pi),
1949
+ (r, region.inner_radius, region.outer_radius),
1950
+ ),
1951
+ )
1952
+ return None
1953
+
1954
+
1955
+ def _try_transform(
1956
+ transform: CoordinateTransform,
1957
+ expr: sp.Expr,
1958
+ *,
1959
+ assumptions,
1960
+ generate_conditions,
1961
+ principal_value,
1962
+ ) -> sp.Expr | None:
1963
+ transformed_expr = transform.apply(expr)
1964
+ if any(v in transformed_expr.free_symbols for v in transform.source_vars):
1965
+ return None
1966
+ try:
1967
+ return multiple_integrate(
1968
+ transformed_expr,
1969
+ *transform.target_ranges,
1970
+ assumptions=assumptions,
1971
+ generate_conditions=generate_conditions,
1972
+ principal_value=principal_value,
1973
+ )
1974
+ except Exception:
1975
+ return None
1976
+
1977
+
1978
+ def _try_region_transform(
1979
+ region: Region, expr: sp.Expr, *, assumptions, generate_conditions, principal_value
1980
+ ) -> sp.Expr | None:
1981
+ if isinstance(region, DiskRegion):
1982
+ val = _try_transform(
1983
+ _polar_disk_transform(region),
1984
+ expr,
1985
+ assumptions=assumptions,
1986
+ generate_conditions=generate_conditions,
1987
+ principal_value=principal_value,
1988
+ )
1989
+ if val is not None:
1990
+ return val
1991
+ if isinstance(region, BallRegion):
1992
+ tfm = _ball_spherical_map(region)
1993
+ if tfm is not None:
1994
+ val = _try_transform(
1995
+ tfm,
1996
+ expr,
1997
+ assumptions=assumptions,
1998
+ generate_conditions=generate_conditions,
1999
+ principal_value=principal_value,
2000
+ )
2001
+ if val is not None:
2002
+ return val
2003
+ tfm = _affine_region_transform(region)
2004
+ if tfm is not None:
2005
+ val = _try_transform(
2006
+ tfm,
2007
+ expr,
2008
+ assumptions=assumptions,
2009
+ generate_conditions=generate_conditions,
2010
+ principal_value=principal_value,
2011
+ )
2012
+ if val is not None:
2013
+ return val
2014
+ return None
2015
+
2016
+
2017
+ def _symbolically_positive(expr: sp.Expr, assumptions=None) -> bool:
2018
+ """Best-effort positivity test for structured convergence checks."""
2019
+ expr = sp.sympify(expr)
2020
+ if expr.is_positive is True:
2021
+ return True
2022
+ if (
2023
+ assumptions
2024
+ and isinstance(assumptions, dict)
2025
+ and expr.is_Symbol
2026
+ and assumptions.get(str(expr)) in ("positive", True)
2027
+ ):
2028
+ return True
2029
+ try:
2030
+ val = sp.N(expr)
2031
+ if val.is_real and float(val) > 0:
2032
+ return True
2033
+ except Exception:
2034
+ pass
2035
+ return False
2036
+
2037
+
2038
+ def _simplex_dirichlet_term(term: sp.Expr, vars_: tuple[sp.Symbol, ...]):
2039
+ """Parse coeff * prod(x_i**a_i) * (1-sum x_i)**b for a simplex term."""
2040
+ term = sp.factor_terms(sp.sympify(term))
2041
+ rem = sp.expand(1 - sum(vars_))
2042
+ coeff = sp.Integer(1)
2043
+ exponents = {v: sp.Integer(0) for v in vars_}
2044
+ rem_exp = sp.Integer(0)
2045
+ factors = term.args if term.is_Mul else (term,)
2046
+ for factor in factors:
2047
+ factor = sp.sympify(factor)
2048
+ if not (factor.free_symbols & set(vars_)):
2049
+ coeff *= factor
2050
+ continue
2051
+ base, exp = factor.as_base_exp()
2052
+ base = sp.expand(base)
2053
+ if base in exponents:
2054
+ exponents[base] += exp
2055
+ continue
2056
+ if sp.simplify(base - rem) == 0:
2057
+ rem_exp += exp
2058
+ continue
2059
+ return None
2060
+ return coeff, tuple(exponents[v] for v in vars_), rem_exp
2061
+
2062
+
2063
+ def _simplex_dirichlet(region: Region, expr: sp.Expr, assumptions=None) -> sp.Expr | None:
2064
+ """Exact Dirichlet-type integration on standard and affine simplices."""
2065
+ if isinstance(region, AffineSimplexRegion):
2066
+ vars_ = region.variables
2067
+ uvars = sp.symbols(f"_u0:{region.dimension}", real=True)
2068
+ subs = {
2069
+ v: sp.sympify(a) + sp.sympify(s) * u
2070
+ for v, a, s, u in zip(vars_, region.shifts, region.scales, uvars, strict=True)
2071
+ }
2072
+ jac = sp.Integer(1)
2073
+ for s in region.scales:
2074
+ jac *= sp.Abs(sp.sympify(s))
2075
+ transformed = sp.expand(sp.sympify(expr).subs(subs) * jac)
2076
+ simplex_ranges = tuple((u, 0, 1 - sum(uvars[:i])) for i, u in enumerate(uvars))
2077
+ simplex = SimplexRegion(simplex_ranges, dimension=region.dimension)
2078
+ return _simplex_dirichlet(simplex, transformed, assumptions=assumptions)
2079
+
2080
+ if not isinstance(region, SimplexRegion):
2081
+ return None
2082
+
2083
+ vars_ = tuple(region.variables)
2084
+ expr = sp.sympify(expr)
2085
+ total = sp.Integer(0)
2086
+ terms = expr.args if expr.is_Add else (expr,)
2087
+ for term in terms:
2088
+ parsed = _simplex_dirichlet_term(term, vars_)
2089
+ if parsed is None:
2090
+ return None
2091
+ coeff, exponents, rem_exp = parsed
2092
+ alphas = [sp.simplify(e + 1) for e in exponents] + [sp.simplify(rem_exp + 1)]
2093
+ if any(a.is_nonpositive is True for a in alphas):
2094
+ return None
2095
+ if any(
2096
+ (a.is_positive is not True) and not _symbolically_positive(a, assumptions=assumptions)
2097
+ for a in alphas
2098
+ ):
2099
+ return None
2100
+ numer = coeff
2101
+ for a in alphas:
2102
+ numer *= sp.gamma(a)
2103
+ total += numer / sp.gamma(sp.Add(*alphas))
2104
+ return _fast_simplify(total)
2105
+
2106
+
2107
+ def _try_gauss_linear_map(
2108
+ expr: sp.Expr, parsed_ranges: list[tuple], assumptions=None
2109
+ ) -> sp.Expr | None:
2110
+ vars_ = tuple(r[0] for r in parsed_ranges)
2111
+ if not vars_ or not all(
2112
+ sp.sympify(lo) == -sp.oo and sp.sympify(hi) == sp.oo for _, lo, hi in parsed_ranges
2113
+ ):
2114
+ return None
2115
+ expr = sp.sympify(expr)
2116
+ if expr.func != sp.exp or len(expr.args) != 1:
2117
+ return None
2118
+ q = sp.expand(expr.args[0])
2119
+ if any(
2120
+ v in sp.sympify(lo).free_symbols or v in sp.sympify(hi).free_symbols
2121
+ for v, lo, hi in parsed_ranges
2122
+ ):
2123
+ return None
2124
+ H = sp.hessian(q, vars_)
2125
+ A = sp.simplify(-H / 2)
2126
+ try:
2127
+ if any(entry.free_symbols & set(vars_) for entry in A):
2128
+ return None
2129
+ except Exception:
2130
+ return None
2131
+ quad = sp.expand((sp.Matrix(vars_).T * A * sp.Matrix(vars_))[0])
2132
+ linear_part = sp.expand(q + quad)
2133
+ b_entries = []
2134
+ for v in vars_:
2135
+ dv = sp.diff(linear_part, v)
2136
+ if dv.free_symbols & set(vars_):
2137
+ return None
2138
+ b_entries.append(sp.simplify(dv))
2139
+ bvec = sp.Matrix(b_entries)
2140
+ c = sp.simplify(linear_part - sum(b * v for b, v in zip(b_entries, vars_, strict=True)))
2141
+ if c.free_symbols & set(vars_):
2142
+ return None
2143
+ detA = sp.simplify(A.det())
2144
+ if detA == 0:
2145
+ return None
2146
+ try:
2147
+ if A.is_positive_definite is False:
2148
+ return None
2149
+ except Exception:
2150
+ pass
2151
+ if A.is_positive_definite is not True:
2152
+ # Avoid returning branch-sensitive square roots unless positivity is clear.
2153
+ return None
2154
+ try:
2155
+ shift_term = sp.simplify((bvec.T * A.LUsolve(bvec))[0] / 4)
2156
+ except Exception:
2157
+ return None
2158
+ return sp.simplify(
2159
+ sp.pi ** (sp.Rational(len(vars_), 2)) * sp.exp(c + shift_term) / sp.sqrt(detA)
2160
+ )
2161
+
2162
+
2163
+ def _region_shortcut(region: Region, expr: sp.Expr, assumptions=None) -> sp.Expr | None:
2164
+ """Exact moments and simple radial integrals on recognized regions."""
2165
+ if isinstance(region, UnionRegion):
2166
+ total = sp.Integer(0)
2167
+ for piece in region.pieces:
2168
+ val = _region_shortcut(piece, expr, assumptions=assumptions)
2169
+ if val is None:
2170
+ return None
2171
+ total += val
2172
+ return sp.simplify(total)
2173
+
2174
+ if isinstance(region, (SimplexRegion, AffineSimplexRegion)):
2175
+ poly_res = region.polynomial_moment(expr)
2176
+ if poly_res is not None:
2177
+ return poly_res
2178
+ dirichlet_res = _simplex_dirichlet(region, expr, assumptions=assumptions)
2179
+ if dirichlet_res is not None:
2180
+ return dirichlet_res
2181
+
2182
+ if isinstance(
2183
+ region,
2184
+ (DiskRegion, BallRegion, EllipsoidRegion, AnnulusRegion, SphericalShellRegion),
2185
+ ):
2186
+ poly_res = region.polynomial_moment(expr)
2187
+ if poly_res is not None:
2188
+ return poly_res
2189
+ return region.radial_integral(expr)
2190
+
2191
+ return None
2192
+
2193
+
2194
+ def _try_graph_reversal(
2195
+ expr: sp.Expr,
2196
+ region: Region,
2197
+ assumptions,
2198
+ generate_conditions: bool,
2199
+ principal_value: bool,
2200
+ ) -> sp.Expr | None:
2201
+ """Safely reverse simple 2-D graph regions when it reduces dependency.
2202
+
2203
+ The current Phase 2 heuristic only fires when the integrand depends on the
2204
+ inner variable but not the outer one. Reversing then converts the problem
2205
+ into one where the first integration produces a simple geometric factor.
2206
+ """
2207
+ if not isinstance(region, GraphRegion):
2208
+ return None
2209
+ if region.outer_var is None or region.inner_var is None:
2210
+ return None
2211
+ outer = region.outer_var
2212
+ inner = region.inner_var
2213
+ if outer in expr.free_symbols or inner not in expr.free_symbols:
2214
+ return None
2215
+ pieces = region.reversed_pieces()
2216
+ if not pieces:
2217
+ return None
2218
+
2219
+ total = sp.Integer(0)
2220
+ for piece in pieces:
2221
+ # reversed_pieces() now returns inner-first tuples, matching the public
2222
+ # SymPy convention: integrate x over [x_lo, x_hi] first, then y.
2223
+ (_, inner_lo, inner_hi), (outer, lo, hi) = piece
2224
+ weight = _fast_simplify(sp.sympify(inner_hi) - sp.sympify(inner_lo))
2225
+ total += multiple_integrate(
2226
+ _fast_simplify(weight * expr),
2227
+ (outer, lo, hi),
2228
+ assumptions=assumptions,
2229
+ generate_conditions=generate_conditions,
2230
+ principal_value=principal_value,
2231
+ )
2232
+ return _fast_simplify(total)
2233
+
2234
+
2235
+ if __name__ == "__main__":
2236
+ x, y, z = symbols("x y z", real=True)
2237
+ a = symbols("a", positive=True)
2238
+
2239
+ tests = [
2240
+ # ── Original polynomial tests ─────────────────────────────────────────
2241
+ (
2242
+ "Poly: x²y [0,1]×[0,2]",
2243
+ lambda: multiple_integrate(x**2 * y, (x, 0, 1), (y, 0, 2)),
2244
+ sp.Rational(2, 3),
2245
+ ),
2246
+ (
2247
+ "Poly: xyz [0,1]³",
2248
+ lambda: multiple_integrate(x * y * z, (x, 0, 1), (y, 0, 1), (z, 0, 1)),
2249
+ sp.Rational(1, 8),
2250
+ ),
2251
+ (
2252
+ "Poly: triangle x+y",
2253
+ lambda: multiple_integrate(x + y, (y, 0, 1 - x), (x, 0, 1)),
2254
+ sp.Rational(1, 3),
2255
+ ),
2256
+ # ── Quadratic / Gaussian ──────────────────────────────────────────────
2257
+ (
2258
+ "Gaussian R²",
2259
+ lambda: multiple_integrate(sp.exp(-(x**2 + y**2)), (x, -oo, oo), (y, -oo, oo)),
2260
+ pi,
2261
+ ),
2262
+ # ── Non-polynomial g: exponential ─────────────────────────────────────
2263
+ (
2264
+ "exp(-x)·exp(-y) [0,∞)²",
2265
+ lambda: multiple_integrate(sp.exp(-x) * sp.exp(-y), (x, 0, oo), (y, 0, oo)),
2266
+ sp.Integer(1),
2267
+ ),
2268
+ (
2269
+ "exp(-(x+y)) [0,∞)² (separable sum)",
2270
+ lambda: multiple_integrate(sp.exp(-(x + y)), (x, 0, oo), (y, 0, oo)),
2271
+ sp.Integer(1),
2272
+ ),
2273
+ # ── Non-polynomial g: trigonometric ───────────────────────────────────
2274
+ (
2275
+ "sin(x)·cos(y) [0,π/2]²",
2276
+ lambda: multiple_integrate(sp.sin(x) * sp.cos(y), (x, 0, pi / 2), (y, 0, pi / 2)),
2277
+ sp.Integer(1),
2278
+ ),
2279
+ (
2280
+ "cos(x+y) [0,π]² (separable sum)",
2281
+ lambda: multiple_integrate(sp.cos(x + y), (x, 0, pi), (y, 0, pi)),
2282
+ -4,
2283
+ ),
2284
+ # ── Non-polynomial g: monotone substitution ───────────────────────────
2285
+ (
2286
+ "1/(1+x²) [0,1]",
2287
+ lambda: multiple_integrate(1 / (1 + x**2), (x, 0, 1)),
2288
+ pi / 4,
2289
+ ),
2290
+ (
2291
+ "exp(-x²) [0,∞)",
2292
+ lambda: multiple_integrate(sp.exp(-(x**2)), (x, 0, oo)),
2293
+ sqrt(pi) / 2,
2294
+ ),
2295
+ # ── Non-polynomial g: piecewise-monotone ──────────────────────────────
2296
+ (
2297
+ "sin(x) [0,π]",
2298
+ lambda: multiple_integrate(sp.sin(x), (x, 0, pi)),
2299
+ sp.Integer(2),
2300
+ ),
2301
+ (
2302
+ "cos(x) [0,2π] (two monotone pieces)",
2303
+ lambda: multiple_integrate(sp.cos(x), (x, 0, 2 * pi)),
2304
+ sp.Integer(0),
2305
+ ),
2306
+ # ── Mixed: product of non-polynomial factors ──────────────────────────
2307
+ (
2308
+ "cos(x)·exp(-y) [0,π/2]×[0,∞)",
2309
+ lambda: multiple_integrate(sp.cos(x) * sp.exp(-y), (x, 0, pi / 2), (y, 0, oo)),
2310
+ sp.Integer(1),
2311
+ ),
2312
+ ]
2313
+
2314
+ passed = failed = errored = 0
2315
+ for desc, fn, expected in tests:
2316
+ try:
2317
+ result = fn()
2318
+ ok = sp.simplify(result - expected) == 0
2319
+ if ok:
2320
+ status = "✓ PASS"
2321
+ passed += 1
2322
+ else:
2323
+ status = f"✗ FAIL got={result} expected={expected}"
2324
+ failed += 1
2325
+ except Exception as e:
2326
+ status = f"✗ ERROR {type(e).__name__}: {e}"
2327
+ errored += 1
2328
+ print(f" {desc}: {status}")
2329
+
2330
+ print(f"\n{passed} passed, {failed} failed, {errored} errored out of {len(tests)} tests.")