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
physverify/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """physverify: verification checks for physics simulation code."""
2
+
3
+ from .checks import Check, OrderResult, Result
4
+ from .mms import ManufacturedODE, mms_order_study, zero_source
5
+ from .order import check_order, fit_order, pairwise_orders
6
+ from .order import check_exact
7
+ from .studies import invariant_study, ode_checks, patch_study, reference_study
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = [
12
+ "Check",
13
+ "ManufacturedODE",
14
+ "OrderResult",
15
+ "Result",
16
+ "check_exact",
17
+ "check_order",
18
+ "fit_order",
19
+ "invariant_study",
20
+ "mms_order_study",
21
+ "ode_checks",
22
+ "pairwise_orders",
23
+ "patch_study",
24
+ "reference_study",
25
+ "zero_source",
26
+ ]
physverify/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,14 @@
1
+ """Adapters that connect physverify to existing simulation code (read-only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def sibling_project(env_var: str, folder: str) -> Path:
10
+ """Folder of another project: $env_var if set, else a sibling of this repository."""
11
+ if os.environ.get(env_var):
12
+ return Path(os.environ[env_var])
13
+ repo_root = Path(__file__).resolve().parents[3]
14
+ return repo_root.parent / folder
@@ -0,0 +1,55 @@
1
+ """Adapter for double-pendulum-simulation/src/mechanics.py.
2
+
3
+ Only the right-hand side (get_derivatives) and the energy (calculate_energy)
4
+ are tested. They are stepped with physverify's own RK4, because the project's
5
+ solvers.py needs SciPy. So these checks say nothing about solvers.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from ..checks import Check
13
+ from ..loaders import load_module_from_path
14
+ from ..problems import pendulum as P
15
+ from ..problems._common import make_integrate
16
+ from ..studies import ode_checks
17
+ from . import sibling_project
18
+
19
+ ENV_VAR = "PHYSVERIFY_PENDULUM_DIR"
20
+ FOLDER = "double-pendulum-simulation"
21
+
22
+
23
+ def source_path():
24
+ return sibling_project(ENV_VAR, FOLDER) / "src" / "mechanics.py"
25
+
26
+
27
+ def load():
28
+ return load_module_from_path("double_pendulum_mechanics_under_test", source_path())
29
+
30
+
31
+ def checks(module=None) -> list[Check]:
32
+ module = module or load()
33
+ params = dict(P.PARAMS)
34
+
35
+ def rhs(t, y):
36
+ return np.asarray(module.get_derivatives(t, y, params), dtype=float)
37
+
38
+ def energy(y):
39
+ return module.calculate_energy(y, **params)
40
+
41
+ out = ode_checks(
42
+ "mechanics.py RHS + physverify RK4",
43
+ make_integrate(rhs, frozenset(), "rk4"),
44
+ rhs_ref=P.rhs_reference(params),
45
+ mms=P.manufactured(),
46
+ y0_free=P.Y0_FREE,
47
+ t_end=P.MMS_T_END,
48
+ ns=P.NS_RK4,
49
+ order=4.0,
50
+ quantity=energy,
51
+ patch=P.PATCH,
52
+ integrate_trajectory=make_integrate(rhs, frozenset(), "rk4", trajectory=True),
53
+ )
54
+ out.append(P.frequency_check(rhs, label="mechanics.py small-oscillation frequencies"))
55
+ return out
@@ -0,0 +1,152 @@
1
+ """Adapter for lorenz-attractor-simulation/src/mechanics.py (lorenz_derivatives).
2
+
3
+ The project integrates with SciPy's solve_ivp, so only the right-hand side is
4
+ under test here; it is stepped with physverify's own RK4. The flow is chaotic
5
+ (largest Lyapunov exponent about 0.906), so trajectories are compared only up
6
+ to t = 1, well inside one Lyapunov time's worth of growth (exp(0.9) < 3).
7
+ Beyond that the project is checked through exact properties of the flow:
8
+ fixed points, the constant divergence -(sigma + 1 + beta), and the Jacobian
9
+ that scripts/calculate_lyapunov.py writes by hand.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import math
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ from ..checks import EXACT, FAIL, PASS, Check, Result
21
+ from ..loaders import load_module_from_path
22
+ from ..mms import ManufacturedODE
23
+ from ..problems._common import make_integrate
24
+ from ..studies import ode_checks
25
+ from . import sibling_project
26
+
27
+ ENV_VAR = "PHYSVERIFY_LORENZ_DIR"
28
+ FOLDER = "lorenz-attractor-simulation"
29
+ PARAMS = {"sigma": 10.0, "rho": 28.0, "beta": 8.0 / 3.0}
30
+ Y0_FREE = np.array([1.0, 1.0, 1.0])
31
+ T_END = 1.0
32
+ NS_RK4 = (100, 200, 400, 800)
33
+
34
+
35
+ def source_path():
36
+ return sibling_project(ENV_VAR, FOLDER) / "src" / "mechanics.py"
37
+
38
+
39
+ def lyapunov_path():
40
+ return sibling_project(ENV_VAR, FOLDER) / "scripts" / "calculate_lyapunov.py"
41
+
42
+
43
+ def load():
44
+ return load_module_from_path("lorenz_mechanics_under_test", source_path())
45
+
46
+
47
+ def rhs_reference(p=PARAMS):
48
+ """From docs/theory.md, written in matrix-free form independently of the code."""
49
+ s, r, b = p["sigma"], p["rho"], p["beta"]
50
+
51
+ def f(t, u):
52
+ x, y, z = u
53
+ return np.array([s * y - s * x, r * x - x * z - y, x * y - b * z])
54
+
55
+ return f
56
+
57
+
58
+ def manufactured() -> ManufacturedODE:
59
+ def u(t):
60
+ return np.array([2.0 * np.sin(t), 1.0 + np.cos(1.7 * t), 20.0 + 3.0 * np.sin(0.6 * t)])
61
+
62
+ return ManufacturedODE(u, rhs_reference())
63
+
64
+
65
+ def fixed_points(p=PARAMS):
66
+ c = math.sqrt(p["beta"] * (p["rho"] - 1.0))
67
+ return [np.zeros(3), np.array([c, c, p["rho"] - 1.0]), np.array([-c, -c, p["rho"] - 1.0])]
68
+
69
+
70
+ def central_jacobian(f, u, h=1e-3):
71
+ """Centred differences are exact (to round-off) for a quadratic field like Lorenz."""
72
+ jac = np.zeros((3, 3))
73
+ for j in range(3):
74
+ e = np.zeros(3)
75
+ e[j] = h
76
+ jac[:, j] = (np.asarray(f(0.0, u + e)) - np.asarray(f(0.0, u - e))) / (2 * h)
77
+ return jac
78
+
79
+
80
+ SAMPLE_POINTS = [np.array(v) for v in ([1.0, 1.0, 1.0], [-8.3, 4.1, 27.0], [12.0, -3.0, 5.5], [0.0, 0.0, 0.0])]
81
+
82
+
83
+ def fixed_point_check(rhs) -> Check:
84
+ def run():
85
+ worst = max(float(np.max(np.abs(rhs(0.0, p)))) for p in fixed_points())
86
+ if worst <= 1e-12 * 30:
87
+ return Result(EXACT, f"all three fixed points are zeros of the field (largest |f| = {worst:.1e})")
88
+ return Result(FAIL, f"field is {worst:.3g} at a fixed point")
89
+
90
+ return Check("limit", "fixed points 0 and (+-sqrt(beta(rho-1)), ., rho-1)", run)
91
+
92
+
93
+ def divergence_check(rhs) -> Check:
94
+ expected = -(PARAMS["sigma"] + 1.0 + PARAMS["beta"])
95
+
96
+ def run():
97
+ divs = [float(np.trace(central_jacobian(rhs, p))) for p in SAMPLE_POINTS]
98
+ worst = max(abs(d - expected) for d in divs)
99
+ if worst <= 1e-9:
100
+ return Result(EXACT, f"divergence {divs[0]:.12f} = -(sigma+1+beta) at {len(divs)} points")
101
+ return Result(FAIL, f"divergence differs from {expected:.6f} by {worst:.3g}")
102
+
103
+ return Check("invariant", "flow divergence is constant -(sigma+1+beta)", run)
104
+
105
+
106
+ def jacobian_check(rhs, jacobian) -> Check:
107
+ def run():
108
+ worst = max(
109
+ float(np.max(np.abs(np.asarray(jacobian(p, PARAMS)) - central_jacobian(rhs, p)))) for p in SAMPLE_POINTS
110
+ )
111
+ if worst <= 1e-9:
112
+ return Result(EXACT, f"hand-written Jacobian matches the field's derivative (max diff {worst:.1e})")
113
+ return Result(FAIL, f"hand-written Jacobian differs from the field's derivative by {worst:.3g}")
114
+
115
+ return Check("limit", "calculate_lyapunov.py Jacobian vs derivative of the field", run)
116
+
117
+
118
+ def load_function(path, name):
119
+ """One top-level function from a script, without running the script's imports.
120
+
121
+ The script appends to sys.path and imports SciPy at module level; only the
122
+ function's own source is executed here, with numpy as np in scope.
123
+ """
124
+ tree = ast.parse(Path(path).read_text(encoding="utf-8"))
125
+ node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == name)
126
+ namespace = {"np": np}
127
+ exec(compile(ast.Module([node], []), str(path), "exec"), namespace)
128
+ return namespace[name]
129
+
130
+
131
+ def checks(module=None) -> list[Check]:
132
+ module = module or load()
133
+
134
+ def rhs(t, y):
135
+ return np.asarray(module.lorenz_derivatives(t, y, PARAMS), dtype=float)
136
+
137
+ out = ode_checks(
138
+ "mechanics.py RHS + physverify RK4",
139
+ make_integrate(rhs, frozenset(), "rk4"),
140
+ rhs_ref=rhs_reference(),
141
+ mms=manufactured(),
142
+ y0_free=Y0_FREE,
143
+ t_end=T_END,
144
+ ns=NS_RK4,
145
+ order=4.0,
146
+ patch=(np.array([1.0, -2.0, 15.0]), np.array([0.5, 1.5, -2.0])),
147
+ integrate_trajectory=make_integrate(rhs, frozenset(), "rk4", trajectory=True),
148
+ )
149
+ out += [fixed_point_check(rhs), divergence_check(rhs)]
150
+ if lyapunov_path().is_file():
151
+ out.append(jacobian_check(rhs, load_function(lyapunov_path(), "lorenz_jacobian")))
152
+ return out
@@ -0,0 +1,92 @@
1
+ """Adapter for projectile-simulation/src/projectile_sim_v1.py (class Projectile, forward Euler).
2
+
3
+ simulate() returns only positions and runs until landing, with no source hook,
4
+ so there is no MMS or patch check (row 25 of the audit plan). Positions at a
5
+ fixed time are compared with the closed-form vacuum and linear-drag solutions
6
+ from the README and with an RK4 reference for quadratic drag; the landing range
7
+ is compared with the vacuum formula v0^2 sin(2 angle) / g. Forward Euler is
8
+ first order by design, so order 1 is the pass mark.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+
15
+ import numpy as np
16
+
17
+ from ..checks import Check
18
+ from ..loaders import load_module_from_path
19
+ from ..order import check_order
20
+ from ..steppers import integrate_fixed, rk4
21
+ from . import sibling_project
22
+
23
+ ENV_VAR = "PHYSVERIFY_PROJECTILE_DIR"
24
+ FOLDER = "projectile-simulation"
25
+ MASS, RADIUS, V0, ANGLE = 0.145, 0.037, 30.0, 45.0
26
+ T_END = 2.0
27
+ NS = (200, 400, 800, 1600)
28
+
29
+
30
+ def source_path():
31
+ return sibling_project(ENV_VAR, FOLDER) / "src" / "projectile_sim_v1.py"
32
+
33
+
34
+ def load():
35
+ return load_module_from_path("projectile_sim_v1_under_test", source_path(), stubs=("matplotlib", "matplotlib.pyplot"))
36
+
37
+
38
+ def exact_position(mode, t, k, g=9.81):
39
+ vx0 = V0 * math.cos(math.radians(ANGLE))
40
+ vy0 = V0 * math.sin(math.radians(ANGLE))
41
+ if mode == "vacuum":
42
+ return np.array([vx0 * t, vy0 * t - 0.5 * g * t * t])
43
+ if mode == "linear":
44
+ decay = 1.0 - math.exp(-k * t)
45
+ return np.array([vx0 / k * decay, (vy0 + g / k) / k * decay - g * t / k])
46
+ # quadratic: no closed form; RK4 on the README's equations with a fine step
47
+ def f(_t, z):
48
+ speed = math.hypot(z[2], z[3])
49
+ return np.array([z[2], z[3], -k * speed * z[2], -g - k * speed * z[3]])
50
+
51
+ return integrate_fixed(f, [0.0, 0.0, vx0, vy0], 0.0, t, 20000, rk4)[:2]
52
+
53
+
54
+ def drag_constant(mode, viscosity=1.81e-5, cd=0.47, rho=1.225):
55
+ if mode == "linear":
56
+ return 6.0 * math.pi * viscosity * RADIUS / MASS
57
+ if mode == "quadratic":
58
+ return 0.5 * rho * math.pi * RADIUS**2 * cd / MASS
59
+ return 0.0
60
+
61
+
62
+ def position_check(module, mode) -> Check:
63
+ def run():
64
+ ball = module.Projectile(mass=MASS, radius=RADIUS)
65
+ expected = exact_position(mode, T_END, drag_constant(mode))
66
+ hs, errs = [], []
67
+ for n in NS:
68
+ _, xs, ys = ball.simulate(V0, ANGLE, mode=mode, dt=T_END / n)
69
+ hs.append(T_END / n)
70
+ errs.append(float(np.max(np.abs(np.array([xs[n], ys[n]]) - expected))))
71
+ return check_order(hs, errs, 1.0, scale=float(np.max(np.abs(expected))))
72
+
73
+ return Check("reference", f"v1 {mode}/euler position at t = {T_END:g}", run)
74
+
75
+
76
+ def range_check(module) -> Check:
77
+ def run():
78
+ ball = module.Projectile(mass=MASS, radius=RADIUS)
79
+ exact = V0 * V0 * math.sin(math.radians(2 * ANGLE)) / 9.81
80
+ hs, errs = [], []
81
+ for dt in (0.02, 0.01, 0.005, 0.0025):
82
+ _, xs, _ = ball.simulate(V0, ANGLE, mode="vacuum", dt=dt)
83
+ hs.append(dt)
84
+ errs.append(abs(xs[-1] - exact))
85
+ return check_order(hs, errs, 1.0, scale=exact)
86
+
87
+ return Check("limit", "v1 vacuum/euler range", run)
88
+
89
+
90
+ def checks(module=None) -> list[Check]:
91
+ module = module or load()
92
+ return [position_check(module, m) for m in ("vacuum", "linear", "quadratic")] + [range_check(module)]
@@ -0,0 +1,94 @@
1
+ """Adapter for projectile-simulation/src/projectile_sim_v2.py (class ProjectileV2).
2
+
3
+ The class has no time argument and no source-term hook, so the manufactured
4
+ forcing is injected by wrapping `_state_derivative` and reading time from x:
5
+ the manufactured solution has x_m(t) = t. The wrapped problem is then the
6
+ autonomous system z' = f(z) + s(x), whose exact solution is the manufactured
7
+ one. The code itself is not modified.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+ import numpy as np
15
+
16
+ from ..checks import Check
17
+ from ..loaders import load_module_from_path
18
+ from ..mms import zero_source
19
+ from ..problems import projectile as P
20
+ from ..studies import ode_checks
21
+ from . import sibling_project
22
+
23
+ ENV_VAR = "PHYSVERIFY_PROJECTILE_DIR"
24
+ FOLDER = "projectile-simulation"
25
+
26
+
27
+ def source_path():
28
+ return sibling_project(ENV_VAR, FOLDER) / "src" / "projectile_sim_v2.py"
29
+
30
+
31
+ def load():
32
+ return load_module_from_path("projectile_sim_v2_under_test", source_path(), stubs=("matplotlib", "matplotlib.pyplot"))
33
+
34
+
35
+ def _new_sim(module):
36
+ p = P.PARAMS
37
+ return module.ProjectileV2(mass=p["m"], radius=p["r"], C_d=p["Cd"])
38
+
39
+
40
+ def make_integrate(module, mode: str, method: str, trajectory: bool = False):
41
+ def integrate(source, y0, t_end, n):
42
+ if y0[0] != 0.0 or y0[1] != 0.0:
43
+ raise ValueError("ProjectileV2 always launches from the origin")
44
+ sim = _new_sim(module)
45
+ original = sim._state_derivative
46
+ # simulate() runs until landing; stop a run that never lands instead of hanging
47
+ budget = [8 * 10 * n]
48
+ # In a forced run x = t, and past the measurement time the state no longer matters, so a
49
+ # strong downward push ends the run (the linear patch solution would never land). Half a
50
+ # step of margin keeps it out of the last measured step. Free runs are left alone.
51
+ after = t_end + 0.5 * t_end / n if source is not zero_source else math.inf
52
+ land = np.array([0.0, 0.0, 0.0, -1000.0])
53
+
54
+ def wrapped(z, m):
55
+ budget[0] -= 1
56
+ if budget[0] < 0:
57
+ raise RuntimeError("simulation ran 10x past the measurement time without landing")
58
+ extra = land if z[0] > after else 0.0
59
+ return original(z, m) + source(z[0]) + extra
60
+
61
+ sim._state_derivative = wrapped
62
+ v0 = math.hypot(y0[2], y0[3])
63
+ angle = math.degrees(math.atan2(y0[3], y0[2]))
64
+ states = sim.simulate(v0, angle, mode=mode, dt=t_end / n, method=method)
65
+ return np.asarray(states[: n + 1]) if trajectory else states[n]
66
+
67
+ return integrate
68
+
69
+
70
+ def checks(module=None) -> list[Check]:
71
+ module = module or load()
72
+ out: list[Check] = []
73
+ for mode in P.MODES:
74
+ for method, ns, order in (("rk4", P.NS_RK4, 4.0), ("euler", P.NS_EULER, 1.0)):
75
+ out += ode_checks(
76
+ f"{mode}/{method}",
77
+ make_integrate(module, mode, method),
78
+ rhs_ref=P.rhs_reference(mode),
79
+ mms=P.manufactured(mode),
80
+ y0_free=P.Y0_FREE,
81
+ t_end=P.MMS_T_END,
82
+ ns=ns,
83
+ order=order,
84
+ quantity=P.energy_vacuum if mode == "vacuum" else None,
85
+ patch=P.PATCH,
86
+ integrate_trajectory=make_integrate(module, mode, method, trajectory=True),
87
+ )
88
+
89
+ def range_fn(v0, angle, dt):
90
+ trajectory = _new_sim(module).simulate(v0, angle, mode="vacuum", dt=dt, method="rk4")
91
+ return trajectory[-1][0]
92
+
93
+ out.append(P.range_check(range_fn, label="ProjectileV2 vacuum/rk4 range"))
94
+ return out