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/cli.py ADDED
@@ -0,0 +1,105 @@
1
+ """Command line.
2
+
3
+ physverify run SPEC.py [SPEC.py ...] [--json FILE|-] [--html FILE] [--quiet]
4
+ physverify matrix [OUT.md]
5
+ physverify compare [OUT.md]
6
+ physverify hook (Claude Code PostToolUse hook; see docs/agent.md)
7
+ physverify mcp (MCP server on stdio; see docs/agent.md)
8
+
9
+ `run` exits 0 when every check passes (PASS or EXACT), 1 when any does not,
10
+ and 2 when a spec cannot be loaded. A spec is a Python file defining
11
+ `checks()`; see examples/verify_damped_oscillator.py.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from . import __version__
21
+
22
+
23
+ def _cmd_run(args) -> int:
24
+ from .report import load_spec, run_checks, to_html, to_json, to_text
25
+
26
+ outcomes = []
27
+ for spec in args.specs:
28
+ try:
29
+ checks = load_spec(spec)
30
+ except Exception as exc:
31
+ print(f"physverify: cannot load {spec}: {type(exc).__name__}: {exc}", file=sys.stderr)
32
+ return 2
33
+ outcomes += run_checks(checks)
34
+ source = ", ".join(str(Path(s)) for s in args.specs)
35
+ if args.json == "-":
36
+ print(to_json(outcomes, source))
37
+ else:
38
+ if not args.quiet:
39
+ print(to_text(outcomes))
40
+ if args.json:
41
+ Path(args.json).write_text(to_json(outcomes, source), encoding="utf-8")
42
+ if args.html:
43
+ Path(args.html).write_text(to_html(outcomes, source=source), encoding="utf-8")
44
+ return 0 if all(o.result.ok for o in outcomes) else 1
45
+
46
+
47
+ def _cmd_matrix(args) -> int:
48
+ from . import matrix
49
+
50
+ return matrix.main([args.out] if args.out else [])
51
+
52
+
53
+ def _cmd_compare(args) -> int:
54
+ from . import compare
55
+
56
+ return compare.main([args.out] if args.out else [])
57
+
58
+
59
+ def _cmd_hook(args) -> int:
60
+ from .agent import hook_main
61
+
62
+ return hook_main()
63
+
64
+
65
+ def _cmd_mcp(args) -> int:
66
+ from .agent import mcp_main
67
+
68
+ return mcp_main()
69
+
70
+
71
+ def build_parser() -> argparse.ArgumentParser:
72
+ parser = argparse.ArgumentParser(prog="physverify", description="Verification checks for physics simulation code.")
73
+ parser.add_argument("--version", action="version", version=f"physverify {__version__}")
74
+ sub = parser.add_subparsers(dest="command", required=True)
75
+
76
+ run = sub.add_parser("run", help="run the checks defined in one or more spec files")
77
+ run.add_argument("specs", nargs="+", help="Python files that define checks()")
78
+ run.add_argument("--json", metavar="FILE", help="write results as JSON to FILE, or to stdout with -")
79
+ run.add_argument("--html", metavar="FILE", help="write a self-contained HTML report")
80
+ run.add_argument("--quiet", action="store_true", help="no text output")
81
+ run.set_defaults(func=_cmd_run)
82
+
83
+ m = sub.add_parser("matrix", help="run the seeded-bug benchmark")
84
+ m.add_argument("out", nargs="?", help="also write the tables next to this Markdown file")
85
+ m.set_defaults(func=_cmd_matrix)
86
+
87
+ c = sub.add_parser("compare", help="compare the families with plain tests on the seeded bugs")
88
+ c.add_argument("out", nargs="?", help="also write the table to this Markdown file")
89
+ c.set_defaults(func=_cmd_compare)
90
+
91
+ h = sub.add_parser("hook", help="Claude Code PostToolUse hook: reads the event on stdin, exits 2 on a failed check")
92
+ h.set_defaults(func=_cmd_hook)
93
+
94
+ s = sub.add_parser("mcp", help="run a minimal MCP server on stdio with a run_checks tool")
95
+ s.set_defaults(func=_cmd_mcp)
96
+ return parser
97
+
98
+
99
+ def main(argv=None) -> int:
100
+ args = build_parser().parse_args(argv)
101
+ return args.func(args)
102
+
103
+
104
+ if __name__ == "__main__":
105
+ raise SystemExit(main())
physverify/compare.py ADDED
@@ -0,0 +1,104 @@
1
+ """How the verification families compare with ordinary unit tests on the seeded bugs.
2
+
3
+ python -m physverify.compare results/comparison.md
4
+
5
+ Strategies, each a set of checks run on every bug:
6
+
7
+ plain tests one run per case, compared with a reference or a known value
8
+ within a fixed tolerance (each problem's `smoke_checks`)
9
+ reference only the `reference` family: an order study against an independent solution
10
+ convergence only the `mms` family
11
+ full suite every family
12
+
13
+ A bug counts as caught by a strategy if at least one of its checks does not
14
+ pass. Runtime is the total time of that strategy's checks over all bugs and
15
+ baselines. The plain tests use tolerances of the size an ordinary test would
16
+ (0.1% for the ODE problems, 1% for the heat solver); a tighter tolerance would
17
+ catch more bugs and need a more accurate reference, which is what the order
18
+ studies provide without having to pick a tolerance.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from .checks import run_check
29
+ from .matrix import PROBLEMS
30
+
31
+ STRATEGIES = {
32
+ "plain tests": ("smoke",),
33
+ "reference only": ("reference",),
34
+ "convergence only": ("mms",),
35
+ "full suite": ("mms", "reference", "invariant", "limit", "patch"),
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class Outcome:
41
+ problem: str
42
+ bug: str | None
43
+ failed: dict = field(default_factory=dict) # family -> number of checks that did not pass
44
+ seconds: dict = field(default_factory=dict) # family -> total runtime
45
+
46
+
47
+ def run_case(module, bug) -> Outcome:
48
+ bugs = frozenset([bug]) if bug else frozenset()
49
+ out = Outcome(module.NAME, bug)
50
+ for check in module.checks(bugs) + module.smoke_checks(bugs):
51
+ start = time.perf_counter()
52
+ result = run_check(check)
53
+ out.seconds[check.family] = out.seconds.get(check.family, 0.0) + time.perf_counter() - start
54
+ if not result.ok:
55
+ out.failed[check.family] = out.failed.get(check.family, 0) + 1
56
+ return out
57
+
58
+
59
+ def run_all() -> list:
60
+ outcomes = []
61
+ for module in PROBLEMS.values():
62
+ outcomes.append(run_case(module, None))
63
+ for bug in module.BUGS:
64
+ outcomes.append(run_case(module, bug))
65
+ return outcomes
66
+
67
+
68
+ def caught(outcome: Outcome, strategy: str) -> bool:
69
+ return any(outcome.failed.get(f) for f in STRATEGIES[strategy])
70
+
71
+
72
+ def to_markdown(outcomes) -> str:
73
+ bug_rows = [o for o in outcomes if o.bug]
74
+ base_rows = [o for o in outcomes if not o.bug]
75
+ lines = [
76
+ "| Strategy | Bugs caught | Missed | False alarms on baselines | Runtime (s) |",
77
+ "|---|---|---|---|---|",
78
+ ]
79
+ for name in STRATEGIES:
80
+ hits = sum(caught(o, name) for o in bug_rows)
81
+ alarms = sum(caught(o, name) for o in base_rows)
82
+ secs = sum(o.seconds.get(f, 0.0) for o in outcomes for f in STRATEGIES[name])
83
+ lines.append(f"| {name} | {hits} of {len(bug_rows)} | {len(bug_rows) - hits} | {alarms} of {len(base_rows)} | {secs:.1f} |")
84
+ lines += ["", "| Problem | Bug | " + " | ".join(STRATEGIES) + " |", "|---|---|" + "---|" * len(STRATEGIES)]
85
+ for o in bug_rows:
86
+ cells = " | ".join("caught" if caught(o, s) else "**missed**" for s in STRATEGIES)
87
+ lines.append(f"| {o.problem} | {o.bug} | {cells} |")
88
+ lines += ["", "Runtimes depend on the machine; the other columns do not (the checks are deterministic)."]
89
+ return "\n".join(lines) + "\n"
90
+
91
+
92
+ def main(argv=None) -> int:
93
+ argv = sys.argv[1:] if argv is None else argv
94
+ text = to_markdown(run_all())
95
+ print(text)
96
+ if argv:
97
+ path = Path(argv[0])
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ path.write_text(text, encoding="utf-8")
100
+ return 0
101
+
102
+
103
+ if __name__ == "__main__":
104
+ raise SystemExit(main())
physverify/loaders.py ADDED
@@ -0,0 +1,49 @@
1
+ """Import a module from a path without touching its folder.
2
+
3
+ Used by the adapters that test other people's code. Nothing is written next to
4
+ the module (no __pycache__), and optional plotting imports can be stubbed so a
5
+ simulation file can be imported on a machine without matplotlib.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import importlib.util
12
+ import sys
13
+ import types
14
+ from pathlib import Path
15
+
16
+
17
+ def _stub_module(name: str) -> types.ModuleType:
18
+ return types.ModuleType(name)
19
+
20
+
21
+ def load_module_from_path(name: str, path, stubs=()):
22
+ path = Path(path)
23
+ if not path.is_file():
24
+ raise FileNotFoundError(path)
25
+
26
+ added = []
27
+ for stub in stubs:
28
+ try:
29
+ importlib.import_module(stub)
30
+ except ImportError:
31
+ sys.modules[stub] = _stub_module(stub)
32
+ added.append(stub)
33
+
34
+ old_flag = sys.dont_write_bytecode
35
+ sys.dont_write_bytecode = True
36
+ try:
37
+ spec = importlib.util.spec_from_file_location(name, path)
38
+ module = importlib.util.module_from_spec(spec)
39
+ sys.modules[name] = module
40
+ try:
41
+ spec.loader.exec_module(module)
42
+ except BaseException:
43
+ sys.modules.pop(name, None) # do not leave a half-initialised module behind
44
+ raise
45
+ finally:
46
+ sys.dont_write_bytecode = old_flag
47
+ for stub in added:
48
+ sys.modules.pop(stub, None)
49
+ return module
physverify/matrix.py ADDED
@@ -0,0 +1,160 @@
1
+ """Run every check on every seeded bug and tabulate which checks notice which bug.
2
+
3
+ python -m physverify.matrix results/detection_matrix.md
4
+
5
+ The bugs were written by the author of this package, so the catch rate below is
6
+ a measure of these bugs, not of bugs in general.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from .checks import FAMILIES, Check, Result, run_check
16
+ from .problems import heat1d, kepler, pendulum, projectile, sod
17
+
18
+ PROBLEMS = {m.NAME: m for m in (projectile, pendulum, kepler, heat1d, sod)}
19
+
20
+
21
+ @dataclass
22
+ class Row:
23
+ problem: str
24
+ bug: str | None # None is the bug-free baseline
25
+ results: list = field(default_factory=list) # list of (Check, Result)
26
+
27
+ def flagged(self) -> dict:
28
+ """family -> labels of the checks that did not pass"""
29
+ out: dict = {}
30
+ for check, result in self.results:
31
+ if not result.ok:
32
+ out.setdefault(check.family, []).append(check.label)
33
+ return out
34
+
35
+ def families_present(self) -> set:
36
+ return {check.family for check, _ in self.results}
37
+
38
+
39
+ def evaluate(module, bugs=frozenset()) -> list:
40
+ return [(check, run_check(check)) for check in module.checks(bugs)]
41
+
42
+
43
+ def build_rows(problems=None) -> list:
44
+ rows = []
45
+ for name, module in PROBLEMS.items():
46
+ if problems and name not in problems:
47
+ continue
48
+ rows.append(Row(name, None, evaluate(module)))
49
+ for bug in module.BUGS:
50
+ rows.append(Row(name, bug, evaluate(module, frozenset([bug]))))
51
+ return rows
52
+
53
+
54
+ def _cell(row: Row, family: str) -> str:
55
+ if family not in row.families_present():
56
+ return "n/a"
57
+ flagged = row.flagged().get(family)
58
+ if not flagged:
59
+ return "no" if row.bug else "ok"
60
+ return "FLAGGED" if row.bug else "FALSE ALARM"
61
+
62
+
63
+ def to_markdown(rows) -> str:
64
+ head = "| Problem | Bug | " + " | ".join(FAMILIES) + " | Predicted by design |\n"
65
+ head += "|---|---|" + "---|" * (len(FAMILIES) + 1) + "\n"
66
+ lines = []
67
+ for row in rows:
68
+ predicted = "baseline (all should pass)" if row.bug is None else ", ".join(
69
+ sorted(PROBLEMS[row.problem].BUGS[row.bug].expected)
70
+ ) or "none (blind spot)"
71
+ cells = " | ".join(_cell(row, f) for f in FAMILIES)
72
+ lines.append(f"| {row.problem} | {row.bug or '(none)'} | {cells} | {predicted} |")
73
+ bug_rows = [r for r in rows if r.bug]
74
+ caught = [r for r in bug_rows if r.flagged()]
75
+ missed = [r for r in bug_rows if not r.flagged()]
76
+ false_alarms = [r for r in rows if r.bug is None and r.flagged()]
77
+ by_family = {f: sum(1 for r in bug_rows if f in r.flagged()) for f in FAMILIES}
78
+ only_one = {f: sum(1 for r in bug_rows if set(r.flagged()) == {f}) for f in FAMILIES}
79
+ summary = [
80
+ "",
81
+ f"Bugs seeded: {len(bug_rows)}. Flagged by at least one check: {len(caught)}. Missed by all: {len(missed)}"
82
+ + (" (" + ", ".join(f"{r.problem}/{r.bug}" for r in missed) + ")" if missed else "")
83
+ + ".",
84
+ f"Bug-free baselines with a false alarm: {len(false_alarms)} of {len(rows) - len(bug_rows)}.",
85
+ "Bugs flagged per family: " + ", ".join(f"{f} {n}" for f, n in by_family.items()) + ".",
86
+ "Bugs flagged by that family alone: " + ", ".join(f"{f} {n}" for f, n in only_one.items()) + ".",
87
+ ]
88
+ return head + "\n".join(lines) + "\n" + "\n".join(summary) + "\n"
89
+
90
+
91
+ def details(rows) -> str:
92
+ """Per-check messages, for reading why a cell is what it is."""
93
+ out = []
94
+ for row in rows:
95
+ out.append(f"### {row.problem} / {row.bug or '(baseline)'}")
96
+ for check, result in row.results:
97
+ out.append(f"- {check.family} [{check.label}] {result.status}: {result.message}")
98
+ out.append("")
99
+ return "\n".join(out)
100
+
101
+
102
+ def catalogue(rows) -> str:
103
+ """One entry per seeded bug: what is changed, the expected symptom, predicted and observed families."""
104
+ out = [
105
+ "# Seeded-bug catalogue",
106
+ "",
107
+ "Generated by `python -m physverify.matrix results/detection_matrix.md`. Each bug is switched on by",
108
+ "name in the problem's solver (search the source for the bug's name to see the exact change).",
109
+ "Predictions and symptoms were written from reasoning before the checks were run.",
110
+ "",
111
+ ]
112
+ for row in rows:
113
+ if row.bug is None:
114
+ continue
115
+ info = PROBLEMS[row.problem].BUGS[row.bug]
116
+ flagged = sorted(row.flagged())
117
+ out += [
118
+ f"## {row.problem} / {row.bug}",
119
+ "",
120
+ f"- Change: {info.description}",
121
+ f"- Expected symptom: {info.symptom or '(not recorded)'}",
122
+ f"- Predicted to be flagged by: {', '.join(sorted(info.expected)) or 'nothing (blind spot)'}",
123
+ f"- Flagged by: {', '.join(flagged) or 'nothing'}",
124
+ "",
125
+ ]
126
+ return "\n".join(out)
127
+
128
+
129
+ def by_check_markdown(rows, problem: str) -> str:
130
+ """One column per individual check, for a problem whose checks differ in how they refine."""
131
+ rows = [r for r in rows if r.problem == problem]
132
+ labels = [(c.family, c.label) for c, _ in rows[0].results]
133
+ head = "| Bug | " + " | ".join(f"{fam}: {label}" for fam, label in labels) + " |\n"
134
+ head += "|---|" + "---|" * len(labels) + "\n"
135
+ lines = []
136
+ for row in rows:
137
+ cells = []
138
+ for _, result in row.results:
139
+ cells.append(("ok" if row.bug is None else "no") if result.ok else ("FALSE ALARM" if row.bug is None else "FLAGGED"))
140
+ lines.append(f"| {row.bug or '(none)'} | " + " | ".join(cells) + " |")
141
+ return head + "\n".join(lines) + "\n"
142
+
143
+
144
+ def main(argv=None) -> int:
145
+ argv = sys.argv[1:] if argv is None else argv
146
+ rows = build_rows()
147
+ text = to_markdown(rows)
148
+ print(text)
149
+ if argv:
150
+ path = Path(argv[0])
151
+ path.parent.mkdir(parents=True, exist_ok=True)
152
+ path.write_text(text, encoding="utf-8")
153
+ path.with_name(path.stem + "_details.md").write_text(details(rows), encoding="utf-8")
154
+ path.with_name("heat1d_by_check.md").write_text(by_check_markdown(rows, "heat1d"), encoding="utf-8")
155
+ path.with_name("bug_catalogue.md").write_text(catalogue(rows), encoding="utf-8")
156
+ return 0
157
+
158
+
159
+ if __name__ == "__main__":
160
+ raise SystemExit(main())
physverify/mms.py ADDED
@@ -0,0 +1,118 @@
1
+ """Method of Manufactured Solutions for systems of ODEs.
2
+
3
+ You pick a smooth function u(t). Substituting it into an *independent*
4
+ reference operator f_ref leaves a residual s(t) = u'(t) - f_ref(t, u(t)).
5
+ The code under test is then run on y' = f_test(t, y) + s(t). If f_test
6
+ agrees with f_ref, u(t) is its exact solution and the error must shrink at the
7
+ scheme's design order. If f_test differs, the error stops shrinking.
8
+
9
+ Two points that are easy to get wrong:
10
+
11
+ * The reference operator must be written independently of the code under
12
+ test. If s(t) is built from the code's own right-hand side, a wrong
13
+ right-hand side passes.
14
+ * The error is measured at a fixed final time, so the code must be driven for
15
+ an exact number of steps; see the `integrate` contract below.
16
+
17
+ The `integrate` contract used throughout:
18
+
19
+ integrate(source, y0, t_end, n_steps) -> state at t_end
20
+
21
+ `source(t)` returns the additive forcing (a vector, or 0.0). The adapter for a
22
+ given code decides how to inject it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Callable
28
+
29
+ import numpy as np
30
+
31
+ from .checks import OrderResult
32
+ from .order import check_order
33
+
34
+
35
+ def zero_source(t):
36
+ return 0.0
37
+
38
+
39
+ def complex_step_derivative(u: Callable, t: float, h: float = 1e-30) -> np.ndarray:
40
+ """du/dt to machine precision, for u written with NumPy functions of t.
41
+
42
+ u must accept a complex t and avoid abs(), max() and similar non-analytic
43
+ operations.
44
+ """
45
+ return np.imag(np.asarray(u(t + 1j * h), dtype=complex)) / h
46
+
47
+
48
+ class ManufacturedODE:
49
+ def __init__(self, u: Callable, rhs_ref: Callable, du: Callable | None = None):
50
+ self._u = u
51
+ self.rhs_ref = rhs_ref
52
+ self._du = du
53
+
54
+ def value(self, t: float) -> np.ndarray:
55
+ return np.asarray(self._u(t), dtype=float)
56
+
57
+ def derivative(self, t: float) -> np.ndarray:
58
+ if self._du is not None:
59
+ return np.asarray(self._du(t), dtype=float)
60
+ return complex_step_derivative(self._u, t)
61
+
62
+ def forcing(self, t: float) -> np.ndarray:
63
+ return self.derivative(t) - np.asarray(self.rhs_ref(t, self.value(t)), dtype=float)
64
+
65
+
66
+ def max_abs_error(a, b) -> float:
67
+ with np.errstate(all="ignore"):
68
+ return float(np.max(np.abs(np.asarray(a, dtype=float) - np.asarray(b, dtype=float))))
69
+
70
+
71
+ def mms_order_study(
72
+ integrate: Callable,
73
+ mms: ManufacturedODE,
74
+ t_end: float,
75
+ ns,
76
+ expected_order: float,
77
+ **order_kwargs,
78
+ ) -> OrderResult:
79
+ """Run `integrate` with the manufactured forcing at several step counts."""
80
+ y0 = mms.value(0.0)
81
+ y_exact = mms.value(t_end)
82
+ order_kwargs.setdefault("scale", float(np.max(np.abs(y_exact))))
83
+ hs, errs = [], []
84
+ for n in ns:
85
+ y_end = integrate(mms.forcing, y0, t_end, n)
86
+ hs.append(t_end / n)
87
+ errs.append(max_abs_error(y_end, y_exact))
88
+ return check_order(hs, errs, expected_order, **order_kwargs)
89
+
90
+
91
+ def mms_trajectory_study(
92
+ integrate_trajectory: Callable,
93
+ mms: ManufacturedODE,
94
+ t_end: float,
95
+ ns,
96
+ expected_order: float,
97
+ **order_kwargs,
98
+ ) -> OrderResult:
99
+ """Like `mms_order_study`, but the error is the largest one over the whole run.
100
+
101
+ `integrate_trajectory(source, y0, t_end, n_steps)` returns the n_steps + 1
102
+ states at t_k = k * t_end / n_steps, starting with y0. A code whose error
103
+ happens to cancel at the final time, or which goes wrong part way through
104
+ and recovers, is caught here and not by the final-state check.
105
+ """
106
+ y0 = mms.value(0.0)
107
+ hs, errs, scale = [], [], 0.0
108
+ for n in ns:
109
+ states = np.asarray(integrate_trajectory(mms.forcing, y0, t_end, n), dtype=float)
110
+ if states.shape[0] != n + 1:
111
+ raise ValueError(f"integrate_trajectory returned {states.shape[0]} states for {n} steps; expected {n + 1}")
112
+ times = np.linspace(0.0, t_end, n + 1)
113
+ exact = np.array([mms.value(t) for t in times])
114
+ scale = max(scale, float(np.max(np.abs(exact))))
115
+ hs.append(t_end / n)
116
+ errs.append(max_abs_error(states, exact))
117
+ order_kwargs.setdefault("scale", scale)
118
+ return check_order(hs, errs, expected_order, **order_kwargs)
physverify/order.py ADDED
@@ -0,0 +1,131 @@
1
+ """Observed order of accuracy from errors at several resolutions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .checks import EXACT, FAIL, INCONCLUSIVE, PASS, OrderResult
8
+
9
+
10
+ def pairwise_orders(hs, errors) -> np.ndarray:
11
+ """p_i = log(e_i / e_{i+1}) / log(h_i / h_{i+1}) for consecutive resolutions."""
12
+ hs = np.asarray(hs, dtype=float)
13
+ errs = np.asarray(errors, dtype=float)
14
+ return np.log(errs[:-1] / errs[1:]) / np.log(hs[:-1] / hs[1:])
15
+
16
+
17
+ def fit_order(hs, errors) -> float:
18
+ """Least-squares slope of log(error) against log(h)."""
19
+ return float(np.polyfit(np.log(hs), np.log(errors), 1)[0])
20
+
21
+
22
+ def effective_floor(floor: float, scale: float, rel: float = 1e-13) -> float:
23
+ """Round-off grows with the size of the solution, so the noise floor does too.
24
+
25
+ The floor is the larger of the absolute `floor` (right for solutions of size
26
+ up to about 10) and `rel` times the solution's size (about 500 machine
27
+ epsilons, room for round-off accumulated over a few hundred steps). A
28
+ non-finite scale (a diverged run) leaves the floor unchanged so that the
29
+ non-finite error is still reported.
30
+ """
31
+ scale = abs(float(scale))
32
+ if not np.isfinite(scale):
33
+ return floor
34
+ return max(floor, rel * scale)
35
+
36
+
37
+ def check_exact(hs, errors, *, floor: float = 1e-12, scale: float = 1.0) -> OrderResult:
38
+ """Require the error to be at round-off level at every resolution.
39
+
40
+ For a manufactured solution that lies inside the scheme's discrete solution
41
+ space (a "patch test": for example, polynomials a finite-difference stencil
42
+ differentiates exactly), a correct code reproduces it to round-off whatever
43
+ the step sizes. Order of convergence cannot see an error that is as small
44
+ as the scheme's own truncation error and shrinks at the same rate; this
45
+ can, because the expected error is zero rather than small.
46
+
47
+ EXACT every error is at most `floor`
48
+ FAIL some error is larger, or non-finite
49
+
50
+ `scale` is the size of the solution; see `effective_floor`.
51
+ """
52
+ floor = effective_floor(floor, scale)
53
+ hs = np.asarray(hs, dtype=float)
54
+ errs = np.asarray(errors, dtype=float)
55
+ order = np.argsort(-hs)
56
+ hs, errs = hs[order], errs[order]
57
+ base = dict(expected=float("inf"), hs=tuple(hs), errors=tuple(errs))
58
+ if not np.all(np.isfinite(errs)):
59
+ return OrderResult(FAIL, "non-finite error: the solution diverged or produced NaN", **base)
60
+ worst = float(errs.max())
61
+ if worst <= floor:
62
+ return OrderResult(EXACT, f"reproduced to round-off (largest error {worst:.2g}), as designed", **base)
63
+ msg = f"expected an exact solution to round-off, but the error reaches {worst:.3g}"
64
+ measurable = errs > floor
65
+ fitted = float("nan")
66
+ if measurable.sum() >= 3:
67
+ fitted = fit_order(hs[measurable], errs[measurable])
68
+ msg += f" and shrinks at about order {fitted:.2f}"
69
+ return OrderResult(FAIL, msg, fitted=fitted, **base)
70
+
71
+
72
+ def check_order(
73
+ hs,
74
+ errors,
75
+ expected: float,
76
+ *,
77
+ tol_low: float = 0.3,
78
+ tol_high: float = 1.0,
79
+ floor: float = 1e-12,
80
+ fit_points: int = 4,
81
+ scale: float = 1.0,
82
+ ) -> OrderResult:
83
+ """Compare the observed order of convergence with the order the code claims.
84
+
85
+ PASS fitted order lies in [expected - tol_low, expected + tol_high]
86
+ EXACT every error is below `floor`, so the order cannot be measured
87
+ FAIL order too low or too high, error plateau, or non-finite error
88
+ INCONCLUSIVE fewer than two resolutions are above the noise floor
89
+
90
+ `floor` is the error level below which round-off dominates, for a solution
91
+ of size about 1. Pass `scale` (the size of the solution, for example the
92
+ largest absolute value of the exact answer) and the floor grows with it.
93
+ """
94
+ floor = effective_floor(floor, scale)
95
+ hs = np.asarray(hs, dtype=float)
96
+ errs = np.asarray(errors, dtype=float)
97
+ order = np.argsort(-hs) # coarse to fine
98
+ hs, errs = hs[order], errs[order]
99
+ base = dict(expected=float(expected), hs=tuple(hs), errors=tuple(errs))
100
+
101
+ if not np.all(np.isfinite(errs)):
102
+ return OrderResult(FAIL, "non-finite error: the solution diverged or produced NaN", **base)
103
+
104
+ measurable = errs > floor
105
+ if not measurable.any():
106
+ return OrderResult(EXACT, f"error below {floor:g} at every resolution; order not measurable", **base)
107
+
108
+ h_m, e_m = hs[measurable], errs[measurable]
109
+ if len(e_m) < 2:
110
+ return OrderResult(INCONCLUSIVE, "fewer than 2 resolutions above the noise floor", **base)
111
+
112
+ if e_m[-2] / e_m[-1] < 1.2:
113
+ return OrderResult(
114
+ FAIL,
115
+ f"error does not decrease with resolution (about {e_m[-1]:.3g} at the finest level)",
116
+ fitted=0.0,
117
+ **base,
118
+ )
119
+
120
+ h_f, e_f = h_m[-fit_points:], e_m[-fit_points:]
121
+ p = fit_order(h_f, e_f)
122
+ if p < expected - tol_low:
123
+ msg = f"observed order {p:.2f} is below the expected {expected:g}"
124
+ status = FAIL
125
+ elif p > expected + tol_high:
126
+ msg = f"observed order {p:.2f} is above the expected {expected:g}; check the expected order"
127
+ status = FAIL
128
+ else:
129
+ msg = f"observed order {p:.2f} (expected {expected:g})"
130
+ status = PASS
131
+ return OrderResult(status, msg, fitted=p, **base)
@@ -0,0 +1,6 @@
1
+ """Seeded-bug benchmark problems.
2
+
3
+ Each problem module defines a reference (independent) implementation of the
4
+ physics, a solver with switchable bugs, and `checks(bugs)` returning the
5
+ checks to run on that solver.
6
+ """