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,240 @@
|
|
|
1
|
+
"""Projectile with vacuum, linear and quadratic drag. State: [x, y, vx, vy].
|
|
2
|
+
|
|
3
|
+
The reference operator follows the equations in the projectile-simulation
|
|
4
|
+
README, written separately from the solver below. One parameter is taken from
|
|
5
|
+
the code rather than the README: the linear drag coefficient b = 0.01 N s/m
|
|
6
|
+
(the README says b = 6*pi*eta*r, which gives a much smaller value).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from ..checks import Check
|
|
16
|
+
from ..mms import ManufacturedODE
|
|
17
|
+
from ..order import check_order
|
|
18
|
+
from ..studies import close_result, ode_checks, smoke_check
|
|
19
|
+
from ._common import STEPPER_BUGS, BugInfo, families, make_integrate, rk4_with_bugs
|
|
20
|
+
|
|
21
|
+
NAME = "projectile"
|
|
22
|
+
PARAMS = dict(m=0.45, r=0.11, Cd=0.47, rho=1.225, g=9.81, b=0.01)
|
|
23
|
+
MODES = ("vacuum", "linear", "quadratic")
|
|
24
|
+
|
|
25
|
+
BUGS = {
|
|
26
|
+
"gravity_sign": BugInfo(
|
|
27
|
+
"gravity accelerates the projectile upward",
|
|
28
|
+
families("mms", "reference", "invariant", "limit", "patch"),
|
|
29
|
+
"error does not shrink; energy not conserved; range wrong or never lands",
|
|
30
|
+
),
|
|
31
|
+
"linear_drag_missing_mass": BugInfo(
|
|
32
|
+
"linear drag coefficient is b instead of b/m",
|
|
33
|
+
families("mms", "reference", "patch"),
|
|
34
|
+
"error plateaus in linear-drag mode only",
|
|
35
|
+
),
|
|
36
|
+
"quad_drag_factor_half": BugInfo(
|
|
37
|
+
"quadratic drag is missing the factor 1/2",
|
|
38
|
+
families("mms", "reference", "patch"),
|
|
39
|
+
"error plateaus in quadratic-drag mode only",
|
|
40
|
+
),
|
|
41
|
+
"quad_drag_speed_from_vx": BugInfo(
|
|
42
|
+
"speed in the quadratic drag uses |vx| instead of |v|",
|
|
43
|
+
families("mms", "reference", "patch"),
|
|
44
|
+
"error plateaus in quadratic-drag mode wherever vy is not zero",
|
|
45
|
+
),
|
|
46
|
+
"quad_drag_abs_vx": BugInfo(
|
|
47
|
+
"x drag uses |vx| instead of vx, which is wrong only when the projectile moves backwards",
|
|
48
|
+
families("mms", "patch"),
|
|
49
|
+
"error plateaus only on runs where vx < 0; a forward launch never shows it",
|
|
50
|
+
),
|
|
51
|
+
"landing_no_interp": BugInfo(
|
|
52
|
+
"range is read from the last step before landing, with no interpolation",
|
|
53
|
+
families("limit"),
|
|
54
|
+
"range converges at order 1, not 2; the integrator itself is fine",
|
|
55
|
+
),
|
|
56
|
+
"angle_not_converted": BugInfo(
|
|
57
|
+
"launch angle in degrees is passed to sin/cos unconverted",
|
|
58
|
+
families("limit"),
|
|
59
|
+
"range wrong by a fixed amount; only the launch set-up is affected",
|
|
60
|
+
),
|
|
61
|
+
"euler_extra_step": BugInfo(
|
|
62
|
+
"the Euler integrator takes one step more than asked for",
|
|
63
|
+
families("patch"),
|
|
64
|
+
"an O(h) error, the same order as Euler's own, so every order study passes",
|
|
65
|
+
),
|
|
66
|
+
**STEPPER_BUGS,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def rhs_reference(mode: str, p=PARAMS):
|
|
71
|
+
m, r, Cd, rho, g, b = p["m"], p["r"], p["Cd"], p["rho"], p["g"], p["b"]
|
|
72
|
+
area = math.pi * r * r
|
|
73
|
+
|
|
74
|
+
def f(t, z):
|
|
75
|
+
vx, vy = z[2], z[3]
|
|
76
|
+
ax, ay = 0.0, -g
|
|
77
|
+
if mode == "linear":
|
|
78
|
+
ax -= (b / m) * vx
|
|
79
|
+
ay -= (b / m) * vy
|
|
80
|
+
elif mode == "quadratic":
|
|
81
|
+
c = 0.5 * rho * Cd * area / m
|
|
82
|
+
speed = math.hypot(vx, vy)
|
|
83
|
+
ax -= c * speed * vx
|
|
84
|
+
ay -= c * speed * vy
|
|
85
|
+
return np.array([vx, vy, ax, ay])
|
|
86
|
+
|
|
87
|
+
return f
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def rhs_seeded(mode: str, bugs, p=PARAMS):
|
|
91
|
+
m, r, Cd, rho, g, b = p["m"], p["r"], p["Cd"], p["rho"], p["g"], p["b"]
|
|
92
|
+
area = math.pi * r * r
|
|
93
|
+
k_lin = b if "linear_drag_missing_mass" in bugs else b / m
|
|
94
|
+
k_quad = (rho * Cd * area / m) if "quad_drag_factor_half" in bugs else 0.5 * rho * Cd * area / m
|
|
95
|
+
gravity = g if "gravity_sign" in bugs else -g
|
|
96
|
+
|
|
97
|
+
def f(t, z):
|
|
98
|
+
vx, vy = z[2], z[3]
|
|
99
|
+
ax, ay = 0.0, gravity
|
|
100
|
+
if mode == "linear":
|
|
101
|
+
ax -= k_lin * vx
|
|
102
|
+
ay -= k_lin * vy
|
|
103
|
+
elif mode == "quadratic":
|
|
104
|
+
speed = abs(vx) if "quad_drag_speed_from_vx" in bugs else math.hypot(vx, vy)
|
|
105
|
+
vx_used = abs(vx) if "quad_drag_abs_vx" in bugs else vx
|
|
106
|
+
ax -= k_quad * speed * vx_used
|
|
107
|
+
ay -= k_quad * speed * vy
|
|
108
|
+
return np.array([vx, vy, ax, ay])
|
|
109
|
+
|
|
110
|
+
return f
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def energy_vacuum(z, p=PARAMS):
|
|
114
|
+
return 0.5 * p["m"] * (z[2] ** 2 + z[3] ** 2) + p["m"] * p["g"] * z[1]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def manufactured(mode: str) -> ManufacturedODE:
|
|
118
|
+
"""x_m(t) = t lets a code with no time argument see t through x (see adapters).
|
|
119
|
+
|
|
120
|
+
vx_m changes sign near t = 0.8 so that code paths for a projectile moving
|
|
121
|
+
backwards are exercised. y_m is above ground until t = 1.5, so a code that
|
|
122
|
+
runs until landing stops soon after the measurement time t = 1. Slow
|
|
123
|
+
time dependence matters: through the x-as-time trick an error in x changes
|
|
124
|
+
the forcing, and a fast-varying vx_m amplified Euler's O(h) error until the
|
|
125
|
+
run blew up.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def u(t):
|
|
129
|
+
return np.array(
|
|
130
|
+
[
|
|
131
|
+
t,
|
|
132
|
+
7.5 * t - 5.0 * t * t,
|
|
133
|
+
1.0 - 1.4 * t + 0.3 * np.sin(t),
|
|
134
|
+
7.5 - 10.0 * t + np.sin(2.0 * t),
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return ManufacturedODE(u, rhs_reference(mode))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
MMS_T_END = 1.0
|
|
142
|
+
NS_RK4 = (10, 20, 40, 80, 160)
|
|
143
|
+
NS_EULER = (40, 80, 160, 320, 640)
|
|
144
|
+
Y0_FREE = np.array([0.0, 0.0, 10.0, 12.0]) # moves forwards the whole time, lands after ~2.4 s
|
|
145
|
+
# Linear patch solution. It starts at the origin and has x = t, as the ProjectileV2 adapter
|
|
146
|
+
# needs; vx stays negative and vy changes over the run, so the drag terms are all exercised.
|
|
147
|
+
PATCH = (np.array([0.0, 0.0, -1.0, 2.0]), np.array([1.0, 3.0, 0.5, -1.0]))
|
|
148
|
+
SMOKE_N = 40 # a typical single-resolution test
|
|
149
|
+
SMOKE_RTOL = 1e-3
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def range_seeded(v0, angle_deg, dt, bugs, mode="vacuum"):
|
|
153
|
+
"""Horizontal range from a launch at the origin, integrated with RK4 until landing."""
|
|
154
|
+
rad = angle_deg if "angle_not_converted" in bugs else math.radians(angle_deg)
|
|
155
|
+
f = rhs_seeded(mode, bugs)
|
|
156
|
+
z = np.array([0.0, 0.0, v0 * math.cos(rad), v0 * math.sin(rad)])
|
|
157
|
+
t = 0.0
|
|
158
|
+
while t < 100.0: # a launch that has not landed after 100 s is not a ballistic flight
|
|
159
|
+
z_new = rk4_with_bugs(f, t, z, dt, bugs)
|
|
160
|
+
t += dt
|
|
161
|
+
if z_new[1] < 0.0:
|
|
162
|
+
if "landing_no_interp" in bugs:
|
|
163
|
+
return z[0]
|
|
164
|
+
frac = z[1] / (z[1] - z_new[1])
|
|
165
|
+
return z[0] + frac * (z_new[0] - z[0])
|
|
166
|
+
z = z_new
|
|
167
|
+
raise RuntimeError("projectile never landed within 100 s")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def range_vacuum_exact(v0, angle_deg, g=PARAMS["g"]):
|
|
171
|
+
return v0 * v0 * math.sin(2.0 * math.radians(angle_deg)) / g
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def range_dts(v0, angle_deg, counts=(10, 20, 40, 80, 160), g=PARAMS["g"]):
|
|
175
|
+
"""Step sizes that put the landing in the middle of a step.
|
|
176
|
+
|
|
177
|
+
With the landing at a fixed phase inside a step the interpolation error is a
|
|
178
|
+
smooth function of dt. Otherwise it jumps around and an order fit is
|
|
179
|
+
meaningless.
|
|
180
|
+
"""
|
|
181
|
+
flight_time = 2.0 * v0 * math.sin(math.radians(angle_deg)) / g
|
|
182
|
+
return [flight_time / (n + 0.5) for n in counts]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def range_check(range_fn, v0=30.0, angle_deg=40.0, label="vacuum/rk4 range") -> Check:
|
|
186
|
+
"""Vacuum range against v0^2 sin(2a)/g. Linear interpolation of the landing gives order 2."""
|
|
187
|
+
|
|
188
|
+
def run():
|
|
189
|
+
exact = range_vacuum_exact(v0, angle_deg)
|
|
190
|
+
dts = range_dts(v0, angle_deg)
|
|
191
|
+
errs = [abs(range_fn(v0, angle_deg, dt) - exact) for dt in dts]
|
|
192
|
+
return check_order(dts, errs, 2.0, floor=1e-12)
|
|
193
|
+
|
|
194
|
+
return Check("limit", label, run)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def checks(bugs=frozenset()) -> list[Check]:
|
|
198
|
+
out: list[Check] = []
|
|
199
|
+
for mode in MODES:
|
|
200
|
+
for method, ns, order in (("rk4", NS_RK4, 4.0), ("euler", NS_EULER, 1.0)):
|
|
201
|
+
if method == "euler" and "euler_instead_of_rk4" in bugs:
|
|
202
|
+
continue # the bug only replaces RK4
|
|
203
|
+
if method == "euler" and any(b in bugs for b in ("rk4_weights", "rk4_full_step_stage2")):
|
|
204
|
+
continue
|
|
205
|
+
integrate = make_integrate(rhs_seeded(mode, bugs), bugs, method)
|
|
206
|
+
trajectory = make_integrate(rhs_seeded(mode, bugs), bugs, method, trajectory=True) if method == "rk4" else None
|
|
207
|
+
out += ode_checks(
|
|
208
|
+
f"{mode}/{method}",
|
|
209
|
+
integrate,
|
|
210
|
+
rhs_ref=rhs_reference(mode),
|
|
211
|
+
mms=manufactured(mode),
|
|
212
|
+
y0_free=Y0_FREE,
|
|
213
|
+
t_end=MMS_T_END,
|
|
214
|
+
ns=ns,
|
|
215
|
+
order=order,
|
|
216
|
+
quantity=energy_vacuum if mode == "vacuum" else None,
|
|
217
|
+
patch=PATCH,
|
|
218
|
+
integrate_trajectory=trajectory,
|
|
219
|
+
)
|
|
220
|
+
out.append(range_check(lambda v0, a, dt: range_seeded(v0, a, dt, bugs)))
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def smoke_checks(bugs=frozenset()) -> list[Check]:
|
|
225
|
+
"""What plain unit tests would look like: one run per mode, one range, each within 0.1%."""
|
|
226
|
+
out = []
|
|
227
|
+
for mode in MODES:
|
|
228
|
+
for method in ("rk4", "euler"):
|
|
229
|
+
if method == "euler" and any(b in bugs for b in ("euler_instead_of_rk4", "rk4_weights", "rk4_full_step_stage2")):
|
|
230
|
+
continue
|
|
231
|
+
n = SMOKE_N if method == "rk4" else 16 * SMOKE_N # Euler needs many more steps for 0.1%
|
|
232
|
+
integrate = make_integrate(rhs_seeded(mode, bugs), bugs, method)
|
|
233
|
+
out.append(smoke_check(f"{mode}/{method}", integrate, rhs_reference(mode), Y0_FREE, MMS_T_END, n, rtol=SMOKE_RTOL))
|
|
234
|
+
|
|
235
|
+
def run_range():
|
|
236
|
+
v0, angle = 30.0, 40.0
|
|
237
|
+
return close_result(range_seeded(v0, angle, 0.01, bugs), range_vacuum_exact(v0, angle), SMOKE_RTOL)
|
|
238
|
+
|
|
239
|
+
out.append(Check("smoke", "vacuum range, dt = 0.01", run_range))
|
|
240
|
+
return out
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""1D Euler equations of gas dynamics: Sod's shock tube and a smooth density wave.
|
|
2
|
+
|
|
3
|
+
The solver is a first-order finite-volume scheme with the Rusanov (local
|
|
4
|
+
Lax-Friedrichs) flux, explicit Euler in time with CFL number 0.5, and copied
|
|
5
|
+
(transmissive) boundary cells or periodic boundaries.
|
|
6
|
+
|
|
7
|
+
Checks:
|
|
8
|
+
|
|
9
|
+
reference Sod's problem against the exact Riemann solution (Toro, ch. 4). With
|
|
10
|
+
shocks and a contact discontinuity a first-order scheme converges in
|
|
11
|
+
L1 at about order 1/2 to 1, so the expected order is 0.5 with a wide
|
|
12
|
+
band ([0.3, 1.0]).
|
|
13
|
+
reference a density wave carried at constant velocity and pressure, which is an
|
|
14
|
+
exact smooth solution: order 1 on a periodic domain.
|
|
15
|
+
invariant discrete conservation, at t = 0.05. Before any wave reaches the ends the fluxes
|
|
16
|
+
through the ends are known exactly (only the pressure term, p_L - p_R,
|
|
17
|
+
moves momentum), so the totals of mass, momentum and energy must match
|
|
18
|
+
to round-off. This is exact for any grid, like the patch checks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from ..checks import FAIL, PASS, Check, Result
|
|
28
|
+
from ..order import check_exact, check_order
|
|
29
|
+
from ._common import BugInfo, families
|
|
30
|
+
|
|
31
|
+
NAME = "sod"
|
|
32
|
+
GAMMA = 1.4
|
|
33
|
+
CFL = 0.5
|
|
34
|
+
T_END = 0.2
|
|
35
|
+
LEFT = (1.0, 0.0, 1.0) # density, velocity, pressure
|
|
36
|
+
RIGHT = (0.125, 0.0, 0.1)
|
|
37
|
+
X0 = 0.5
|
|
38
|
+
SOD_CELLS = (100, 200, 400, 800, 1600)
|
|
39
|
+
WAVE_CELLS = (50, 100, 200, 400, 800)
|
|
40
|
+
WAVE_T_END = 0.25
|
|
41
|
+
CONSERVATION_CELLS = (50, 100, 200)
|
|
42
|
+
# A first-order explicit scheme moves information one cell per step, about 0.84 of the domain
|
|
43
|
+
# by t = 0.2 at CFL 0.5, so numerical diffusion would reach the ends. By t = 0.05 it has
|
|
44
|
+
# moved about 0.21 from the diaphragm, and the end cells are still exactly the initial states.
|
|
45
|
+
CONSERVATION_T_END = 0.05
|
|
46
|
+
|
|
47
|
+
BUGS = {
|
|
48
|
+
"gamma_wrong": BugInfo(
|
|
49
|
+
"the flux computes pressure with gamma = 5/3 while the initial data use 1.4",
|
|
50
|
+
families("reference"),
|
|
51
|
+
"wrong wave speeds and plateaus in Sod's problem; the smooth wave has uniform pressure, so it may not see it",
|
|
52
|
+
),
|
|
53
|
+
"nonconservative_momentum": BugInfo(
|
|
54
|
+
"velocity is updated in primitive form (u_t + u u_x + p_x / rho = 0) instead of conserving momentum",
|
|
55
|
+
families("invariant"),
|
|
56
|
+
"momentum not conserved. Predicted before running: the Sod reference check too, through a wrong "
|
|
57
|
+
"shock speed. Observed: its order falls from 0.63 to 0.43, still inside the wide band a "
|
|
58
|
+
"discontinuous solution needs, so only the conservation check flags it",
|
|
59
|
+
),
|
|
60
|
+
"wave_speed_without_sound": BugInfo(
|
|
61
|
+
"the Rusanov dissipation uses |u| instead of |u| + c",
|
|
62
|
+
families(),
|
|
63
|
+
"predicted before running: oscillations at the discontinuities, flagged by the Sod reference "
|
|
64
|
+
"check. Observed: the scheme stayed stable and was slightly more accurate (less dissipation), "
|
|
65
|
+
"and it is still in conservation form, so no check flags it. A blind spot: the bug is a "
|
|
66
|
+
"stability risk, not a wrong answer on this problem",
|
|
67
|
+
),
|
|
68
|
+
"last_step_not_clipped": BugInfo(
|
|
69
|
+
"the last time step is not shortened to end exactly at t_end",
|
|
70
|
+
families("invariant"),
|
|
71
|
+
"an O(dt) time error, the same order as the scheme's own, so the order studies pass; "
|
|
72
|
+
"the conservation check uses the intended time and sees the extra momentum",
|
|
73
|
+
),
|
|
74
|
+
"reflective_boundary": BugInfo(
|
|
75
|
+
"the boundary cells reflect (velocity negated) instead of letting waves out",
|
|
76
|
+
families(),
|
|
77
|
+
"nothing until a wave reaches a boundary, which does not happen before t_end: a documented "
|
|
78
|
+
"blind spot of every check here",
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- exact Riemann solver (Toro, Riemann Solvers and Numerical Methods for Fluid Dynamics, ch. 4) ---
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sound(rho, p, gamma=GAMMA):
|
|
87
|
+
return math.sqrt(gamma * p / rho)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _f_and_df(p, rho_k, p_k, gamma):
|
|
91
|
+
c_k = _sound(rho_k, p_k, gamma)
|
|
92
|
+
if p > p_k: # shock
|
|
93
|
+
a = 2.0 / ((gamma + 1.0) * rho_k)
|
|
94
|
+
b = (gamma - 1.0) / (gamma + 1.0) * p_k
|
|
95
|
+
q = math.sqrt(a / (p + b))
|
|
96
|
+
return (p - p_k) * q, q * (1.0 - 0.5 * (p - p_k) / (p + b))
|
|
97
|
+
ratio = p / p_k # rarefaction
|
|
98
|
+
f = 2.0 * c_k / (gamma - 1.0) * (ratio ** ((gamma - 1.0) / (2.0 * gamma)) - 1.0)
|
|
99
|
+
df = ratio ** (-(gamma + 1.0) / (2.0 * gamma)) / (rho_k * c_k)
|
|
100
|
+
return f, df
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def star_state(left=LEFT, right=RIGHT, gamma=GAMMA):
|
|
104
|
+
"""Pressure and velocity between the two outer waves, by Newton iteration."""
|
|
105
|
+
rl, ul, pl = left
|
|
106
|
+
rr, ur, pr = right
|
|
107
|
+
p = max(1e-10, 0.5 * (pl + pr))
|
|
108
|
+
for _ in range(100):
|
|
109
|
+
fl, dfl = _f_and_df(p, rl, pl, gamma)
|
|
110
|
+
fr, dfr = _f_and_df(p, rr, pr, gamma)
|
|
111
|
+
step = (fl + fr + ur - ul) / (dfl + dfr)
|
|
112
|
+
p_new = max(1e-10, p - step)
|
|
113
|
+
if abs(p_new - p) <= 1e-14 * (p_new + p):
|
|
114
|
+
p = p_new
|
|
115
|
+
break
|
|
116
|
+
p = p_new
|
|
117
|
+
fl, _ = _f_and_df(p, rl, pl, gamma)
|
|
118
|
+
fr, _ = _f_and_df(p, rr, pr, gamma)
|
|
119
|
+
return p, 0.5 * (ul + ur) + 0.5 * (fr - fl)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sample(s, left=LEFT, right=RIGHT, gamma=GAMMA, star=None):
|
|
123
|
+
"""(density, velocity, pressure) of the exact solution at x/t = s."""
|
|
124
|
+
rl, ul, pl = left
|
|
125
|
+
rr, ur, pr = right
|
|
126
|
+
cl, cr = _sound(rl, pl, gamma), _sound(rr, pr, gamma)
|
|
127
|
+
ps, us = star or star_state(left, right, gamma)
|
|
128
|
+
g6 = (gamma - 1.0) / (gamma + 1.0)
|
|
129
|
+
if s <= us:
|
|
130
|
+
if ps > pl:
|
|
131
|
+
shock = ul - cl * math.sqrt((gamma + 1.0) / (2.0 * gamma) * ps / pl + (gamma - 1.0) / (2.0 * gamma))
|
|
132
|
+
if s <= shock:
|
|
133
|
+
return rl, ul, pl
|
|
134
|
+
return rl * (ps / pl + g6) / (g6 * ps / pl + 1.0), us, ps
|
|
135
|
+
if s <= ul - cl:
|
|
136
|
+
return rl, ul, pl
|
|
137
|
+
c_star = cl * (ps / pl) ** ((gamma - 1.0) / (2.0 * gamma))
|
|
138
|
+
if s > us - c_star:
|
|
139
|
+
return rl * (ps / pl) ** (1.0 / gamma), us, ps
|
|
140
|
+
u = 2.0 / (gamma + 1.0) * (cl + 0.5 * (gamma - 1.0) * ul + s)
|
|
141
|
+
c = 2.0 / (gamma + 1.0) * (cl + 0.5 * (gamma - 1.0) * (ul - s))
|
|
142
|
+
return rl * (c / cl) ** (2.0 / (gamma - 1.0)), u, pl * (c / cl) ** (2.0 * gamma / (gamma - 1.0))
|
|
143
|
+
if ps > pr:
|
|
144
|
+
shock = ur + cr * math.sqrt((gamma + 1.0) / (2.0 * gamma) * ps / pr + (gamma - 1.0) / (2.0 * gamma))
|
|
145
|
+
if s >= shock:
|
|
146
|
+
return rr, ur, pr
|
|
147
|
+
return rr * (ps / pr + g6) / (g6 * ps / pr + 1.0), us, ps
|
|
148
|
+
if s >= ur + cr:
|
|
149
|
+
return rr, ur, pr
|
|
150
|
+
c_star = cr * (ps / pr) ** ((gamma - 1.0) / (2.0 * gamma))
|
|
151
|
+
if s <= us + c_star:
|
|
152
|
+
return rr * (ps / pr) ** (1.0 / gamma), us, ps
|
|
153
|
+
u = 2.0 / (gamma + 1.0) * (-cr + 0.5 * (gamma - 1.0) * ur + s)
|
|
154
|
+
c = 2.0 / (gamma + 1.0) * (cr - 0.5 * (gamma - 1.0) * (ur - s))
|
|
155
|
+
return rr * (c / cr) ** (2.0 / (gamma - 1.0)), u, pr * (c / cr) ** (2.0 * gamma / (gamma - 1.0))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def exact_sod(x, t, left=LEFT, right=RIGHT, x0=X0, gamma=GAMMA):
|
|
159
|
+
star = star_state(left, right, gamma)
|
|
160
|
+
return np.array([sample((xi - x0) / t, left, right, gamma, star) for xi in x]).T
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --- the solver under test ---
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def conserved(rho, u, p, gamma=GAMMA):
|
|
167
|
+
return np.array([rho, rho * u, p / (gamma - 1.0) + 0.5 * rho * u * u])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def solve(n_cells, bugs, init, t_end, periodic=False):
|
|
171
|
+
"""Advance cell averages on [0, 1] to t_end. Returns cell centres, final U, and the time reached."""
|
|
172
|
+
gamma_flux = 5.0 / 3.0 if "gamma_wrong" in bugs else GAMMA
|
|
173
|
+
dx = 1.0 / n_cells
|
|
174
|
+
x = (np.arange(n_cells) + 0.5) * dx
|
|
175
|
+
U = conserved(*init(x))
|
|
176
|
+
t = 0.0
|
|
177
|
+
with np.errstate(all="ignore"):
|
|
178
|
+
while t < t_end:
|
|
179
|
+
if periodic:
|
|
180
|
+
G = np.concatenate([U[:, -1:], U, U[:, :1]], axis=1)
|
|
181
|
+
else:
|
|
182
|
+
G = np.concatenate([U[:, :1], U, U[:, -1:]], axis=1)
|
|
183
|
+
if "reflective_boundary" in bugs:
|
|
184
|
+
G[1, 0], G[1, -1] = -G[1, 0], -G[1, -1]
|
|
185
|
+
rho, mom, E = G
|
|
186
|
+
u = mom / rho
|
|
187
|
+
p = (gamma_flux - 1.0) * (E - 0.5 * rho * u * u)
|
|
188
|
+
c = np.sqrt(gamma_flux * np.abs(p) / rho)
|
|
189
|
+
speed = np.max(np.abs(u) + c)
|
|
190
|
+
if not np.isfinite(speed) or speed <= 0.0:
|
|
191
|
+
return x, np.full_like(U, np.nan), t
|
|
192
|
+
dt = CFL * dx / speed
|
|
193
|
+
if t + dt > t_end and "last_step_not_clipped" not in bugs:
|
|
194
|
+
dt = t_end - t
|
|
195
|
+
F = np.array([mom, mom * u + p, (E + p) * u])
|
|
196
|
+
wave = np.abs(u) if "wave_speed_without_sound" in bugs else np.abs(u) + c
|
|
197
|
+
a = np.maximum(wave[:-1], wave[1:])
|
|
198
|
+
flux = 0.5 * (F[:, :-1] + F[:, 1:]) - 0.5 * a * (G[:, 1:] - G[:, :-1])
|
|
199
|
+
U_new = U - dt / dx * (flux[:, 1:] - flux[:, :-1])
|
|
200
|
+
if "nonconservative_momentum" in bugs:
|
|
201
|
+
# u_t + u u_x + p_x / rho = 0, with the same Rusanov-type dissipation on u
|
|
202
|
+
ui = u[1:-1]
|
|
203
|
+
du = 0.5 * (u[2:] - u[:-2])
|
|
204
|
+
dp = 0.5 * (p[2:] - p[:-2])
|
|
205
|
+
u_new = ui - dt / dx * (ui * du + dp / rho[1:-1]) + 0.5 * dt / dx * a.max() * (u[2:] - 2 * ui + u[:-2])
|
|
206
|
+
U_new[1] = U_new[0] * u_new
|
|
207
|
+
U = U_new
|
|
208
|
+
t += dt
|
|
209
|
+
return x, U, t
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def primitive(U, gamma=GAMMA):
|
|
213
|
+
rho, mom, E = U
|
|
214
|
+
u = mom / rho
|
|
215
|
+
return rho, u, (gamma - 1.0) * (E - 0.5 * rho * u * u)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def sod_init(x):
|
|
219
|
+
left = x < X0
|
|
220
|
+
rho = np.where(left, LEFT[0], RIGHT[0])
|
|
221
|
+
u = np.where(left, LEFT[1], RIGHT[1])
|
|
222
|
+
p = np.where(left, LEFT[2], RIGHT[2])
|
|
223
|
+
return rho, u, p
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def wave_init(x, t=0.0):
|
|
227
|
+
return 1.0 + 0.2 * np.sin(2.0 * math.pi * (x - t)), np.ones_like(x), np.ones_like(x)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _l1(a, b):
|
|
231
|
+
with np.errstate(all="ignore"):
|
|
232
|
+
return float(np.mean(np.abs(a - b)))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def checks(bugs=frozenset()) -> list[Check]:
|
|
236
|
+
def run_sod():
|
|
237
|
+
hs, errs = [], []
|
|
238
|
+
for n in SOD_CELLS:
|
|
239
|
+
x, U, _ = solve(n, bugs, sod_init, T_END)
|
|
240
|
+
hs.append(1.0 / n)
|
|
241
|
+
errs.append(_l1(U[0], exact_sod(x, T_END)[0]))
|
|
242
|
+
return check_order(hs, errs, 0.5, tol_low=0.2, tol_high=0.5)
|
|
243
|
+
|
|
244
|
+
def run_wave():
|
|
245
|
+
hs, errs = [], []
|
|
246
|
+
for n in WAVE_CELLS:
|
|
247
|
+
x, U, _ = solve(n, bugs, wave_init, WAVE_T_END, periodic=True)
|
|
248
|
+
hs.append(1.0 / n)
|
|
249
|
+
errs.append(_l1(U[0], wave_init(x, WAVE_T_END)[0]))
|
|
250
|
+
return check_order(hs, errs, 1.0)
|
|
251
|
+
|
|
252
|
+
def run_conservation():
|
|
253
|
+
# Fluxes through the ends stay those of the initial states until a wave arrives:
|
|
254
|
+
# mass and energy fluxes are zero (u = 0), the momentum flux is the pressure.
|
|
255
|
+
expected_change = np.array([0.0, -(RIGHT[2] - LEFT[2]) * CONSERVATION_T_END, 0.0])
|
|
256
|
+
hs, errs = [], []
|
|
257
|
+
for n in CONSERVATION_CELLS:
|
|
258
|
+
x, U, _ = solve(n, bugs, sod_init, CONSERVATION_T_END)
|
|
259
|
+
total0 = conserved(*sod_init(x)).sum(axis=1) / n
|
|
260
|
+
with np.errstate(all="ignore"):
|
|
261
|
+
errs.append(float(np.max(np.abs(U.sum(axis=1) / n - total0 - expected_change))))
|
|
262
|
+
hs.append(1.0 / n)
|
|
263
|
+
return check_exact(hs, errs, floor=1e-11)
|
|
264
|
+
|
|
265
|
+
return [
|
|
266
|
+
Check("reference", "Sod shock tube, density L1", run_sod),
|
|
267
|
+
Check("reference", "smooth density wave, periodic", run_wave),
|
|
268
|
+
Check("invariant", "discrete conservation of mass, momentum, energy", run_conservation),
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
SMOKE_CELLS = 400
|
|
273
|
+
SMOKE_RTOL = 0.05 # an L1 density error of a few percent is typical for a first-order scheme here
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def smoke_checks(bugs=frozenset()) -> list[Check]:
|
|
277
|
+
"""A plain test: Sod's density on 400 cells within 5% (L1, relative to the mean density)."""
|
|
278
|
+
|
|
279
|
+
def run():
|
|
280
|
+
x, U, _ = solve(SMOKE_CELLS, bugs, sod_init, T_END)
|
|
281
|
+
exact = exact_sod(x, T_END)[0]
|
|
282
|
+
rel = _l1(U[0], exact) / float(np.mean(exact))
|
|
283
|
+
return _within(rel, SMOKE_RTOL)
|
|
284
|
+
|
|
285
|
+
return [Check("smoke", "Sod density on 400 cells", run)]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _within(rel, tol):
|
|
289
|
+
if np.isfinite(rel) and rel <= tol:
|
|
290
|
+
return Result(PASS, f"relative L1 error {rel:.3g} within {tol:g}")
|
|
291
|
+
return Result(FAIL, f"relative L1 error {rel:.3g} exceeds {tol:g}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""pytest plugin: every check in a `verify_*.py` file becomes a test.
|
|
2
|
+
|
|
3
|
+
A `verify_*.py` file defines `checks()`, returning a list of physverify.Check
|
|
4
|
+
(the same spec format as `physverify run`). With physverify installed, plain
|
|
5
|
+
`pytest` collects them; do not also pass `-p physverify.pytest_plugin`, because
|
|
6
|
+
pytest then stops with "Plugin already registered". Without installing, use
|
|
7
|
+
`pytest -p physverify.pytest_plugin`.
|
|
8
|
+
Files named verify_*.py that do not define checks() are skipped. Turn the
|
|
9
|
+
plugin off with `-p no:physverify`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from .checks import OrderResult, run_check
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pytest_collect_file(file_path, parent):
|
|
20
|
+
if file_path.suffix == ".py" and file_path.name.startswith("verify_"):
|
|
21
|
+
return VerifyFile.from_parent(parent, path=file_path)
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CheckFailed(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class VerifyFile(pytest.File):
|
|
30
|
+
def collect(self):
|
|
31
|
+
from .report import load_spec
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
checks = load_spec(self.path)
|
|
35
|
+
except AttributeError as exc:
|
|
36
|
+
if "does not define checks()" in str(exc):
|
|
37
|
+
return
|
|
38
|
+
raise
|
|
39
|
+
seen = {}
|
|
40
|
+
for check in checks:
|
|
41
|
+
name = f"{check.family}: {check.label}"
|
|
42
|
+
seen[name] = seen.get(name, 0) + 1
|
|
43
|
+
if seen[name] > 1:
|
|
44
|
+
name = f"{name} [{seen[name]}]"
|
|
45
|
+
yield CheckItem.from_parent(self, name=name, check=check)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CheckItem(pytest.Item):
|
|
49
|
+
def __init__(self, *, check, **kwargs):
|
|
50
|
+
super().__init__(**kwargs)
|
|
51
|
+
self.check = check
|
|
52
|
+
self.add_marker(pytest.mark.physverify)
|
|
53
|
+
|
|
54
|
+
def runtest(self):
|
|
55
|
+
result = run_check(self.check)
|
|
56
|
+
self.user_properties.append(("status", result.status))
|
|
57
|
+
if not result.ok:
|
|
58
|
+
raise CheckFailed(result)
|
|
59
|
+
|
|
60
|
+
def repr_failure(self, excinfo):
|
|
61
|
+
if isinstance(excinfo.value, CheckFailed):
|
|
62
|
+
result = excinfo.value.args[0]
|
|
63
|
+
lines = [f"{result.status}: {result.message}"]
|
|
64
|
+
if isinstance(result, OrderResult) and result.hs:
|
|
65
|
+
lines.append(f"{'h':>12s} {'error':>12s}")
|
|
66
|
+
lines += [f"{h:12.4g} {e:12.4g}" for h, e in zip(result.hs, result.errors)]
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
return super().repr_failure(excinfo)
|
|
69
|
+
|
|
70
|
+
def reportinfo(self):
|
|
71
|
+
return self.path, None, f"{self.check.family}: {self.check.label}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pytest_configure(config):
|
|
75
|
+
config.addinivalue_line("markers", "physverify: a check collected from a verify_*.py file")
|