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,89 @@
|
|
|
1
|
+
"""Pieces shared by the seeded-bug problems."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..steppers import euler, rk4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class BugInfo:
|
|
14
|
+
description: str # what is changed in the code
|
|
15
|
+
# Families of check that should flag this bug, from reasoning about what
|
|
16
|
+
# each check can see. An empty set documents a deliberate blind spot.
|
|
17
|
+
expected: frozenset
|
|
18
|
+
symptom: str = "" # what the error should look like, written before running the checks
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def families(*names: str) -> frozenset:
|
|
22
|
+
return frozenset(names)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Stepper bugs apply to every ODE problem.
|
|
26
|
+
STEPPER_BUGS = {
|
|
27
|
+
"euler_instead_of_rk4": BugInfo(
|
|
28
|
+
"stepper silently falls back to forward Euler while claiming RK4",
|
|
29
|
+
families("mms", "reference", "invariant"),
|
|
30
|
+
"order 1 instead of 4. Euler is consistent, so the linear patch solution is still exact",
|
|
31
|
+
),
|
|
32
|
+
"rk4_weights": BugInfo(
|
|
33
|
+
"RK4 final combination uses weights 1,1,1,1 instead of 1,2,2,1 (inconsistent scheme)",
|
|
34
|
+
families("mms", "reference", "invariant", "patch"),
|
|
35
|
+
"weights sum to 2/3, so the step is too short: the error does not shrink",
|
|
36
|
+
),
|
|
37
|
+
"rk4_full_step_stage2": BugInfo(
|
|
38
|
+
"RK4 stage 2 uses a full step instead of a half step",
|
|
39
|
+
families("mms", "reference", "patch"),
|
|
40
|
+
"order drops (to 2 here); the linear patch solution is not reproduced",
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def rk4_with_bugs(f, t, y, h, bugs):
|
|
46
|
+
if "euler_instead_of_rk4" in bugs:
|
|
47
|
+
return euler(f, t, y, h)
|
|
48
|
+
k1 = f(t, y)
|
|
49
|
+
if "rk4_full_step_stage2" in bugs:
|
|
50
|
+
k2 = f(t + 0.5 * h, y + h * k1)
|
|
51
|
+
else:
|
|
52
|
+
k2 = f(t + 0.5 * h, y + 0.5 * h * k1)
|
|
53
|
+
k3 = f(t + 0.5 * h, y + 0.5 * h * k2)
|
|
54
|
+
t4 = t + 0.5 * h if "rk4_stage4_time" in bugs else t + h
|
|
55
|
+
k4 = f(t4, y + h * k3)
|
|
56
|
+
if "rk4_weights" in bugs:
|
|
57
|
+
return y + (h / 6.0) * (k1 + k2 + k3 + k4)
|
|
58
|
+
return y + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_integrate(rhs, bugs, method="rk4", trajectory=False):
|
|
62
|
+
"""Build an `integrate(source, y0, t_end, n)` for a solver with an optional source hook.
|
|
63
|
+
|
|
64
|
+
With trajectory=True it returns all n + 1 states instead of the last one.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def integrate(source, y0, t_end, n):
|
|
68
|
+
h = t_end / n
|
|
69
|
+
y = np.array(y0, dtype=float)
|
|
70
|
+
states = [y]
|
|
71
|
+
|
|
72
|
+
def forced(t, state):
|
|
73
|
+
return rhs(t, state) + source(t)
|
|
74
|
+
|
|
75
|
+
steps = n + 1 if (method == "euler" and "euler_extra_step" in bugs) else n
|
|
76
|
+
for i in range(steps):
|
|
77
|
+
t = i * h
|
|
78
|
+
if method == "rk4":
|
|
79
|
+
y = rk4_with_bugs(forced, t, y, h, bugs)
|
|
80
|
+
elif method == "euler":
|
|
81
|
+
y = euler(forced, t, y, h)
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(method)
|
|
84
|
+
states.append(y)
|
|
85
|
+
if trajectory:
|
|
86
|
+
return np.array(states)
|
|
87
|
+
return y
|
|
88
|
+
|
|
89
|
+
return integrate
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""1D heat equation u_t = alpha u_xx on [0, 1] with u = 0 at both ends, explicit (FTCS) scheme.
|
|
2
|
+
|
|
3
|
+
Time step and grid are refined in several ways, because each way can hide
|
|
4
|
+
different bugs:
|
|
5
|
+
|
|
6
|
+
coupled dt = r dx^2 / alpha with r = 0.25 (the usual stable choice); the
|
|
7
|
+
expected order in dx is 2 (second order in space, first in time).
|
|
8
|
+
time-only grid fixed, dt refined. The manufactured solution is cubic in x,
|
|
9
|
+
which the three-point stencil differentiates exactly, so the grid
|
|
10
|
+
adds no error of its own. Expected order 1. (To leading order: the
|
|
11
|
+
time-stepping error is itself smoothed by the discrete Laplacian,
|
|
12
|
+
a cross term of order dt * dx^2.)
|
|
13
|
+
space-only the manufactured solution is linear in t, which forward Euler
|
|
14
|
+
integrates exactly, so the time stepping adds no error of its own.
|
|
15
|
+
Expected order 2, with the same kind of small cross term.
|
|
16
|
+
patch cubic in x and linear in t: FTCS must reproduce it to round-off
|
|
17
|
+
for any dx and dt. Any error at all is a bug.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from ..checks import Check
|
|
27
|
+
from ..order import check_exact, check_order
|
|
28
|
+
from ..studies import close_result
|
|
29
|
+
from ._common import BugInfo, families
|
|
30
|
+
|
|
31
|
+
NAME = "heat1d"
|
|
32
|
+
ALPHA = 0.7
|
|
33
|
+
T_END = 0.1
|
|
34
|
+
R = 0.25
|
|
35
|
+
LEVELS = (9, 17, 33, 65, 129) # nodes; dx = 1 / (N - 1)
|
|
36
|
+
TIME_ONLY_NODES = 33
|
|
37
|
+
TIME_ONLY_STEPS = (160, 320, 640, 1280, 2560) # r = alpha dt / dx^2 from 0.45 down to 0.03
|
|
38
|
+
PATCH_NODES = (9, 17, 33)
|
|
39
|
+
|
|
40
|
+
BUGS = {
|
|
41
|
+
"dx_off_by_one": BugInfo(
|
|
42
|
+
"grid spacing taken as 1/N instead of 1/(N-1) for N nodes",
|
|
43
|
+
families("mms", "reference", "patch"),
|
|
44
|
+
"the grid is stretched by N/(N-1); error plateaus",
|
|
45
|
+
),
|
|
46
|
+
"alpha_ignored": BugInfo(
|
|
47
|
+
"diffusivity left out of the diffusion term (alpha = 1 used)",
|
|
48
|
+
families("mms", "reference", "patch"),
|
|
49
|
+
"solution decays at the wrong rate; error plateaus",
|
|
50
|
+
),
|
|
51
|
+
"extra_time_step": BugInfo(
|
|
52
|
+
"one more time step than needed",
|
|
53
|
+
families("patch"),
|
|
54
|
+
"an O(dt) error, the same order as FTCS's own, so no convergence-order check can tell it "
|
|
55
|
+
"from truncation error",
|
|
56
|
+
),
|
|
57
|
+
"unstable_dt": BugInfo(
|
|
58
|
+
"time step uses r = 0.6, beyond the FTCS stability limit of 0.5",
|
|
59
|
+
families("mms", "reference", "patch"),
|
|
60
|
+
"the finest grids blow up (non-finite or huge errors); the patch check's coupled runs use the same dt",
|
|
61
|
+
),
|
|
62
|
+
"source_time_shift": BugInfo(
|
|
63
|
+
"the source term is evaluated at the end of the step, t + dt, instead of t",
|
|
64
|
+
families("patch"),
|
|
65
|
+
"an O(dt) error like FTCS's own, so the order studies pass; the exact patch solution is lost",
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Sources are differentiated by hand; the tests check them against finite differences.
|
|
71
|
+
# Coupled and space-only studies use smooth sine modes; time-only and patch use cubics in x.
|
|
72
|
+
def _sines(x):
|
|
73
|
+
return np.sin(math.pi * x) + 0.5 * np.sin(3.0 * math.pi * x)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _sines_xx(x):
|
|
77
|
+
return -(math.pi**2) * np.sin(math.pi * x) - 0.5 * (3.0 * math.pi) ** 2 * np.sin(3.0 * math.pi * x)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cubic(x):
|
|
81
|
+
return x - x**3 # zero at x = 0 and x = 1; second derivative -6x
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def u_manufactured(x, t):
|
|
85
|
+
return np.exp(-t) * _sines(x)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def source_manufactured(x, t, alpha=ALPHA):
|
|
89
|
+
"""u_t - alpha u_xx for u_manufactured."""
|
|
90
|
+
return np.exp(-t) * (-_sines(x) - alpha * _sines_xx(x))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def u_time_only(x, t):
|
|
94
|
+
return np.exp(-t) * _cubic(x)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def source_time_only(x, t, alpha=ALPHA):
|
|
98
|
+
return np.exp(-t) * (-_cubic(x) + 6.0 * alpha * x)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def u_space_only(x, t):
|
|
102
|
+
return (1.0 + t) * _sines(x)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def source_space_only(x, t, alpha=ALPHA):
|
|
106
|
+
return _sines(x) - alpha * (1.0 + t) * _sines_xx(x)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def u_patch(x, t):
|
|
110
|
+
return (1.0 + t) * _cubic(x)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def source_patch(x, t, alpha=ALPHA):
|
|
114
|
+
return _cubic(x) + 6.0 * alpha * (1.0 + t) * x
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def steps_for_coupled_dt(n_nodes, r=R):
|
|
118
|
+
dx = 1.0 / (n_nodes - 1)
|
|
119
|
+
return math.ceil(T_END / (r * dx**2 / ALPHA))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def solve(n_nodes, bugs, u0, source=None, n_steps=None):
|
|
123
|
+
"""FTCS to T_END; returns the nodal values at the end.
|
|
124
|
+
|
|
125
|
+
With n_steps=None the time step follows the grid (the coupled choice).
|
|
126
|
+
Passing n_steps fixes dt = T_END / n_steps independently of the grid.
|
|
127
|
+
"""
|
|
128
|
+
x = np.linspace(0.0, 1.0, n_nodes)
|
|
129
|
+
dx_nominal = 1.0 / (n_nodes - 1)
|
|
130
|
+
if n_steps is None:
|
|
131
|
+
r_used = 0.6 if "unstable_dt" in bugs else R
|
|
132
|
+
n_steps = math.ceil(T_END / (r_used * dx_nominal**2 / ALPHA))
|
|
133
|
+
dt = T_END / n_steps
|
|
134
|
+
if "extra_time_step" in bugs:
|
|
135
|
+
n_steps += 1
|
|
136
|
+
dx = 1.0 / n_nodes if "dx_off_by_one" in bugs else dx_nominal
|
|
137
|
+
alpha_used = 1.0 if "alpha_ignored" in bugs else ALPHA
|
|
138
|
+
r = alpha_used * dt / dx**2
|
|
139
|
+
u = u0(x).copy()
|
|
140
|
+
with np.errstate(all="ignore"):
|
|
141
|
+
for i in range(n_steps):
|
|
142
|
+
lap = u[2:] - 2.0 * u[1:-1] + u[:-2]
|
|
143
|
+
new = u.copy()
|
|
144
|
+
new[1:-1] = u[1:-1] + r * lap
|
|
145
|
+
if source is not None:
|
|
146
|
+
t_source = (i + 1) * dt if "source_time_shift" in bugs else i * dt
|
|
147
|
+
new[1:-1] += dt * source(x[1:-1], t_source)
|
|
148
|
+
new[0] = new[-1] = 0.0
|
|
149
|
+
u = new
|
|
150
|
+
return x, u
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _error(u_fun, source, n_nodes, bugs, n_steps=None):
|
|
154
|
+
x, u = solve(n_nodes, bugs, lambda xx: u_fun(xx, 0.0), source=source, n_steps=n_steps)
|
|
155
|
+
with np.errstate(all="ignore"):
|
|
156
|
+
return float(np.max(np.abs(u - u_fun(x, T_END))))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def checks(bugs=frozenset()) -> list[Check]:
|
|
160
|
+
def run_coupled():
|
|
161
|
+
hs = [1.0 / (n - 1) for n in LEVELS]
|
|
162
|
+
errs = [_error(u_manufactured, source_manufactured, n, bugs) for n in LEVELS]
|
|
163
|
+
return check_order(hs, errs, 2.0)
|
|
164
|
+
|
|
165
|
+
def run_time_only():
|
|
166
|
+
hs = [T_END / m for m in TIME_ONLY_STEPS]
|
|
167
|
+
errs = [_error(u_time_only, source_time_only, TIME_ONLY_NODES, bugs, n_steps=m) for m in TIME_ONLY_STEPS]
|
|
168
|
+
return check_order(hs, errs, 1.0)
|
|
169
|
+
|
|
170
|
+
def run_space_only():
|
|
171
|
+
hs = [1.0 / (n - 1) for n in LEVELS]
|
|
172
|
+
errs = [_error(u_space_only, source_space_only, n, bugs) for n in LEVELS]
|
|
173
|
+
return check_order(hs, errs, 2.0)
|
|
174
|
+
|
|
175
|
+
def run_reference():
|
|
176
|
+
hs, errs = [], []
|
|
177
|
+
for n in LEVELS:
|
|
178
|
+
x, u = solve(n, bugs, lambda xx: np.sin(math.pi * xx))
|
|
179
|
+
exact = np.sin(math.pi * x) * math.exp(-ALPHA * math.pi**2 * T_END)
|
|
180
|
+
hs.append(1.0 / (n - 1))
|
|
181
|
+
with np.errstate(all="ignore"):
|
|
182
|
+
errs.append(float(np.max(np.abs(u - exact))))
|
|
183
|
+
return check_order(hs, errs, 2.0)
|
|
184
|
+
|
|
185
|
+
def run_patch():
|
|
186
|
+
# The grid and the time step are varied separately: the coupled choice on three grids,
|
|
187
|
+
# then three time steps on one grid.
|
|
188
|
+
errs = [_error(u_patch, source_patch, n, bugs) for n in PATCH_NODES]
|
|
189
|
+
hs = [T_END / steps_for_coupled_dt(n) for n in PATCH_NODES]
|
|
190
|
+
base = steps_for_coupled_dt(17)
|
|
191
|
+
for factor in (1, 2, 4):
|
|
192
|
+
errs.append(_error(u_patch, source_patch, 17, bugs, n_steps=factor * base))
|
|
193
|
+
hs.append(T_END / (factor * base))
|
|
194
|
+
return check_exact(hs, errs)
|
|
195
|
+
|
|
196
|
+
return [
|
|
197
|
+
Check("mms", "FTCS coupled dt ~ dx^2", run_coupled),
|
|
198
|
+
Check("mms", "FTCS time-only refinement", run_time_only),
|
|
199
|
+
Check("mms", "FTCS space-only refinement", run_space_only),
|
|
200
|
+
Check("reference", "FTCS eigenmode decay", run_reference),
|
|
201
|
+
Check("patch", "FTCS cubic in x, linear in t", run_patch),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
SMOKE_NODES = 33
|
|
206
|
+
SMOKE_RTOL = 1e-2 # FTCS on 33 nodes is accurate to a few parts in a thousand here
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def smoke_checks(bugs=frozenset()) -> list[Check]:
|
|
210
|
+
"""A plain unit test: one grid, the decaying sine mode, within 1%."""
|
|
211
|
+
|
|
212
|
+
def run():
|
|
213
|
+
x, u = solve(SMOKE_NODES, bugs, lambda xx: np.sin(math.pi * xx))
|
|
214
|
+
exact = np.sin(math.pi * x) * math.exp(-ALPHA * math.pi**2 * T_END)
|
|
215
|
+
return close_result(u, exact, SMOKE_RTOL)
|
|
216
|
+
|
|
217
|
+
return [Check("smoke", "eigenmode decay on 33 nodes", run)]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Kepler problem: a body in the inverse-square field of a fixed mass. State: [x, y, vx, vy].
|
|
2
|
+
|
|
3
|
+
Two invariants (energy and angular momentum) and a limit (Kepler's third law:
|
|
4
|
+
after one period the orbit closes) give checks that tell bugs apart. A force
|
|
5
|
+
that is central but not inverse-square keeps angular momentum and loses energy;
|
|
6
|
+
a force that is not central loses both.
|
|
7
|
+
|
|
8
|
+
The reference operator uses the vector form -GM r / |r|^3; the solver below
|
|
9
|
+
writes it as a magnitude times a unit vector, as many codes do.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from ..checks import Check
|
|
19
|
+
from ..mms import ManufacturedODE
|
|
20
|
+
from ..order import check_order
|
|
21
|
+
from ..studies import close_result, invariant_study, ode_checks, smoke_check
|
|
22
|
+
from ._common import STEPPER_BUGS, BugInfo, families, make_integrate
|
|
23
|
+
|
|
24
|
+
NAME = "kepler"
|
|
25
|
+
GM = 1.0
|
|
26
|
+
ECC = 0.5 # orbit for the free runs: semi-major axis 1, released at periapsis
|
|
27
|
+
SOFTENING = 1e-3
|
|
28
|
+
|
|
29
|
+
BUGS = {
|
|
30
|
+
"inverse_r_force": BugInfo(
|
|
31
|
+
"force magnitude GM/r instead of GM/r^2",
|
|
32
|
+
families("mms", "reference", "invariant", "limit", "patch"),
|
|
33
|
+
"wrong orbit; energy drifts, angular momentum is still conserved (the force is still central)",
|
|
34
|
+
),
|
|
35
|
+
"y_force_sign": BugInfo(
|
|
36
|
+
"the y component of the force has the wrong sign",
|
|
37
|
+
families("mms", "reference", "invariant", "limit", "patch"),
|
|
38
|
+
"force no longer central: energy and angular momentum both drift",
|
|
39
|
+
),
|
|
40
|
+
"gm_one_percent": BugInfo(
|
|
41
|
+
"GM is 1% too large in the force (for example a unit-conversion constant)",
|
|
42
|
+
families("mms", "reference", "invariant", "limit", "patch"),
|
|
43
|
+
"error plateaus; the energy computed with the true GM drifts; the orbit does not close",
|
|
44
|
+
),
|
|
45
|
+
"softening_left_on": BugInfo(
|
|
46
|
+
f"gravitational softening r^2 + eps^2 with eps = {SOFTENING:g} left on",
|
|
47
|
+
families("mms", "reference", "patch"),
|
|
48
|
+
"a small plateau (relative size about eps^2 / r^2 = 1e-6); visible only once the "
|
|
49
|
+
"truncation error is smaller than that",
|
|
50
|
+
),
|
|
51
|
+
**STEPPER_BUGS,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def rhs_reference(gm=GM):
|
|
56
|
+
def f(t, z):
|
|
57
|
+
x, y, vx, vy = z
|
|
58
|
+
r3 = (x * x + y * y) ** 1.5
|
|
59
|
+
return np.array([vx, vy, -gm * x / r3, -gm * y / r3])
|
|
60
|
+
|
|
61
|
+
return f
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def rhs_seeded(bugs, gm=GM):
|
|
65
|
+
gm_used = gm * 1.01 if "gm_one_percent" in bugs else gm
|
|
66
|
+
eps2 = SOFTENING**2 if "softening_left_on" in bugs else 0.0
|
|
67
|
+
|
|
68
|
+
def f(t, z):
|
|
69
|
+
x, y, vx, vy = z
|
|
70
|
+
r = math.sqrt(x * x + y * y + eps2)
|
|
71
|
+
magnitude = gm_used / r if "inverse_r_force" in bugs else gm_used / (r * r)
|
|
72
|
+
ux, uy = x / r, y / r
|
|
73
|
+
ay = magnitude * uy if "y_force_sign" in bugs else -magnitude * uy
|
|
74
|
+
return np.array([vx, vy, -magnitude * ux, ay])
|
|
75
|
+
|
|
76
|
+
return f
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def energy(z, gm=GM):
|
|
80
|
+
x, y, vx, vy = z
|
|
81
|
+
return 0.5 * (vx * vx + vy * vy) - gm / math.sqrt(x * x + y * y)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def angular_momentum(z):
|
|
85
|
+
x, y, vx, vy = z
|
|
86
|
+
return x * vy - y * vx
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def periapsis_state(ecc=ECC, a=1.0, gm=GM):
|
|
90
|
+
rp = a * (1.0 - ecc)
|
|
91
|
+
return np.array([rp, 0.0, 0.0, math.sqrt(gm * (1.0 + ecc) / rp)])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def period(a=1.0, gm=GM):
|
|
95
|
+
return 2.0 * math.pi * math.sqrt(a**3 / gm)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def manufactured() -> ManufacturedODE:
|
|
99
|
+
"""A smooth path that stays between r = 0.7 and r = 1.3, away from the singularity."""
|
|
100
|
+
|
|
101
|
+
def u(t):
|
|
102
|
+
return np.array(
|
|
103
|
+
[
|
|
104
|
+
np.cos(t) + 0.2 * np.sin(2.0 * t),
|
|
105
|
+
0.9 * np.sin(t),
|
|
106
|
+
-np.sin(t) + 0.3 * np.cos(1.5 * t),
|
|
107
|
+
0.8 * np.cos(t) + 0.1 * t,
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return ManufacturedODE(u, rhs_reference())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
MMS_T_END = 1.0
|
|
115
|
+
NS_RK4 = (20, 40, 80, 160, 320)
|
|
116
|
+
Y0_FREE = periapsis_state()
|
|
117
|
+
PATCH = (np.array([1.0, 0.5, 0.2, -0.3]), np.array([0.3, 0.4, -0.2, 0.1]))
|
|
118
|
+
ORBIT_NS = (100, 200, 400, 800, 1600) # steps per period for the closure check
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def closure_check(integrate, label="orbit closes after one period") -> Check:
|
|
122
|
+
"""Kepler's third law: after T = 2 pi sqrt(a^3 / GM) the state returns to the start."""
|
|
123
|
+
|
|
124
|
+
def run():
|
|
125
|
+
y0, t = periapsis_state(), period()
|
|
126
|
+
hs = [t / n for n in ORBIT_NS]
|
|
127
|
+
errs = [float(np.max(np.abs(integrate(lambda s: 0.0, y0, t, n) - y0))) for n in ORBIT_NS]
|
|
128
|
+
return check_order(hs, errs, 4.0, scale=float(np.max(np.abs(y0))))
|
|
129
|
+
|
|
130
|
+
return Check("limit", label, run)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def checks(bugs=frozenset()) -> list[Check]:
|
|
134
|
+
rhs = rhs_seeded(bugs)
|
|
135
|
+
integrate = make_integrate(rhs, bugs, "rk4")
|
|
136
|
+
out = ode_checks(
|
|
137
|
+
"rk4",
|
|
138
|
+
integrate,
|
|
139
|
+
rhs_ref=rhs_reference(),
|
|
140
|
+
mms=manufactured(),
|
|
141
|
+
y0_free=Y0_FREE,
|
|
142
|
+
t_end=MMS_T_END,
|
|
143
|
+
ns=NS_RK4,
|
|
144
|
+
order=4.0,
|
|
145
|
+
quantity=energy,
|
|
146
|
+
patch=PATCH,
|
|
147
|
+
integrate_trajectory=make_integrate(rhs, bugs, "rk4", trajectory=True),
|
|
148
|
+
)
|
|
149
|
+
out.append(
|
|
150
|
+
Check(
|
|
151
|
+
"invariant",
|
|
152
|
+
"rk4 angular momentum",
|
|
153
|
+
lambda: invariant_study(integrate, angular_momentum, Y0_FREE, MMS_T_END, NS_RK4, 4.0),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
out.append(closure_check(integrate))
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
SMOKE_N = 200
|
|
161
|
+
SMOKE_RTOL = 1e-3
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def smoke_checks(bugs=frozenset()) -> list[Check]:
|
|
165
|
+
"""Plain tests: one run against a reference, energy conserved, each within 0.1%."""
|
|
166
|
+
rhs = rhs_seeded(bugs)
|
|
167
|
+
integrate = make_integrate(rhs, bugs, "rk4")
|
|
168
|
+
|
|
169
|
+
def run_energy():
|
|
170
|
+
y = integrate(lambda t: 0.0, Y0_FREE, MMS_T_END, SMOKE_N)
|
|
171
|
+
return close_result(energy(y), energy(Y0_FREE), SMOKE_RTOL)
|
|
172
|
+
|
|
173
|
+
return [
|
|
174
|
+
smoke_check("rk4", integrate, rhs_reference(), Y0_FREE, MMS_T_END, SMOKE_N, rtol=SMOKE_RTOL),
|
|
175
|
+
Check("smoke", "energy conserved", run_energy),
|
|
176
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Planar double pendulum. State: [theta1, theta2, omega1, omega2], angles from the downward vertical.
|
|
2
|
+
|
|
3
|
+
The reference operator uses the explicit closed-form accelerations (no matrix
|
|
4
|
+
solve) so that it is written differently from a typical mass-matrix solver.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..checks import FAIL, PASS, Check, Result
|
|
14
|
+
from ..mms import ManufacturedODE
|
|
15
|
+
from ..studies import close_result, ode_checks, smoke_check
|
|
16
|
+
from ._common import STEPPER_BUGS, BugInfo, families, make_integrate
|
|
17
|
+
|
|
18
|
+
NAME = "pendulum"
|
|
19
|
+
PARAMS = dict(m1=1.0, m2=0.8, L1=1.0, L2=0.7, g=9.81)
|
|
20
|
+
|
|
21
|
+
BUGS = {
|
|
22
|
+
"coupling_sign": BugInfo(
|
|
23
|
+
"centripetal coupling term of the first equation has the wrong sign",
|
|
24
|
+
families("mms", "reference", "invariant", "patch"),
|
|
25
|
+
"error plateaus; energy not conserved",
|
|
26
|
+
),
|
|
27
|
+
"mass_matrix_missing_cos": BugInfo(
|
|
28
|
+
"mass matrix entry m2*L2*cos(theta1-theta2) is missing its cosine",
|
|
29
|
+
families("mms", "reference", "invariant", "patch"),
|
|
30
|
+
"error plateaus; energy not conserved",
|
|
31
|
+
),
|
|
32
|
+
"gravity_mass_missing": BugInfo(
|
|
33
|
+
"gravity on the first equation uses m1 instead of m1+m2",
|
|
34
|
+
families("mms", "reference", "invariant", "limit", "patch"),
|
|
35
|
+
"error plateaus; energy not conserved; normal-mode frequencies wrong",
|
|
36
|
+
),
|
|
37
|
+
"rk4_stage4_time": BugInfo(
|
|
38
|
+
"RK4 evaluates its fourth stage at t + h/2 instead of t + h",
|
|
39
|
+
families("mms", "patch"),
|
|
40
|
+
"visible only when the right-hand side depends on time: the free pendulum is autonomous, "
|
|
41
|
+
"so the reference and invariant checks cannot see it; the forced (MMS) runs drop to order 1",
|
|
42
|
+
),
|
|
43
|
+
"swapped_lengths": BugInfo(
|
|
44
|
+
"second equation uses L2 where L1 belongs in the omega1^2 term",
|
|
45
|
+
families("mms", "reference", "invariant", "patch"),
|
|
46
|
+
"error plateaus; energy not conserved",
|
|
47
|
+
),
|
|
48
|
+
"small_angle_gravity": BugInfo(
|
|
49
|
+
"gravity on the second pendulum uses theta2 instead of sin(theta2)",
|
|
50
|
+
families("mms", "reference", "invariant", "patch"),
|
|
51
|
+
"error plateaus at large angles; small-oscillation frequencies unchanged",
|
|
52
|
+
),
|
|
53
|
+
**STEPPER_BUGS,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rhs_reference(p=PARAMS):
|
|
58
|
+
m1, m2, L1, L2, g = p["m1"], p["m2"], p["L1"], p["L2"], p["g"]
|
|
59
|
+
|
|
60
|
+
def f(t, y):
|
|
61
|
+
t1, t2, w1, w2 = y
|
|
62
|
+
d = t1 - t2
|
|
63
|
+
den = 2.0 * m1 + m2 - m2 * math.cos(2.0 * d)
|
|
64
|
+
a1 = (
|
|
65
|
+
-g * (2.0 * m1 + m2) * math.sin(t1)
|
|
66
|
+
- m2 * g * math.sin(t1 - 2.0 * t2)
|
|
67
|
+
- 2.0 * math.sin(d) * m2 * (w2 * w2 * L2 + w1 * w1 * L1 * math.cos(d))
|
|
68
|
+
) / (L1 * den)
|
|
69
|
+
a2 = (
|
|
70
|
+
2.0
|
|
71
|
+
* math.sin(d)
|
|
72
|
+
* (w1 * w1 * L1 * (m1 + m2) + g * (m1 + m2) * math.cos(t1) + w2 * w2 * L2 * m2 * math.cos(d))
|
|
73
|
+
) / (L2 * den)
|
|
74
|
+
return np.array([w1, w2, a1, a2])
|
|
75
|
+
|
|
76
|
+
return f
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def energy_reference(y, p=PARAMS):
|
|
80
|
+
m1, m2, L1, L2, g = p["m1"], p["m2"], p["L1"], p["L2"], p["g"]
|
|
81
|
+
t1, t2, w1, w2 = y
|
|
82
|
+
kinetic = 0.5 * m1 * (L1 * w1) ** 2 + 0.5 * m2 * (
|
|
83
|
+
(L1 * w1) ** 2 + (L2 * w2) ** 2 + 2.0 * L1 * L2 * w1 * w2 * math.cos(t1 - t2)
|
|
84
|
+
)
|
|
85
|
+
potential = -(m1 + m2) * g * L1 * math.cos(t1) - m2 * g * L2 * math.cos(t2)
|
|
86
|
+
return kinetic + potential
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def rhs_seeded(bugs, p=PARAMS):
|
|
90
|
+
"""Mass-matrix form M(theta) alpha = f(theta, omega), with optional bugs."""
|
|
91
|
+
m1, m2, L1, L2, g = p["m1"], p["m2"], p["L1"], p["L2"], p["g"]
|
|
92
|
+
|
|
93
|
+
def f(t, y):
|
|
94
|
+
t1, t2, w1, w2 = y
|
|
95
|
+
d = t1 - t2
|
|
96
|
+
c = 1.0 if "mass_matrix_missing_cos" in bugs else math.cos(d)
|
|
97
|
+
mass = np.array([[(m1 + m2) * L1, m2 * L2 * c], [L1 * math.cos(d), L2]])
|
|
98
|
+
sign = 1.0 if "coupling_sign" in bugs else -1.0
|
|
99
|
+
gravity_mass = m1 if "gravity_mass_missing" in bugs else (m1 + m2)
|
|
100
|
+
L_w1 = L2 if "swapped_lengths" in bugs else L1
|
|
101
|
+
sin_t2 = t2 if "small_angle_gravity" in bugs else math.sin(t2)
|
|
102
|
+
force = np.array(
|
|
103
|
+
[
|
|
104
|
+
sign * m2 * L2 * w2 * w2 * math.sin(d) - gravity_mass * g * math.sin(t1),
|
|
105
|
+
L_w1 * w1 * w1 * math.sin(d) - g * sin_t2,
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
a1, a2 = np.linalg.solve(mass, force)
|
|
109
|
+
return np.array([w1, w2, a1, a2])
|
|
110
|
+
|
|
111
|
+
return f
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def manufactured() -> ManufacturedODE:
|
|
115
|
+
def u(t):
|
|
116
|
+
return np.array(
|
|
117
|
+
[
|
|
118
|
+
0.8 * np.sin(t),
|
|
119
|
+
-0.5 + 0.6 * np.cos(1.3 * t),
|
|
120
|
+
0.2 + 0.7 * np.cos(0.9 * t),
|
|
121
|
+
0.4 * np.sin(1.1 * t),
|
|
122
|
+
]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return ManufacturedODE(u, rhs_reference())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def small_oscillation_omegas(p=PARAMS):
|
|
129
|
+
"""Normal-mode angular frequencies about the hanging equilibrium (closed form)."""
|
|
130
|
+
m1, m2, L1, L2, g = p["m1"], p["m2"], p["L1"], p["L2"], p["g"]
|
|
131
|
+
s = (m1 + m2) * (L1 + L2)
|
|
132
|
+
disc = math.sqrt(s * s - 4.0 * m1 * (m1 + m2) * L1 * L2)
|
|
133
|
+
w_sq = [g * (s - disc) / (2.0 * m1 * L1 * L2), g * (s + disc) / (2.0 * m1 * L1 * L2)]
|
|
134
|
+
return [math.sqrt(x) for x in w_sq]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def linearized_omegas(rhs, eps=1e-6):
|
|
138
|
+
"""Angular frequencies from a finite-difference Jacobian of `rhs` at rest at the bottom."""
|
|
139
|
+
jac = np.zeros((4, 4))
|
|
140
|
+
for j in range(4):
|
|
141
|
+
step = np.zeros(4)
|
|
142
|
+
step[j] = eps
|
|
143
|
+
jac[:, j] = (np.asarray(rhs(0.0, step), float) - np.asarray(rhs(0.0, -step), float)) / (2 * eps)
|
|
144
|
+
eig = np.linalg.eigvals(jac)
|
|
145
|
+
return sorted(abs(eig.imag))[::2], max(abs(eig.real))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def frequency_check(rhs, label="small-oscillation frequencies", tol=1e-5) -> Check:
|
|
149
|
+
def run():
|
|
150
|
+
omegas, growth = linearized_omegas(rhs)
|
|
151
|
+
expected = small_oscillation_omegas()
|
|
152
|
+
if growth > 1e-6:
|
|
153
|
+
return Result(FAIL, f"equilibrium is not neutrally stable (growth rate {growth:.3g})")
|
|
154
|
+
err = max(abs(a - b) / b for a, b in zip(omegas, expected))
|
|
155
|
+
if err > tol:
|
|
156
|
+
return Result(FAIL, f"frequencies {omegas} differ from {expected} (relative error {err:.2g})")
|
|
157
|
+
return Result(PASS, f"frequencies match the closed form (relative error {err:.1e})")
|
|
158
|
+
|
|
159
|
+
return Check("limit", label, run)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
MMS_T_END = 1.0
|
|
163
|
+
NS_RK4 = (10, 20, 40, 80, 160)
|
|
164
|
+
Y0_FREE = np.array([0.4, 0.3, 0.0, 0.0]) # moderate amplitude, released from rest
|
|
165
|
+
PATCH = (np.array([0.3, -0.2, 0.5, -0.4]), np.array([0.5, -0.4, 0.3, 0.6]))
|
|
166
|
+
SMOKE_N = 80
|
|
167
|
+
SMOKE_RTOL = 1e-3
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def checks(bugs=frozenset()) -> list[Check]:
|
|
171
|
+
rhs = rhs_seeded(bugs)
|
|
172
|
+
out = ode_checks(
|
|
173
|
+
"rk4",
|
|
174
|
+
make_integrate(rhs, bugs, "rk4"),
|
|
175
|
+
rhs_ref=rhs_reference(),
|
|
176
|
+
mms=manufactured(),
|
|
177
|
+
y0_free=Y0_FREE,
|
|
178
|
+
t_end=MMS_T_END,
|
|
179
|
+
ns=NS_RK4,
|
|
180
|
+
order=4.0,
|
|
181
|
+
quantity=energy_reference,
|
|
182
|
+
patch=PATCH,
|
|
183
|
+
integrate_trajectory=make_integrate(rhs, bugs, "rk4", trajectory=True),
|
|
184
|
+
)
|
|
185
|
+
out.append(frequency_check(rhs))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def smoke_checks(bugs=frozenset()) -> list[Check]:
|
|
190
|
+
"""Plain unit tests: one run against a reference, and energy conserved, each within 0.1%."""
|
|
191
|
+
rhs = rhs_seeded(bugs)
|
|
192
|
+
integrate = make_integrate(rhs, bugs, "rk4")
|
|
193
|
+
|
|
194
|
+
def run_energy():
|
|
195
|
+
y = integrate(lambda t: 0.0, Y0_FREE, MMS_T_END, SMOKE_N)
|
|
196
|
+
return close_result(energy_reference(y), energy_reference(Y0_FREE), SMOKE_RTOL)
|
|
197
|
+
|
|
198
|
+
return [
|
|
199
|
+
smoke_check("rk4", integrate, rhs_reference(), Y0_FREE, MMS_T_END, SMOKE_N, rtol=SMOKE_RTOL),
|
|
200
|
+
Check("smoke", "energy conserved", run_energy),
|
|
201
|
+
]
|