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,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")