physverify 0.1.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.
- physverify/__init__.py +26 -0
- physverify/__main__.py +3 -0
- physverify/adapters/__init__.py +14 -0
- physverify/adapters/double_pendulum.py +55 -0
- physverify/adapters/lorenz.py +152 -0
- physverify/adapters/projectile_v1.py +92 -0
- physverify/adapters/projectile_v2.py +94 -0
- physverify/adapters/three_body.py +283 -0
- physverify/agent.py +265 -0
- physverify/checks.py +59 -0
- physverify/cli.py +105 -0
- physverify/compare.py +104 -0
- physverify/loaders.py +49 -0
- physverify/matrix.py +160 -0
- physverify/mms.py +118 -0
- physverify/order.py +131 -0
- physverify/problems/__init__.py +6 -0
- physverify/problems/_common.py +89 -0
- physverify/problems/heat1d.py +217 -0
- physverify/problems/kepler.py +176 -0
- physverify/problems/pendulum.py +201 -0
- physverify/problems/projectile.py +240 -0
- physverify/problems/sod.py +291 -0
- physverify/pytest_plugin.py +75 -0
- physverify/report.py +189 -0
- physverify/steppers.py +38 -0
- physverify/stochastic/__init__.py +1 -0
- physverify/stochastic/calibrate.py +202 -0
- physverify/stochastic/checks.py +378 -0
- physverify/stochastic/exact.py +205 -0
- physverify/stochastic/ising.py +170 -0
- physverify/stochastic/matrix.py +151 -0
- physverify/stochastic/reference.py +67 -0
- physverify/stochastic/sampler.py +253 -0
- physverify/stochastic/stats.py +137 -0
- physverify/studies.py +146 -0
- physverify/symbolic.py +89 -0
- physverify-0.1.0.dist-info/METADATA +122 -0
- physverify-0.1.0.dist-info/RECORD +43 -0
- physverify-0.1.0.dist-info/WHEEL +5 -0
- physverify-0.1.0.dist-info/entry_points.txt +5 -0
- physverify-0.1.0.dist-info/licenses/LICENSE +21 -0
- physverify-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Statistics for Monte Carlo checks: error bars, autocorrelation and verdicts.
|
|
2
|
+
|
|
3
|
+
Design (docs/plan_ising.md, section 4):
|
|
4
|
+
|
|
5
|
+
* Every estimate is a mean over independent chains (each chain is burned in and
|
|
6
|
+
then averaged). Chains are independent by construction, so the standard error
|
|
7
|
+
is the spread of the chain means over sqrt(n_chains), with no
|
|
8
|
+
autocorrelation model needed. Within one chain, `tau_int` and `blocked_error`
|
|
9
|
+
give the error bar for codes that report one.
|
|
10
|
+
* A check makes m comparisons with exact values. Each uses a two-sided Student-t
|
|
11
|
+
test with n_chains - 1 degrees of freedom at level alpha / m (Bonferroni),
|
|
12
|
+
so the false-alarm rate of a whole check is at most alpha.
|
|
13
|
+
* Verdicts: FAIL when some comparison is significant; INCONCLUSIVE when none is,
|
|
14
|
+
but the test could not have detected a deviation as large as the stated
|
|
15
|
+
resolution (critical value times standard error > resolution); PASS otherwise.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from ..checks import FAIL, INCONCLUSIVE, PASS, Result
|
|
27
|
+
|
|
28
|
+
ALPHA_CHECK = 1e-3 # false-alarm rate of one statistical check
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _t_pdf(x, nu):
|
|
32
|
+
c = math.lgamma((nu + 1) / 2) - math.lgamma(nu / 2) - 0.5 * math.log(nu * math.pi)
|
|
33
|
+
return np.exp(c - (nu + 1) / 2 * np.log1p(x * x / nu))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def t_sf(x: float, nu: float) -> float:
|
|
37
|
+
"""P(T > x) for Student's t with nu degrees of freedom (x >= 0), by Simpson's rule on the pdf."""
|
|
38
|
+
x = abs(float(x))
|
|
39
|
+
if nu > 1e6:
|
|
40
|
+
return 0.5 * math.erfc(x / math.sqrt(2))
|
|
41
|
+
n = 4000
|
|
42
|
+
grid = np.linspace(0.0, x, n + 1)
|
|
43
|
+
w = np.ones(n + 1)
|
|
44
|
+
w[1:-1:2], w[2:-1:2] = 4, 2
|
|
45
|
+
integral = float((w * _t_pdf(grid, nu)).sum() * x / (3 * n))
|
|
46
|
+
return max(0.0, 0.5 - integral)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@lru_cache(maxsize=4096)
|
|
50
|
+
def t_critical(alpha_two_sided: float, nu: float) -> float:
|
|
51
|
+
"""x with P(|T| > x) = alpha, by bisection (cached: checks ask for the same few values)."""
|
|
52
|
+
lo, hi = 0.0, 50.0
|
|
53
|
+
for _ in range(80):
|
|
54
|
+
mid = 0.5 * (lo + hi)
|
|
55
|
+
if 2 * t_sf(mid, nu) > alpha_two_sided:
|
|
56
|
+
lo = mid
|
|
57
|
+
else:
|
|
58
|
+
hi = mid
|
|
59
|
+
return 0.5 * (lo + hi)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tau_int(series, c: float = 6.0) -> float:
|
|
63
|
+
"""Integrated autocorrelation time with Sokal's automatic window (window W >= c * tau)."""
|
|
64
|
+
x = np.asarray(series, dtype=float)
|
|
65
|
+
x = x - x.mean()
|
|
66
|
+
n = len(x)
|
|
67
|
+
var = float(np.dot(x, x)) / n
|
|
68
|
+
if n < 4 or var == 0.0:
|
|
69
|
+
return 0.5
|
|
70
|
+
f = np.fft.rfft(x, 2 * n)
|
|
71
|
+
acf = np.fft.irfft(f * np.conj(f))[:n] / (n * var)
|
|
72
|
+
tau = 0.5
|
|
73
|
+
for w in range(1, n):
|
|
74
|
+
tau += acf[w]
|
|
75
|
+
if w >= c * tau:
|
|
76
|
+
break
|
|
77
|
+
return max(tau, 0.5)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def blocked_error(series) -> float:
|
|
81
|
+
"""Standard error of the mean of a correlated series: sqrt(2 tau var / n)."""
|
|
82
|
+
x = np.asarray(series, dtype=float)
|
|
83
|
+
return float(np.sqrt(2.0 * tau_int(x) * x.var() / len(x)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Comparison:
|
|
88
|
+
name: str
|
|
89
|
+
estimate: float
|
|
90
|
+
se: float
|
|
91
|
+
exact: float
|
|
92
|
+
resolution: float
|
|
93
|
+
dof: float
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def compare_chain_means(name, per_chain_values, exact, resolution, min_se=0.0) -> Comparison:
|
|
97
|
+
"""Mean over independent chains, with the spread of the chain means as its standard error.
|
|
98
|
+
|
|
99
|
+
`min_se` is a floor for the standard error: for the frequency of a rare event the
|
|
100
|
+
chain-to-chain spread can be zero when the event never happens, which would make
|
|
101
|
+
any small expected probability look like a certain deviation.
|
|
102
|
+
"""
|
|
103
|
+
v = np.asarray(per_chain_values, dtype=float)
|
|
104
|
+
se = float(v.std(ddof=1) / math.sqrt(len(v))) if len(v) > 1 else float("inf")
|
|
105
|
+
return Comparison(name, float(v.mean()), max(se, float(min_se)), float(exact), float(resolution), len(v) - 1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def verdict(comparisons, alpha: float = ALPHA_CHECK) -> Result:
|
|
109
|
+
"""Bonferroni-corrected t-tests; see the module docstring for PASS, FAIL and INCONCLUSIVE."""
|
|
110
|
+
m = max(len(comparisons), 1)
|
|
111
|
+
worst, worst_ratio, unresolved = None, -1.0, []
|
|
112
|
+
for c in comparisons:
|
|
113
|
+
if not (np.isfinite(c.estimate) and np.isfinite(c.se)):
|
|
114
|
+
return Result(FAIL, f"{c.name}: non-finite estimate")
|
|
115
|
+
crit = t_critical(alpha / m, float(c.dof))
|
|
116
|
+
dev = abs(c.estimate - c.exact)
|
|
117
|
+
if c.se == 0.0:
|
|
118
|
+
ratio = math.inf if dev > 1e-12 * max(1.0, abs(c.exact)) else 0.0
|
|
119
|
+
else:
|
|
120
|
+
ratio = dev / (crit * c.se)
|
|
121
|
+
if ratio > worst_ratio:
|
|
122
|
+
worst, worst_ratio = c, ratio
|
|
123
|
+
if crit * c.se > c.resolution:
|
|
124
|
+
unresolved.append(c.name)
|
|
125
|
+
w = worst
|
|
126
|
+
detail = f"{w.name} = {w.estimate:.5g} +- {w.se:.2g}, exact {w.exact:.5g}"
|
|
127
|
+
level = f"(Bonferroni over {m}, false-alarm rate {alpha:g})"
|
|
128
|
+
if worst_ratio > 1.0:
|
|
129
|
+
sigmas = abs(w.estimate - w.exact) / w.se if w.se > 0 else math.inf
|
|
130
|
+
return Result(FAIL, f"{detail}: off by {sigmas:.1f} standard errors {level}")
|
|
131
|
+
if unresolved:
|
|
132
|
+
return Result(
|
|
133
|
+
INCONCLUSIVE,
|
|
134
|
+
f"no significant deviation, but the error bars cannot resolve a deviation of the stated size for "
|
|
135
|
+
f"{len(unresolved)} of {m} comparisons (first: {unresolved[0]}) {level}",
|
|
136
|
+
)
|
|
137
|
+
return Result(PASS, f"all {m} comparisons agree; largest: {detail} {level}")
|
physverify/studies.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Order studies other than MMS, and a helper that bundles them for an ODE code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .checks import FAIL, PASS, Check, OrderResult, Result
|
|
10
|
+
from .mms import ManufacturedODE, max_abs_error, mms_order_study, mms_trajectory_study, zero_source
|
|
11
|
+
from .order import check_exact, check_order
|
|
12
|
+
from .steppers import integrate_fixed, rk4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def reference_study(integrate, rhs_ref, y0, t_end, ns, expected_order, ref_factor=16, **kw) -> OrderResult:
|
|
16
|
+
"""Compare with a reference solution from an independent right-hand side.
|
|
17
|
+
|
|
18
|
+
The reference is RK4 on `rhs_ref` with ref_factor times the finest step
|
|
19
|
+
count, so its own error is negligible next to the code under test.
|
|
20
|
+
"""
|
|
21
|
+
y_ref = integrate_fixed(rhs_ref, y0, 0.0, t_end, ref_factor * max(ns), rk4)
|
|
22
|
+
kw.setdefault("scale", float(np.max(np.abs(y_ref))))
|
|
23
|
+
hs, errs = [], []
|
|
24
|
+
for n in ns:
|
|
25
|
+
hs.append(t_end / n)
|
|
26
|
+
errs.append(max_abs_error(integrate(zero_source, y0, t_end, n), y_ref))
|
|
27
|
+
return check_order(hs, errs, expected_order, **kw)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def invariant_study(integrate, quantity, y0, t_end, ns, expected_order, **kw) -> OrderResult:
|
|
31
|
+
"""Relative drift of a conserved quantity at t_end as the step shrinks.
|
|
32
|
+
|
|
33
|
+
Drift is only required to shrink at least as fast as the scheme's order.
|
|
34
|
+
RK4 on a pendulum, for example, drifts in energy at about order 5, so a
|
|
35
|
+
drift that converges faster than expected is not a failure here.
|
|
36
|
+
"""
|
|
37
|
+
kw.setdefault("tol_high", float("inf"))
|
|
38
|
+
q0 = float(quantity(np.asarray(y0, dtype=float)))
|
|
39
|
+
scale = max(abs(q0), 1.0)
|
|
40
|
+
hs, errs = [], []
|
|
41
|
+
for n in ns:
|
|
42
|
+
with np.errstate(all="ignore"):
|
|
43
|
+
y_end = np.asarray(integrate(zero_source, y0, t_end, n), dtype=float)
|
|
44
|
+
drift = abs(float(quantity(y_end)) - q0) / scale
|
|
45
|
+
hs.append(t_end / n)
|
|
46
|
+
errs.append(drift)
|
|
47
|
+
return check_order(hs, errs, expected_order, **kw)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def linear_manufactured(rhs_ref: Callable, y0, slope) -> ManufacturedODE:
|
|
51
|
+
"""u(t) = y0 + slope * t, with its forcing taken from the reference operator."""
|
|
52
|
+
y0 = np.asarray(y0, dtype=float)
|
|
53
|
+
slope = np.asarray(slope, dtype=float)
|
|
54
|
+
return ManufacturedODE(lambda t: y0 + slope * t, rhs_ref, du=lambda t: slope)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def patch_study(integrate, rhs_ref, y0, slope, t_end, ns, *, floor=1e-11) -> OrderResult:
|
|
58
|
+
"""Exactness test for an ODE code: a manufactured solution linear in t.
|
|
59
|
+
|
|
60
|
+
Every consistent explicit Runge-Kutta method (Euler, midpoint, RK4, ...)
|
|
61
|
+
reproduces u(t) = y0 + slope * t exactly, whatever the step: each stage
|
|
62
|
+
lands on u at its own stage time, so each stage derivative is exactly
|
|
63
|
+
`slope`, and weights that sum to one return y0 + slope * (t + h). So any
|
|
64
|
+
error above round-off is a bug, including bugs whose error shrinks at the
|
|
65
|
+
scheme's own order (an extra step for Euler, a wrong stage time) that every
|
|
66
|
+
order-of-convergence check accepts. See docs/patch.md.
|
|
67
|
+
|
|
68
|
+
It does not see a wrong but consistent stepper (Euler in place of RK4 is
|
|
69
|
+
still exact here): the order studies cover that.
|
|
70
|
+
"""
|
|
71
|
+
mms = linear_manufactured(rhs_ref, y0, slope)
|
|
72
|
+
exact = mms.value(t_end)
|
|
73
|
+
hs, errs = [], []
|
|
74
|
+
for n in ns:
|
|
75
|
+
y_end = integrate(mms.forcing, mms.value(0.0), t_end, n)
|
|
76
|
+
hs.append(t_end / n)
|
|
77
|
+
errs.append(max_abs_error(y_end, exact))
|
|
78
|
+
return check_exact(hs, errs, floor=floor, scale=float(np.max(np.abs(exact))))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def smoke_check(label, integrate, rhs_ref, y0, t_end, n, *, rtol=1e-3) -> Check:
|
|
82
|
+
"""What an ordinary unit test would do: one run, compared with a reference within a tolerance.
|
|
83
|
+
|
|
84
|
+
Not one of the verification families. It is here so that the benchmark can
|
|
85
|
+
report what a plain test would have caught (see physverify.compare).
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def run():
|
|
89
|
+
y_ref = integrate_fixed(rhs_ref, y0, 0.0, t_end, 16 * n, rk4)
|
|
90
|
+
y = np.asarray(integrate(zero_source, y0, t_end, n), dtype=float)
|
|
91
|
+
return close_result(y, y_ref, rtol)
|
|
92
|
+
|
|
93
|
+
return Check("smoke", label, run)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def close_result(value, expected, rtol) -> Result:
|
|
97
|
+
value = np.asarray(value, dtype=float)
|
|
98
|
+
expected = np.asarray(expected, dtype=float)
|
|
99
|
+
with np.errstate(all="ignore"):
|
|
100
|
+
err = float(np.max(np.abs(value - expected)) / max(float(np.max(np.abs(expected))), 1e-300))
|
|
101
|
+
if np.isfinite(err) and err <= rtol:
|
|
102
|
+
return Result(PASS, f"relative difference {err:.2g} within {rtol:g}")
|
|
103
|
+
return Result(FAIL, f"relative difference {err:.3g} exceeds {rtol:g}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ode_checks(
|
|
107
|
+
label: str,
|
|
108
|
+
integrate: Callable,
|
|
109
|
+
*,
|
|
110
|
+
rhs_ref: Callable,
|
|
111
|
+
mms: ManufacturedODE,
|
|
112
|
+
y0_free,
|
|
113
|
+
t_end: float,
|
|
114
|
+
ns,
|
|
115
|
+
order: float,
|
|
116
|
+
quantity: Callable | None = None,
|
|
117
|
+
floor: float = 1e-12,
|
|
118
|
+
patch: tuple | None = None,
|
|
119
|
+
integrate_trajectory: Callable | None = None,
|
|
120
|
+
) -> list[Check]:
|
|
121
|
+
"""The standard bundle for an ODE code: MMS, reference solution, invariant.
|
|
122
|
+
|
|
123
|
+
patch (y0, slope) of a linear manufactured solution; adds the exactness check
|
|
124
|
+
integrate_trajectory same contract as `integrate` but returning every state; adds an MMS
|
|
125
|
+
check on the largest error over the whole run
|
|
126
|
+
"""
|
|
127
|
+
checks = [
|
|
128
|
+
Check("mms", label, lambda: mms_order_study(integrate, mms, t_end, ns, order, floor=floor)),
|
|
129
|
+
Check("reference", label, lambda: reference_study(integrate, rhs_ref, y0_free, t_end, ns, order, floor=floor)),
|
|
130
|
+
]
|
|
131
|
+
if integrate_trajectory is not None:
|
|
132
|
+
checks.append(
|
|
133
|
+
Check(
|
|
134
|
+
"mms",
|
|
135
|
+
f"{label} (whole trajectory)",
|
|
136
|
+
lambda: mms_trajectory_study(integrate_trajectory, mms, t_end, ns, order, floor=floor),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
if quantity is not None:
|
|
140
|
+
checks.append(
|
|
141
|
+
Check("invariant", label, lambda: invariant_study(integrate, quantity, y0_free, t_end, ns, order, floor=floor))
|
|
142
|
+
)
|
|
143
|
+
if patch is not None:
|
|
144
|
+
p_y0, p_slope = patch
|
|
145
|
+
checks.append(Check("patch", label, lambda: patch_study(integrate, rhs_ref, p_y0, p_slope, t_end, ns)))
|
|
146
|
+
return checks
|
physverify/symbolic.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Manufactured solutions with symbolic derivatives (optional; needs SymPy).
|
|
2
|
+
|
|
3
|
+
pip install physverify[symbolic]
|
|
4
|
+
|
|
5
|
+
Hand-derived source terms are a common place for the verification itself to
|
|
6
|
+
go wrong. Here you write the manufactured solution and the PDE operator once,
|
|
7
|
+
symbolically, and the source term is derived and turned into NumPy functions.
|
|
8
|
+
|
|
9
|
+
Example, the 1D heat equation u_t = alpha u_xx + s:
|
|
10
|
+
|
|
11
|
+
import sympy as sp
|
|
12
|
+
x, t = sp.symbols("x t")
|
|
13
|
+
u = sp.exp(-t) * sp.sin(sp.pi * x)
|
|
14
|
+
ms = manufactured_pde(u, lambda u: sp.diff(u, t) - 0.7 * sp.diff(u, x, 2), (x, t))
|
|
15
|
+
ms.source(xs, 0.3) # NumPy array of s at the points xs, time 0.3
|
|
16
|
+
|
|
17
|
+
The operator must be written independently of the code under test, for the same
|
|
18
|
+
reason as the ODE reference operator (see physverify.mms).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Callable
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sympy():
|
|
30
|
+
try:
|
|
31
|
+
import sympy
|
|
32
|
+
except ImportError as exc: # pragma: no cover - exercised only without sympy
|
|
33
|
+
raise ImportError("physverify.symbolic needs SymPy: pip install physverify[symbolic]") from exc
|
|
34
|
+
return sympy
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ManufacturedPDE:
|
|
39
|
+
solution_expr: object
|
|
40
|
+
source_expr: object
|
|
41
|
+
solution: Callable
|
|
42
|
+
source: Callable
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _vectorise(f, n_args):
|
|
46
|
+
"""lambdify returns a scalar for a constant expression; broadcast it to the inputs' shape."""
|
|
47
|
+
|
|
48
|
+
def g(*args):
|
|
49
|
+
value = f(*args)
|
|
50
|
+
shape = np.broadcast(*[np.asarray(a) for a in args[:n_args]]).shape
|
|
51
|
+
return np.broadcast_to(np.asarray(value, dtype=float), shape).copy() if shape else float(value)
|
|
52
|
+
|
|
53
|
+
return g
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def manufactured_pde(u_expr, operator: Callable, variables) -> ManufacturedPDE:
|
|
57
|
+
"""Source s = operator(u) for a manufactured solution u.
|
|
58
|
+
|
|
59
|
+
`operator(u)` returns the PDE residual for u, written with sympy.diff, so that
|
|
60
|
+
u solves operator(u) = s exactly. `variables` is the tuple of SymPy symbols
|
|
61
|
+
the NumPy functions take, in order (for example (x, t) or (x, y, t)).
|
|
62
|
+
"""
|
|
63
|
+
sp = _sympy()
|
|
64
|
+
variables = tuple(variables)
|
|
65
|
+
source_expr = sp.simplify(operator(u_expr))
|
|
66
|
+
n = len(variables)
|
|
67
|
+
return ManufacturedPDE(
|
|
68
|
+
u_expr,
|
|
69
|
+
source_expr,
|
|
70
|
+
_vectorise(sp.lambdify(variables, u_expr, "numpy"), n),
|
|
71
|
+
_vectorise(sp.lambdify(variables, source_expr, "numpy"), n),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def manufactured_ode(u_exprs, rhs_ref: Callable, t_symbol):
|
|
76
|
+
"""A physverify.ManufacturedODE whose derivative is taken symbolically.
|
|
77
|
+
|
|
78
|
+
`u_exprs` is a list of SymPy expressions in `t_symbol`, one per state component.
|
|
79
|
+
"""
|
|
80
|
+
from .mms import ManufacturedODE
|
|
81
|
+
|
|
82
|
+
sp = _sympy()
|
|
83
|
+
u_f = sp.lambdify(t_symbol, list(u_exprs), "numpy")
|
|
84
|
+
du_f = sp.lambdify(t_symbol, [sp.diff(e, t_symbol) for e in u_exprs], "numpy")
|
|
85
|
+
return ManufacturedODE(
|
|
86
|
+
lambda t: np.array(u_f(t), dtype=float),
|
|
87
|
+
rhs_ref,
|
|
88
|
+
du=lambda t: np.array(du_f(t), dtype=float),
|
|
89
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: physverify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verification checks for physics simulation code: manufactured solutions, order-of-accuracy studies and a seeded-bug benchmark.
|
|
5
|
+
Author: Ram Chandra Giri
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rcgiri-physics/physverify
|
|
8
|
+
Project-URL: Documentation, https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md
|
|
9
|
+
Project-URL: Issues, https://github.com/rcgiri-physics/physverify/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: verification,method of manufactured solutions,numerical methods,physics,simulation,testing
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Classifier: Intended Audience :: Science/Research
|
|
23
|
+
Classifier: Intended Audience :: Education
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Classifier: Development Status :: 3 - Alpha
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: numpy>=1.24
|
|
30
|
+
Provides-Extra: symbolic
|
|
31
|
+
Requires-Dist: sympy>=1.12; extra == "symbolic"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
34
|
+
Requires-Dist: hypothesis>=6; extra == "dev"
|
|
35
|
+
Requires-Dist: sympy>=1.12; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# physverify
|
|
39
|
+
|
|
40
|
+
**A tool that checks whether a physics simulation is solving the right equations correctly, not just running without errors.**
|
|
41
|
+
|
|
42
|
+
Status: early version (0.1.0). Nobody besides the author has used it yet, so feedback is very welcome.
|
|
43
|
+
|
|
44
|
+
## The problem it addresses
|
|
45
|
+
|
|
46
|
+
A simulation can run, produce nice plots and still be wrong: a minus sign in the wrong place, a factor of 2 missing, a time step that is off by one. Ordinary tests often miss these, because nobody knows the "right answer" for a real simulation to compare against.
|
|
47
|
+
|
|
48
|
+
## How it works, in plain words
|
|
49
|
+
|
|
50
|
+
physverify uses a few standard ideas from numerical analysis:
|
|
51
|
+
|
|
52
|
+
1. **Make up an answer, then check the code finds it.** Pick a smooth function, work out what extra push (a "source term") would make it an exact solution, give that push to your code, and see if your code reproduces the function. This is called the *Method of Manufactured Solutions*.
|
|
53
|
+
2. **Check the error shrinks at the right speed.** If your method is supposed to be 4th order, halving the time step should make the error about 16 times smaller. If it only gets 2 times smaller, something is wrong, even if the error looked small.
|
|
54
|
+
3. **Check conserved quantities.** Energy, mass or momentum should stay constant (or drift in a predictable way).
|
|
55
|
+
4. **Check known special cases.** For example, a projectile with no air resistance has a range you can write down by hand.
|
|
56
|
+
5. **Exact "patch" tests.** Some solutions should be reproduced to computer precision. This catches small bugs that the other checks let through.
|
|
57
|
+
|
|
58
|
+
Each check reports **PASS**, **FAIL**, **EXACT** or **INCONCLUSIVE**.
|
|
59
|
+
|
|
60
|
+
There is also a separate set of checks for random (Monte Carlo) simulations, using the 2D Ising model as the example.
|
|
61
|
+
|
|
62
|
+
## Does it actually catch bugs?
|
|
63
|
+
|
|
64
|
+
To test it, 37 bugs were added one at a time to five small solvers (projectile, double pendulum, planet orbit, heat equation, shock tube):
|
|
65
|
+
|
|
66
|
+
- **35 of 37** bugs were caught. Plain "compare with one answer" tests caught 27.
|
|
67
|
+
- **0 false alarms** on the bug-free versions.
|
|
68
|
+
- The 2 missed bugs are explained in the [technical details](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md).
|
|
69
|
+
|
|
70
|
+
For the Monte Carlo checks, all 20 seeded bugs were caught with no false alarms.
|
|
71
|
+
|
|
72
|
+
The bugs and the checks were written by the same person, so these numbers show the tool works as designed. They are not a promise about bugs in other people's code.
|
|
73
|
+
|
|
74
|
+
## Try it
|
|
75
|
+
|
|
76
|
+
You need Python 3.10 or newer.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install physverify
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The example below lives in the repository, so clone it to follow along:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
git clone https://github.com/rcgiri-physics/physverify.git
|
|
86
|
+
cd physverify
|
|
87
|
+
physverify run examples/verify_damped_oscillator.py
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You should see a list of checks, all passing. Now open `examples/damped_oscillator.py`, change the damping by 10%, and run the same command again: the checks fail.
|
|
91
|
+
|
|
92
|
+
To check your own code you write a short "spec" file. It tells physverify two things: the equations your code is meant to solve (written independently of your code), and how to run your code. [examples/verify_damped_oscillator.py](https://github.com/rcgiri-physics/physverify/blob/main/examples/verify_damped_oscillator.py) is a complete example to copy.
|
|
93
|
+
|
|
94
|
+
Other ways to run it:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
physverify run my_spec.py --html report.html # a web page with convergence plots
|
|
98
|
+
physverify run my_spec.py --json - # machine-readable output
|
|
99
|
+
pytest my_spec.py # each check becomes a pytest test
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## What it can and cannot do
|
|
103
|
+
|
|
104
|
+
- Works best for ordinary differential equations (orbits, pendulums, projectiles).
|
|
105
|
+
- Partial differential equations: only 1D examples so far (heat equation, shock tube).
|
|
106
|
+
- Your code needs a way to accept an extra source term, or a small wrapper ("adapter") around it. The wrapper can itself contain mistakes.
|
|
107
|
+
- Passing all checks does not prove the code is correct, only that these tests found nothing wrong.
|
|
108
|
+
|
|
109
|
+
## More
|
|
110
|
+
|
|
111
|
+
- [results/own_work/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/own_work/SUMMARY.md): results on four of the author's own public physics projects (two real bugs found and fixed).
|
|
112
|
+
- [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
|
|
113
|
+
- [docs/patch.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/patch.md): what the exact "patch" checks assume.
|
|
114
|
+
- [docs/ising.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/ising.md): checks for Monte Carlo codes.
|
|
115
|
+
- [docs/agent.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/agent.md): using it with an AI coding assistant.
|
|
116
|
+
- [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md)
|
|
117
|
+
|
|
118
|
+
Run the project's own tests with `pip install -e .[dev]` then `python -m pytest -q` (about 1.5 minutes).
|
|
119
|
+
|
|
120
|
+
## Licence
|
|
121
|
+
|
|
122
|
+
MIT. Copyright (c) 2026 Ram Chandra Giri.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
physverify/__init__.py,sha256=viD63SRjqeOtmvyYstDQD-0Ybiq_V6vqAdehw1znImA,657
|
|
2
|
+
physverify/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
physverify/agent.py,sha256=lZKvUYykji8VkFU38mtwsE9XSCKXbPwMnJDnGz4h1qk,11398
|
|
4
|
+
physverify/checks.py,sha256=0BmPL4WRXvcFXGIAhnrWxU284aUjsKGf3WZyb95s3Lo,1699
|
|
5
|
+
physverify/cli.py,sha256=-phU4181-O6lFhIOMEOP2TA7Y0wLO3oQVG-0qWmcjks,3580
|
|
6
|
+
physverify/compare.py,sha256=9dAfKsl5n63VvJPDJ1d9PGqRLumuwMr_Gtg-23kjZN0,3809
|
|
7
|
+
physverify/loaders.py,sha256=pO9p3yzPxZWgVaJr8fw46DQxAtksXl-Ta-cvqlZVzvE,1420
|
|
8
|
+
physverify/matrix.py,sha256=48Eappft-KuHy0DJtJyCbfQPQkLtVQx9f3dyIe-bxAo,6321
|
|
9
|
+
physverify/mms.py,sha256=x7US9wUXCOEPSxja1t1F37crds3b7yFV-kzMV-TGEQY,4145
|
|
10
|
+
physverify/order.py,sha256=x1QKA8rfmbYpCidcQXZvvJ75oYXhMXdJY_XPNPVtPuo,5310
|
|
11
|
+
physverify/pytest_plugin.py,sha256=_cL4iQFIawXPu4WY8n4JtdQ443aa_fntjJ7rxuMLU7c,2539
|
|
12
|
+
physverify/report.py,sha256=uopkT1qP4OVmGp6yDeRcNZe8jc-gXR7SnY3uYbUb_Cs,7672
|
|
13
|
+
physverify/steppers.py,sha256=A2BMgca3_Oq_3kEicblZCGSIaPeq1Da472wrPVcA35c,975
|
|
14
|
+
physverify/studies.py,sha256=bzjmp7zdNBlkWCDqF8V2V4K0cqJ-EfCoYxDWTIJMONw,6105
|
|
15
|
+
physverify/symbolic.py,sha256=8oAuSYzToGKOSseKvjYItQcZKqDSE-J9BWMgJXiOJAo,2955
|
|
16
|
+
physverify/adapters/__init__.py,sha256=jFByVfj91ENm_S6L2BSFd0vpJJQUZi5skCYpPAUJAW0,461
|
|
17
|
+
physverify/adapters/double_pendulum.py,sha256=EUIaimQ4hDcEwi3h3pMKZlBofjinofsjZGjdm8SLAec,1629
|
|
18
|
+
physverify/adapters/lorenz.py,sha256=h5aQLkeS93oUyEJPUa1NduaL74zAxhH6GPOqZqKTZgc,5410
|
|
19
|
+
physverify/adapters/projectile_v1.py,sha256=XSbS6B-gkX0ziEmDEazXILKmR4FUzvyD14_XWfFFg3U,3366
|
|
20
|
+
physverify/adapters/projectile_v2.py,sha256=IJnTfhd9-zdzsD93c0IYekqq0br7KqDs7awZGE95WIg,3579
|
|
21
|
+
physverify/adapters/three_body.py,sha256=Vv7m7uagZtIxGPiQ6ufeZPejcyNPFVFzuhYNbVjTPq0,11028
|
|
22
|
+
physverify/problems/__init__.py,sha256=b0GJzVq3c38csBkDIatyj2Koc3j10Px26uyTXd7cTi8,218
|
|
23
|
+
physverify/problems/_common.py,sha256=3aTKpLUdKKvN5fAJUjNTO-f1DGEFJ57Xu74DftAesP0,2911
|
|
24
|
+
physverify/problems/heat1d.py,sha256=KiZUqJ7FFfiKQK_tGj8PRyytHlLNvh7h7XCq2LQ6k8M,7936
|
|
25
|
+
physverify/problems/kepler.py,sha256=saLS4J9L_0wFPhIc3YYftQjeSGR19BTY67A1P5P2vpI,5626
|
|
26
|
+
physverify/problems/pendulum.py,sha256=s_PCWCBtugBkchmxjHMiR5Sr-t4Uq1314fmBfYaqQkI,7357
|
|
27
|
+
physverify/problems/projectile.py,sha256=3FMjeXYLc36mvApQZC5dviMdbSiT3K3UhBovdoII7yE,9390
|
|
28
|
+
physverify/problems/sod.py,sha256=KyXLbjuHy3d3OFOOYnXSaiNGhepHuNLZpcQDlDHcVo0,11934
|
|
29
|
+
physverify/stochastic/__init__.py,sha256=RrooJVboF5LPwhLTKnf4dezmnNhwaT0wV7ehGV8vkYw,101
|
|
30
|
+
physverify/stochastic/calibrate.py,sha256=1eprgcx0UwoqYoI3fibisUxQF61QJOa07eu_E5FFNMA,8633
|
|
31
|
+
physverify/stochastic/checks.py,sha256=8JS_bvK-MG-zZdJ8uspATPafv4WsvVSLkBCA5yXxSOs,18086
|
|
32
|
+
physverify/stochastic/exact.py,sha256=U7Mg4LmfT__eRLUyTsKHAhPrYKAVU6bQBJK21Y_pEN0,7484
|
|
33
|
+
physverify/stochastic/ising.py,sha256=4LAJ3kKLGzBd_XtjPtb980VU5gsP1YtlYze5CbP-l0U,7936
|
|
34
|
+
physverify/stochastic/matrix.py,sha256=RHHHmWvY5leesSELTLXQ69Sy397blXxsu6VTA-rQKMk,6235
|
|
35
|
+
physverify/stochastic/reference.py,sha256=s3G83INawcnNbv2k1ScHQI3b3-7m2EDxEozegXdqAU4,2264
|
|
36
|
+
physverify/stochastic/sampler.py,sha256=m0jV_qd3G-V2Q4NM653TTbA8QTgz7vj6zKfMX8FSp9c,10548
|
|
37
|
+
physverify/stochastic/stats.py,sha256=G5SEj6iu3BUKQ5hKU89Cj2h201KfGQBexDeGhJUGZ8U,5244
|
|
38
|
+
physverify-0.1.0.dist-info/licenses/LICENSE,sha256=A7qXUpTre2zp1GZSa1XwjM14YGyHnvA2Zh0frie2RKg,1073
|
|
39
|
+
physverify-0.1.0.dist-info/METADATA,sha256=R7V58ro1qXmIcs2pjyblcfYTYtuLJMZK8Fs9rLUBG2U,6664
|
|
40
|
+
physverify-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
41
|
+
physverify-0.1.0.dist-info/entry_points.txt,sha256=PAskhOwukCAo7xzNdJzVBYCwH0j_qck2_LFoCLkzM7k,101
|
|
42
|
+
physverify-0.1.0.dist-info/top_level.txt,sha256=NbSy2myuT-0wT98BIYVvvAWB7Bb6RG9mgo2Y2qGouJw,11
|
|
43
|
+
physverify-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ram Chandra Giri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
physverify
|