multiple-integrate 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multiple_integrate/__init__.py +45 -0
- multiple_integrate/core.py +2330 -0
- multiple_integrate/regions.py +1125 -0
- multiple_integrate-2.0.0.dist-info/METADATA +396 -0
- multiple_integrate-2.0.0.dist-info/RECORD +7 -0
- multiple_integrate-2.0.0.dist-info/WHEEL +4 -0
- multiple_integrate-2.0.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,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.")
|