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.
Files changed (43) hide show
  1. physverify/__init__.py +26 -0
  2. physverify/__main__.py +3 -0
  3. physverify/adapters/__init__.py +14 -0
  4. physverify/adapters/double_pendulum.py +55 -0
  5. physverify/adapters/lorenz.py +152 -0
  6. physverify/adapters/projectile_v1.py +92 -0
  7. physverify/adapters/projectile_v2.py +94 -0
  8. physverify/adapters/three_body.py +283 -0
  9. physverify/agent.py +265 -0
  10. physverify/checks.py +59 -0
  11. physverify/cli.py +105 -0
  12. physverify/compare.py +104 -0
  13. physverify/loaders.py +49 -0
  14. physverify/matrix.py +160 -0
  15. physverify/mms.py +118 -0
  16. physverify/order.py +131 -0
  17. physverify/problems/__init__.py +6 -0
  18. physverify/problems/_common.py +89 -0
  19. physverify/problems/heat1d.py +217 -0
  20. physverify/problems/kepler.py +176 -0
  21. physverify/problems/pendulum.py +201 -0
  22. physverify/problems/projectile.py +240 -0
  23. physverify/problems/sod.py +291 -0
  24. physverify/pytest_plugin.py +75 -0
  25. physverify/report.py +189 -0
  26. physverify/steppers.py +38 -0
  27. physverify/stochastic/__init__.py +1 -0
  28. physverify/stochastic/calibrate.py +202 -0
  29. physverify/stochastic/checks.py +378 -0
  30. physverify/stochastic/exact.py +205 -0
  31. physverify/stochastic/ising.py +170 -0
  32. physverify/stochastic/matrix.py +151 -0
  33. physverify/stochastic/reference.py +67 -0
  34. physverify/stochastic/sampler.py +253 -0
  35. physverify/stochastic/stats.py +137 -0
  36. physverify/studies.py +146 -0
  37. physverify/symbolic.py +89 -0
  38. physverify-0.1.0.dist-info/METADATA +122 -0
  39. physverify-0.1.0.dist-info/RECORD +43 -0
  40. physverify-0.1.0.dist-info/WHEEL +5 -0
  41. physverify-0.1.0.dist-info/entry_points.txt +5 -0
  42. physverify-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. 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
+ ]