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