physverify 0.4.1__tar.gz → 0.4.2__tar.gz

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 (80) hide show
  1. physverify-0.4.2/PKG-INFO +89 -0
  2. physverify-0.4.2/README.md +48 -0
  3. {physverify-0.4.1 → physverify-0.4.2}/pyproject.toml +4 -3
  4. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/__init__.py +1 -1
  5. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/checks.py +1 -0
  6. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/cli.py +15 -1
  7. physverify-0.4.2/src/physverify/demo.py +75 -0
  8. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/matrix.py +121 -5
  9. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/order.py +13 -2
  10. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/report.py +20 -2
  11. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/scaffold.py +8 -0
  12. physverify-0.4.2/src/physverify.egg-info/PKG-INFO +89 -0
  13. {physverify-0.4.1 → physverify-0.4.2}/src/physverify.egg-info/SOURCES.txt +6 -1
  14. {physverify-0.4.1 → physverify-0.4.2}/src/physverify.egg-info/requires.txt +5 -0
  15. physverify-0.4.2/tests/test_awkward_input.py +74 -0
  16. physverify-0.4.2/tests/test_docs_examples.py +28 -0
  17. physverify-0.4.2/tests/test_figures.py +14 -0
  18. physverify-0.4.2/tests/test_versions.py +21 -0
  19. physverify-0.4.1/PKG-INFO +0 -220
  20. physverify-0.4.1/README.md +0 -183
  21. physverify-0.4.1/src/physverify.egg-info/PKG-INFO +0 -220
  22. {physverify-0.4.1 → physverify-0.4.2}/LICENSE +0 -0
  23. {physverify-0.4.1 → physverify-0.4.2}/setup.cfg +0 -0
  24. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/__main__.py +0 -0
  25. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/adapters/__init__.py +0 -0
  26. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/adapters/double_pendulum.py +0 -0
  27. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/adapters/lorenz.py +0 -0
  28. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/adapters/projectile_v1.py +0 -0
  29. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/adapters/projectile_v2.py +0 -0
  30. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/adapters/three_body.py +0 -0
  31. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/agent.py +0 -0
  32. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/butcher.py +0 -0
  33. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/compare.py +0 -0
  34. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/constants.py +0 -0
  35. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/loaders.py +0 -0
  36. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/method_bugs.py +0 -0
  37. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/mms.py +0 -0
  38. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/problems/__init__.py +0 -0
  39. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/problems/_common.py +0 -0
  40. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/problems/heat1d.py +0 -0
  41. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/problems/kepler.py +0 -0
  42. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/problems/pendulum.py +0 -0
  43. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/problems/projectile.py +0 -0
  44. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/problems/sod.py +0 -0
  45. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/pytest_plugin.py +0 -0
  46. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/references.py +0 -0
  47. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/smoke.py +0 -0
  48. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/steppers.py +0 -0
  49. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/__init__.py +0 -0
  50. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/calibrate.py +0 -0
  51. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/checks.py +0 -0
  52. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/exact.py +0 -0
  53. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/ising.py +0 -0
  54. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/matrix.py +0 -0
  55. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/reference.py +0 -0
  56. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/sampler.py +0 -0
  57. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/stochastic/stats.py +0 -0
  58. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/studies.py +0 -0
  59. {physverify-0.4.1 → physverify-0.4.2}/src/physverify/symbolic.py +0 -0
  60. {physverify-0.4.1 → physverify-0.4.2}/src/physverify.egg-info/dependency_links.txt +0 -0
  61. {physverify-0.4.1 → physverify-0.4.2}/src/physverify.egg-info/entry_points.txt +0 -0
  62. {physverify-0.4.1 → physverify-0.4.2}/src/physverify.egg-info/top_level.txt +0 -0
  63. {physverify-0.4.1 → physverify-0.4.2}/tests/test_agent.py +0 -0
  64. {physverify-0.4.1 → physverify-0.4.2}/tests/test_batch7_lessons.py +0 -0
  65. {physverify-0.4.1 → physverify-0.4.2}/tests/test_butcher_properties.py +0 -0
  66. {physverify-0.4.1 → physverify-0.4.2}/tests/test_cli.py +0 -0
  67. {physverify-0.4.1 → physverify-0.4.2}/tests/test_corpus_lessons.py +0 -0
  68. {physverify-0.4.1 → physverify-0.4.2}/tests/test_ising.py +0 -0
  69. {physverify-0.4.1 → physverify-0.4.2}/tests/test_method_bugs.py +0 -0
  70. {physverify-0.4.1 → physverify-0.4.2}/tests/test_mms.py +0 -0
  71. {physverify-0.4.1 → physverify-0.4.2}/tests/test_new_problems.py +0 -0
  72. {physverify-0.4.1 → physverify-0.4.2}/tests/test_order.py +0 -0
  73. {physverify-0.4.1 → physverify-0.4.2}/tests/test_properties.py +0 -0
  74. {physverify-0.4.1 → physverify-0.4.2}/tests/test_real_projects.py +0 -0
  75. {physverify-0.4.1 → physverify-0.4.2}/tests/test_reference_problems.py +0 -0
  76. {physverify-0.4.1 → physverify-0.4.2}/tests/test_references.py +0 -0
  77. {physverify-0.4.1 → physverify-0.4.2}/tests/test_scaffold.py +0 -0
  78. {physverify-0.4.1 → physverify-0.4.2}/tests/test_seeded_bugs.py +0 -0
  79. {physverify-0.4.1 → physverify-0.4.2}/tests/test_studies.py +0 -0
  80. {physverify-0.4.1 → physverify-0.4.2}/tests/test_v040_helpers.py +0 -0
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: physverify
3
+ Version: 0.4.2
4
+ Summary: Verification checks for physics simulation code: manufactured solutions, order-of-accuracy studies and a seeded-bug benchmark.
5
+ Author: Ram Chandra Giri
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rcgiri-physics/physverify
8
+ Project-URL: Documentation, https://rcgiri-physics.github.io/physverify/
9
+ Project-URL: Issues, https://github.com/rcgiri-physics/physverify/issues
10
+ Project-URL: Changelog, https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md
11
+ Keywords: verification,method of manufactured solutions,numerical methods,physics,simulation,testing
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Physics
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Intended Audience :: Science/Research
23
+ Classifier: Intended Audience :: Education
24
+ Classifier: Topic :: Software Development :: Testing
25
+ Classifier: Development Status :: 3 - Alpha
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.24
30
+ Provides-Extra: symbolic
31
+ Requires-Dist: sympy>=1.12; extra == "symbolic"
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=8; extra == "dev"
34
+ Requires-Dist: pytest-cov>=6.1; extra == "dev"
35
+ Requires-Dist: hypothesis>=6; extra == "dev"
36
+ Requires-Dist: sympy>=1.12; extra == "dev"
37
+ Provides-Extra: docs
38
+ Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
39
+ Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
40
+ Dynamic: license-file
41
+
42
+ # physverify
43
+
44
+ [![tests](https://github.com/rcgiri-physics/physverify/actions/workflows/ci.yml/badge.svg)](https://github.com/rcgiri-physics/physverify/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/physverify)](https://pypi.org/project/physverify/) [![Python](https://img.shields.io/pypi/pyversions/physverify)](https://pypi.org/project/physverify/) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23534512.svg)](https://doi.org/10.5281/zenodo.23534512) [![Docs](https://img.shields.io/badge/docs-site-blue)](https://rcgiri-physics.github.io/physverify/)
45
+
46
+ **Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
47
+
48
+ physverify is actively evolving (v0.4.2), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
49
+
50
+ ![The check loop: your solver and an independent description of the physics go in; physverify runs the solver at several resolutions, measures the error and reports a verdict with a reason](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/check-loop.svg)
51
+
52
+ ```bash
53
+ pip install physverify
54
+ physverify demo
55
+ ```
56
+
57
+ The demo checks a correct solver (all checks pass), then the same solver with the damping 10% too large (all fail):
58
+
59
+ ![Error against step size: the correct solver follows the slope-2 line, the broken one levels off](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/convergence.svg)
60
+
61
+ ## Use it on your own code
62
+
63
+ - **An ODE solver:** `physverify init verify_solver.py --solver mysolver --order 4` writes a spec; fill in the equations (from the physics, not from your code) and run `physverify run verify_solver.py`.
64
+ - **A Runge-Kutta table:** `check_tableau(A, b, c, claimed=3)` from `physverify.butcher` checks its order, stage order and stability from the coefficients alone.
65
+ - **Errors you already have:** `check_order(hs, errors, expected=2)` gives PASS, FAIL or INCONCLUSIVE with a reason.
66
+
67
+ ## Run it in CI
68
+
69
+ A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
70
+
71
+ ```yaml
72
+ - uses: rcgiri-physics/physverify@v0.4.2
73
+ with:
74
+ specs: tests/verify_*.py
75
+ ```
76
+
77
+ [Getting started](https://rcgiri-physics.github.io/physverify/getting-started/) · [Recipes](https://rcgiri-physics.github.io/physverify/recipes/) · [What a PASS means](https://rcgiri-physics.github.io/physverify/assumptions/) · [Troubleshooting](https://rcgiri-physics.github.io/physverify/troubleshooting/) · [API](https://rcgiri-physics.github.io/physverify/api/)
78
+
79
+ ## Does it catch bugs?
80
+
81
+ Of 37 bugs seeded into five small solvers, it flagged 35 (plain "compare with one answer" tests: 27), with no false alarms. The bugs and checks were written by the same person, so this shows the tool works as designed, not how it does on other people's code. On public physics repositories it found real bugs, for example Runge-Kutta tables with a wrong order label; the counts are not a bug rate. Details: [results](https://rcgiri-physics.github.io/physverify/results/), and [results/README.md](https://github.com/rcgiri-physics/physverify/blob/main/results/README.md) to regenerate every table.
82
+
83
+ ## Limits
84
+
85
+ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a source term, or a small wrapper that can itself be wrong. A PASS means these checks found nothing wrong; it does not prove the simulation correct, and it is only as independent as the reference you wrote: see [What a PASS means](https://rcgiri-physics.github.io/physverify/assumptions/).
86
+
87
+ ## Contributing, citing, licence
88
+
89
+ Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md). To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
@@ -0,0 +1,48 @@
1
+ # physverify
2
+
3
+ [![tests](https://github.com/rcgiri-physics/physverify/actions/workflows/ci.yml/badge.svg)](https://github.com/rcgiri-physics/physverify/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/physverify)](https://pypi.org/project/physverify/) [![Python](https://img.shields.io/pypi/pyversions/physverify)](https://pypi.org/project/physverify/) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23534512.svg)](https://doi.org/10.5281/zenodo.23534512) [![Docs](https://img.shields.io/badge/docs-site-blue)](https://rcgiri-physics.github.io/physverify/)
4
+
5
+ **Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
6
+
7
+ physverify is actively evolving (v0.4.2), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
8
+
9
+ ![The check loop: your solver and an independent description of the physics go in; physverify runs the solver at several resolutions, measures the error and reports a verdict with a reason](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/check-loop.svg)
10
+
11
+ ```bash
12
+ pip install physverify
13
+ physverify demo
14
+ ```
15
+
16
+ The demo checks a correct solver (all checks pass), then the same solver with the damping 10% too large (all fail):
17
+
18
+ ![Error against step size: the correct solver follows the slope-2 line, the broken one levels off](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/convergence.svg)
19
+
20
+ ## Use it on your own code
21
+
22
+ - **An ODE solver:** `physverify init verify_solver.py --solver mysolver --order 4` writes a spec; fill in the equations (from the physics, not from your code) and run `physverify run verify_solver.py`.
23
+ - **A Runge-Kutta table:** `check_tableau(A, b, c, claimed=3)` from `physverify.butcher` checks its order, stage order and stability from the coefficients alone.
24
+ - **Errors you already have:** `check_order(hs, errors, expected=2)` gives PASS, FAIL or INCONCLUSIVE with a reason.
25
+
26
+ ## Run it in CI
27
+
28
+ A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
29
+
30
+ ```yaml
31
+ - uses: rcgiri-physics/physverify@v0.4.2
32
+ with:
33
+ specs: tests/verify_*.py
34
+ ```
35
+
36
+ [Getting started](https://rcgiri-physics.github.io/physverify/getting-started/) · [Recipes](https://rcgiri-physics.github.io/physverify/recipes/) · [What a PASS means](https://rcgiri-physics.github.io/physverify/assumptions/) · [Troubleshooting](https://rcgiri-physics.github.io/physverify/troubleshooting/) · [API](https://rcgiri-physics.github.io/physverify/api/)
37
+
38
+ ## Does it catch bugs?
39
+
40
+ Of 37 bugs seeded into five small solvers, it flagged 35 (plain "compare with one answer" tests: 27), with no false alarms. The bugs and checks were written by the same person, so this shows the tool works as designed, not how it does on other people's code. On public physics repositories it found real bugs, for example Runge-Kutta tables with a wrong order label; the counts are not a bug rate. Details: [results](https://rcgiri-physics.github.io/physverify/results/), and [results/README.md](https://github.com/rcgiri-physics/physverify/blob/main/results/README.md) to regenerate every table.
41
+
42
+ ## Limits
43
+
44
+ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a source term, or a small wrapper that can itself be wrong. A PASS means these checks found nothing wrong; it does not prove the simulation correct, and it is only as independent as the reference you wrote: see [What a PASS means](https://rcgiri-physics.github.io/physverify/assumptions/).
45
+
46
+ ## Contributing, citing, licence
47
+
48
+ Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md). To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "physverify"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Verification checks for physics simulation code: manufactured solutions, order-of-accuracy studies and a seeded-bug benchmark."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -32,13 +32,14 @@ dependencies = ["numpy>=1.24"]
32
32
 
33
33
  [project.urls]
34
34
  Homepage = "https://github.com/rcgiri-physics/physverify"
35
- Documentation = "https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md"
35
+ Documentation = "https://rcgiri-physics.github.io/physverify/"
36
36
  Issues = "https://github.com/rcgiri-physics/physverify/issues"
37
37
  Changelog = "https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md"
38
38
 
39
39
  [project.optional-dependencies]
40
40
  symbolic = ["sympy>=1.12"]
41
- dev = ["pytest>=8", "hypothesis>=6", "sympy>=1.12"]
41
+ dev = ["pytest>=8", "pytest-cov>=6.1", "hypothesis>=6", "sympy>=1.12"]
42
+ docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5,<10"]
42
43
 
43
44
  [project.scripts]
44
45
  physverify = "physverify.cli:main"
@@ -6,7 +6,7 @@ from .order import check_order, fit_order, pairwise_orders
6
6
  from .order import check_exact
7
7
  from .studies import invariant_study, ode_checks, patch_study, reference_study
8
8
 
9
- __version__ = "0.4.1"
9
+ __version__ = "0.4.2"
10
10
 
11
11
  __all__ = [
12
12
  "Check",
@@ -42,6 +42,7 @@ class OrderResult(Result):
42
42
  fitted: float = float("nan")
43
43
  hs: tuple = ()
44
44
  errors: tuple = ()
45
+ acceptance: str = "" # the rule a PASS had to meet, in words (reported in the JSON output)
45
46
 
46
47
 
47
48
  @dataclass
@@ -1,6 +1,7 @@
1
1
  """Command line.
2
2
 
3
3
  physverify run SPEC.py [SPEC.py ...] [--json FILE|-] [--html FILE] [--markdown FILE] [--quiet]
4
+ physverify demo (a correct and a broken solver, no files needed)
4
5
  physverify init [SPEC.py] [--solver MODULE] [--function NAME] [--dim N] [--order P]
5
6
  physverify matrix [OUT.md]
6
7
  physverify compare [OUT.md]
@@ -42,7 +43,7 @@ def expand_specs(patterns) -> list[str]:
42
43
 
43
44
 
44
45
  def _cmd_run(args) -> int:
45
- from .report import load_spec, run_checks, to_html, to_json, to_markdown, to_text
46
+ from .report import NOTE, load_spec, run_checks, to_html, to_json, to_markdown, to_text
46
47
 
47
48
  try:
48
49
  args.specs = expand_specs(args.specs)
@@ -56,6 +57,9 @@ def _cmd_run(args) -> int:
56
57
  except Exception as exc:
57
58
  print(f"physverify: cannot load {spec}: {type(exc).__name__}: {exc}", file=sys.stderr)
58
59
  return 2
60
+ if not checks:
61
+ print(f"physverify: {spec}: checks() returned no checks, so nothing was tested", file=sys.stderr)
62
+ return 2
59
63
  outcomes += run_checks(checks, args.budget)
60
64
  source = ", ".join(str(Path(s)) for s in args.specs)
61
65
  if args.json == "-":
@@ -63,6 +67,7 @@ def _cmd_run(args) -> int:
63
67
  else:
64
68
  if not args.quiet:
65
69
  print(to_text(outcomes))
70
+ print(NOTE)
66
71
  if args.json:
67
72
  Path(args.json).write_text(to_json(outcomes, source), encoding="utf-8")
68
73
  if args.html:
@@ -87,6 +92,12 @@ def _cmd_init(args) -> int:
87
92
  return 0
88
93
 
89
94
 
95
+ def _cmd_demo(args) -> int:
96
+ from .demo import main as demo_main
97
+
98
+ return demo_main()
99
+
100
+
90
101
  def _cmd_matrix(args) -> int:
91
102
  from . import matrix
92
103
 
@@ -149,6 +160,9 @@ def build_parser() -> argparse.ArgumentParser:
149
160
  " and a check still running at twice the budget is reported INCONCLUSIVE")
150
161
  run.set_defaults(func=_cmd_run)
151
162
 
163
+ d = sub.add_parser("demo", help="run the checks on a correct and on a broken example solver (no files needed)")
164
+ d.set_defaults(func=_cmd_demo)
165
+
152
166
  i = sub.add_parser("init", help="write a starter spec for a fixed-step ODE solver")
153
167
  i.add_argument("spec", nargs="?", default="verify_solver.py", help="spec file to write (default verify_solver.py)")
154
168
  i.add_argument("--solver", default="solver", help="module that holds the solver, importable from the spec's folder (default solver)")
@@ -0,0 +1,75 @@
1
+ """`physverify demo`: the damped-oscillator example, shipped in the package.
2
+
3
+ It runs the checks on a correct midpoint solver, then on a copy whose damping
4
+ is 10% too large, so a first run needs only `pip install physverify`.
5
+ The same solver and spec, as files to copy, are in examples/ in the repository.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from .studies import ode_checks
13
+ from .mms import ManufacturedODE
14
+
15
+ OMEGA, GAMMA = 3.0, 0.4
16
+
17
+
18
+ def make_solver(damping_factor: float = 1.0):
19
+ """Midpoint method for x'' = -omega^2 x - 2 gamma x', with an additive source term."""
20
+
21
+ def rhs(t, y):
22
+ damping = 2.0 * GAMMA * damping_factor
23
+ return np.array([y[1], -(OMEGA**2) * y[0] - damping * y[1]])
24
+
25
+ def integrate(source, y0, t_end, n_steps, trajectory=False):
26
+ h = t_end / n_steps
27
+ y = np.array(y0, dtype=float)
28
+ states = [y]
29
+ for i in range(n_steps):
30
+ t = i * h
31
+ k1 = rhs(t, y) + source(t)
32
+ mid = y + 0.5 * h * k1
33
+ y = y + h * (rhs(t + 0.5 * h, mid) + source(t + 0.5 * h))
34
+ states.append(y)
35
+ return np.array(states) if trajectory else y
36
+
37
+ return integrate
38
+
39
+
40
+ def reference_rhs(t, y):
41
+ """The equations, written from the physics, not copied from the solver."""
42
+ return np.array([y[1], -(OMEGA**2) * y[0] - 2.0 * GAMMA * y[1]])
43
+
44
+
45
+ def checks(damping_factor: float = 1.0):
46
+ integrate = make_solver(damping_factor)
47
+ mms = ManufacturedODE(lambda t: np.array([np.sin(1.3 * t) + 0.2, 1.04 * np.cos(1.3 * t)]), reference_rhs)
48
+ return ode_checks(
49
+ "midpoint damped oscillator",
50
+ integrate,
51
+ rhs_ref=reference_rhs,
52
+ mms=mms,
53
+ y0_free=np.array([1.0, 0.0]),
54
+ t_end=1.0,
55
+ ns=(10, 20, 40, 80, 160),
56
+ order=2.0,
57
+ patch=(np.array([0.5, -0.3]), np.array([0.8, 1.1])),
58
+ integrate_trajectory=lambda s, y0, t, n: integrate(s, y0, t, n, trajectory=True),
59
+ )
60
+
61
+
62
+ def main() -> int:
63
+ """Exit 0 when the correct solver passes and the broken one fails, as they should."""
64
+ from .report import NOTE, run_checks, to_text
65
+
66
+ print("1. A correct midpoint solver for a damped oscillator:\n")
67
+ good = run_checks(checks())
68
+ print(to_text(good))
69
+ print("\n2. The same solver with the damping 10% too large (a typical slip):\n")
70
+ bad = run_checks(checks(damping_factor=1.1))
71
+ print(to_text(bad))
72
+ ok = not any(o.result.failed for o in good) and any(o.result.failed for o in bad)
73
+ print(f"\n{NOTE}")
74
+ print("\nTo check your own solver, start a spec with: physverify init verify_solver.py --solver mysolver --order 2")
75
+ return 0 if ok else 1
@@ -2,17 +2,23 @@
2
2
 
3
3
  python -m physverify.matrix results/detection_matrix.md
4
4
 
5
+ This also writes detection_matrix.json (every check's full status, message and
6
+ run time, with how and where it was made) and run_time.md next to it.
7
+
5
8
  The bugs were written by the author of this package, so the catch rate below is
6
9
  a measure of these bugs, not of bugs in general.
7
10
  """
8
11
 
9
12
  from __future__ import annotations
10
13
 
14
+ import json
11
15
  import sys
16
+ import time
17
+ from collections import Counter
12
18
  from dataclasses import dataclass, field
13
19
  from pathlib import Path
14
20
 
15
- from .checks import FAMILIES, Check, Result, run_check
21
+ from .checks import FAIL, FAMILIES, Check, Result, run_check
16
22
  from .problems import heat1d, kepler, pendulum, projectile, sod
17
23
 
18
24
  PROBLEMS = {m.NAME: m for m in (projectile, pendulum, kepler, heat1d, sod)}
@@ -23,6 +29,7 @@ class Row:
23
29
  problem: str
24
30
  bug: str | None # None is the bug-free baseline
25
31
  results: list = field(default_factory=list) # list of (Check, Result)
32
+ seconds: list = field(default_factory=list) # run time of each check, when measured
26
33
 
27
34
  def flagged(self) -> dict:
28
35
  """family -> labels of the checks that did not pass"""
@@ -32,6 +39,10 @@ class Row:
32
39
  out.setdefault(check.family, []).append(check.label)
33
40
  return out
34
41
 
42
+ def flagged_by_fail(self) -> bool:
43
+ """At least one check gave FAIL, a clean detection (not only INCONCLUSIVE or ERROR)."""
44
+ return any(result.status == FAIL for _, result in self.results)
45
+
35
46
  def families_present(self) -> set:
36
47
  return {check.family for check, _ in self.results}
37
48
 
@@ -40,14 +51,23 @@ def evaluate(module, bugs=frozenset()) -> list:
40
51
  return [(check, run_check(check)) for check in module.checks(bugs)]
41
52
 
42
53
 
54
+ def evaluate_timed(problem, bug) -> Row:
55
+ row = Row(problem, bug)
56
+ for check in PROBLEMS[problem].checks(frozenset([bug]) if bug else frozenset()):
57
+ start = time.perf_counter()
58
+ row.results.append((check, run_check(check)))
59
+ row.seconds.append(time.perf_counter() - start)
60
+ return row
61
+
62
+
43
63
  def build_rows(problems=None) -> list:
44
64
  rows = []
45
65
  for name, module in PROBLEMS.items():
46
66
  if problems and name not in problems:
47
67
  continue
48
- rows.append(Row(name, None, evaluate(module)))
68
+ rows.append(evaluate_timed(name, None))
49
69
  for bug in module.BUGS:
50
- rows.append(Row(name, bug, evaluate(module, frozenset([bug]))))
70
+ rows.append(evaluate_timed(name, bug))
51
71
  return rows
52
72
 
53
73
 
@@ -74,6 +94,7 @@ def to_markdown(rows) -> str:
74
94
  caught = [r for r in bug_rows if r.flagged()]
75
95
  missed = [r for r in bug_rows if not r.flagged()]
76
96
  false_alarms = [r for r in rows if r.bug is None and r.flagged()]
97
+ by_fail = [r for r in caught if r.flagged_by_fail()]
77
98
  by_family = {f: sum(1 for r in bug_rows if f in r.flagged()) for f in FAMILIES}
78
99
  only_one = {f: sum(1 for r in bug_rows if set(r.flagged()) == {f}) for f in FAMILIES}
79
100
  summary = [
@@ -81,6 +102,11 @@ def to_markdown(rows) -> str:
81
102
  f"Bugs seeded: {len(bug_rows)}. Flagged by at least one check: {len(caught)}. Missed by all: {len(missed)}"
82
103
  + (" (" + ", ".join(f"{r.problem}/{r.bug}" for r in missed) + ")" if missed else "")
83
104
  + ".",
105
+ f"Of the {len(caught)} flagged: {len(by_fail)} by at least one FAIL, {len(caught) - len(by_fail)} only by"
106
+ " INCONCLUSIVE or ERROR"
107
+ + (" (" + ", ".join(f"{r.problem}/{r.bug}" for r in caught if not r.flagged_by_fail()) + ")"
108
+ if len(by_fail) < len(caught) else "")
109
+ + ".",
84
110
  f"Bug-free baselines with a false alarm: {len(false_alarms)} of {len(rows) - len(bug_rows)}.",
85
111
  "Bugs flagged per family: " + ", ".join(f"{f} {n}" for f, n in by_family.items()) + ".",
86
112
  "Bugs flagged by that family alone: " + ", ".join(f"{f} {n}" for f, n in only_one.items()) + ".",
@@ -88,6 +114,73 @@ def to_markdown(rows) -> str:
88
114
  return head + "\n".join(lines) + "\n" + "\n".join(summary) + "\n"
89
115
 
90
116
 
117
+ def provenance() -> dict:
118
+ """How and where a result file was made; kept out of the Markdown so the committed tables stay comparable."""
119
+ import datetime
120
+ import platform
121
+ import subprocess
122
+
123
+ import numpy
124
+
125
+ from . import __version__
126
+
127
+ try:
128
+ commit = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10,
129
+ cwd=Path(__file__).resolve().parent).stdout.strip() or None
130
+ except (OSError, subprocess.SubprocessError):
131
+ commit = None
132
+ return {
133
+ "physverify": __version__,
134
+ "git_commit": commit,
135
+ "python": platform.python_version(),
136
+ "numpy": numpy.__version__,
137
+ "platform": platform.platform(),
138
+ "date": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
139
+ }
140
+
141
+
142
+ def to_json(rows) -> str:
143
+ """One record per (problem, bug, check) with the full status, message, prediction and run time."""
144
+ records = []
145
+ for row in rows:
146
+ predicted = sorted(PROBLEMS[row.problem].BUGS[row.bug].expected) if row.bug else []
147
+ seconds = row.seconds or [None] * len(row.results)
148
+ for (check, result), sec in zip(row.results, seconds):
149
+ records.append({
150
+ "problem": row.problem,
151
+ "bug": row.bug,
152
+ "family": check.family,
153
+ "check": check.label,
154
+ "status": result.status,
155
+ "message": result.message,
156
+ "predicted_to_flag": check.family in predicted,
157
+ "seconds": None if sec is None else round(sec, 4),
158
+ })
159
+ return json.dumps({"provenance": provenance(), "records": records}, indent=1) + "\n"
160
+
161
+
162
+ def run_time(rows) -> str:
163
+ """Total run time per check family and problem, over the baseline and every bug (not deterministic)."""
164
+ totals: dict = {}
165
+ for row in rows:
166
+ for (check, _), sec in zip(row.results, row.seconds):
167
+ totals.setdefault(row.problem, {}).setdefault(check.family, 0.0)
168
+ totals[row.problem][check.family] += sec
169
+ lines = [
170
+ "# Run time per check family",
171
+ "",
172
+ "Seconds, summed over the baseline and every seeded bug of each problem, on the machine recorded in",
173
+ "detection_matrix.json (`provenance`). Generated by `python scripts/reproduce_all.py`; it varies between runs.",
174
+ "",
175
+ "| Problem | " + " | ".join(FAMILIES) + " | Total |",
176
+ "|---|" + "---|" * (len(FAMILIES) + 1),
177
+ ]
178
+ for problem, fam in totals.items():
179
+ cells = " | ".join(f"{fam[f]:.2f}" if f in fam else "n/a" for f in FAMILIES)
180
+ lines.append(f"| {problem} | {cells} | {sum(fam.values()):.2f} |")
181
+ return "\n".join(lines) + "\n"
182
+
183
+
91
184
  def details(rows) -> str:
92
185
  """Per-check messages, for reading why a cell is what it is."""
93
186
  out = []
@@ -99,13 +192,28 @@ def details(rows) -> str:
99
192
  return "\n".join(out)
100
193
 
101
194
 
195
+ REPO = "https://github.com/rcgiri-physics/physverify/blob/main/"
196
+
197
+
198
+ def bug_switches(problem: str, bug: str) -> list:
199
+ """(path, line number, code) of every line that switches the bug on: `"bug" in bugs`."""
200
+ root = Path(__file__).resolve().parents[2]
201
+ files = [Path(PROBLEMS[problem].__file__), Path(__file__).resolve().parent / "problems" / "_common.py"]
202
+ out = []
203
+ for f in files:
204
+ for n, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
205
+ if f'"{bug}" in bugs' in line:
206
+ out.append((f.resolve().relative_to(root).as_posix(), n, line.strip()))
207
+ return out
208
+
209
+
102
210
  def catalogue(rows) -> str:
103
211
  """One entry per seeded bug: what is changed, the expected symptom, predicted and observed families."""
104
212
  out = [
105
213
  "# Seeded-bug catalogue",
106
214
  "",
107
215
  "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).",
216
+ "name in the problem's solver; the lines that switch it on are linked under each bug.",
109
217
  "Predictions and symptoms were written from reasoning before the checks were run.",
110
218
  "",
111
219
  ]
@@ -121,8 +229,14 @@ def catalogue(rows) -> str:
121
229
  f"- Expected symptom: {info.symptom or '(not recorded)'}",
122
230
  f"- Predicted to be flagged by: {', '.join(sorted(info.expected)) or 'nothing (blind spot)'}",
123
231
  f"- Flagged by: {', '.join(flagged) or 'nothing'}",
124
- "",
232
+ "- Outcome of its " + str(len(row.results)) + " checks: " + ", ".join(
233
+ f"{n} {s}" for s, n in sorted(Counter(r.status for _, r in row.results).items())),
125
234
  ]
235
+ switches = bug_switches(row.problem, row.bug)
236
+ if switches:
237
+ out.append("- Where it is switched on:")
238
+ out += [f" - [{path}:{n}]({REPO}{path}#L{n}): `{code}`" for path, n, code in switches]
239
+ out.append("")
126
240
  return "\n".join(out)
127
241
 
128
242
 
@@ -153,6 +267,8 @@ def main(argv=None) -> int:
153
267
  path.with_name(path.stem + "_details.md").write_text(details(rows), encoding="utf-8")
154
268
  path.with_name("heat1d_by_check.md").write_text(by_check_markdown(rows, "heat1d"), encoding="utf-8")
155
269
  path.with_name("bug_catalogue.md").write_text(catalogue(rows), encoding="utf-8")
270
+ path.with_suffix(".json").write_text(to_json(rows), encoding="utf-8")
271
+ path.with_name("run_time.md").write_text(run_time(rows), encoding="utf-8")
156
272
  return 0
157
273
 
158
274
 
@@ -54,7 +54,8 @@ def check_exact(hs, errors, *, floor: float = 1e-12, scale: float = 1.0) -> Orde
54
54
  errs = np.asarray(errors, dtype=float)
55
55
  order = np.argsort(-hs)
56
56
  hs, errs = hs[order], errs[order]
57
- base = dict(expected=float("inf"), hs=tuple(hs), errors=tuple(errs))
57
+ base = dict(expected=float("inf"), hs=tuple(hs), errors=tuple(errs),
58
+ acceptance=f"every error at most {floor:.2g} (round-off)")
58
59
  if not np.all(np.isfinite(errs)):
59
60
  return OrderResult(FAIL, "non-finite error: the solution diverged or produced NaN", **base)
60
61
  worst = float(errs.max())
@@ -100,9 +101,19 @@ def check_order(
100
101
  floor = effective_floor(floor, scale)
101
102
  hs = np.asarray(hs, dtype=float)
102
103
  errs = np.asarray(errors, dtype=float)
104
+ if hs.shape != errs.shape:
105
+ return OrderResult(INCONCLUSIVE, f"{hs.size} step sizes but {errs.size} errors; they must match",
106
+ expected=float(expected), hs=tuple(hs), errors=tuple(errs))
103
107
  order = np.argsort(-hs) # coarse to fine
104
108
  hs, errs = hs[order], errs[order]
105
- base = dict(expected=float(expected), hs=tuple(hs), errors=tuple(errs))
109
+ base = dict(expected=float(expected), hs=tuple(hs), errors=tuple(errs),
110
+ acceptance=f"fitted order within [{expected - tol_low:g}, {expected + tol_high:g}] on errors above {floor:.2g}")
111
+
112
+ if not np.all(np.isfinite(hs) & (hs > 0)):
113
+ return OrderResult(INCONCLUSIVE, "step sizes must be positive and finite; check how h is computed", **base)
114
+ if len(np.unique(hs)) < 2:
115
+ return OrderResult(INCONCLUSIVE, "all step sizes are the same, so no order can be measured; use at least two"
116
+ " different resolutions", **base)
106
117
 
107
118
  if not np.all(np.isfinite(errs)):
108
119
  return OrderResult(FAIL, "non-finite error: the solution diverged or produced NaN", **base)
@@ -54,6 +54,17 @@ def load_spec(path):
54
54
  return checks
55
55
 
56
56
 
57
+ SCHEMA_VERSION = 1 # of the --json output; see docs/api.md
58
+ NOTE = "A PASS means these checks found nothing wrong; it does not prove the simulation correct."
59
+ CLAIMS = { # what each family tests, for the JSON output
60
+ "mms": "the code converges at the claimed order to a manufactured solution of the reference equations",
61
+ "reference": "the code converges at the claimed order to an independently computed reference solution",
62
+ "invariant": "the drift of a conserved quantity shrinks as the resolution is refined",
63
+ "limit": "the code reproduces a known limiting or special case",
64
+ "patch": "the code reproduces a solution it should represent exactly, to round-off",
65
+ }
66
+
67
+
57
68
  def _number(x):
58
69
  x = float(x)
59
70
  return x if math.isfinite(x) else None
@@ -67,12 +78,15 @@ def outcome_to_dict(o: Outcome) -> dict:
67
78
  "ok": o.result.ok,
68
79
  "message": o.result.message,
69
80
  "seconds": round(o.seconds, 4),
81
+ "claim": CLAIMS.get(o.check.family, ""),
70
82
  }
71
83
  if isinstance(o.result, OrderResult):
72
84
  d["expected_order"] = _number(o.result.expected)
73
85
  d["observed_order"] = _number(o.result.fitted)
74
86
  d["h"] = [_number(h) for h in o.result.hs]
75
87
  d["error"] = [_number(e) for e in o.result.errors]
88
+ if o.result.acceptance:
89
+ d["acceptance"] = o.result.acceptance
76
90
  return d
77
91
 
78
92
 
@@ -90,10 +104,12 @@ def to_json(outcomes, source: str = "") -> str:
90
104
  from . import __version__
91
105
 
92
106
  payload = {
107
+ "schema_version": SCHEMA_VERSION,
93
108
  "physverify": __version__,
94
109
  "source": source,
95
110
  "summary": summary(outcomes),
96
111
  "checks": [outcome_to_dict(o) for o in outcomes],
112
+ "note": NOTE,
97
113
  }
98
114
  return json.dumps(payload, indent=2)
99
115
 
@@ -101,7 +117,8 @@ def to_json(outcomes, source: str = "") -> str:
101
117
  def to_text(outcomes) -> str:
102
118
  lines = [f"{o.check.family:10s} {o.result.status:12s} {o.check.label}: {o.result.message}" for o in outcomes]
103
119
  s = summary(outcomes)
104
- lines.append(f"{s['ok']} of {s['total']} checks passed")
120
+ skipped = f", {s['skipped']} skipped" if s["skipped"] else ""
121
+ lines.append(f"{s['ok']} of {s['total']} checks passed{skipped}")
105
122
  return "\n".join(lines)
106
123
 
107
124
 
@@ -125,6 +142,7 @@ def to_markdown(outcomes, source: str = "") -> str:
125
142
  lines.append(
126
143
  f"| {cell(o.check.family)} | {cell(o.result.status)} | {cell(o.check.label)} | {observed} | {cell(o.result.message)} |"
127
144
  )
145
+ lines += ["", f"_{NOTE}_"]
128
146
  return "\n".join(lines) + "\n"
129
147
 
130
148
 
@@ -210,5 +228,5 @@ def to_html(outcomes, title: str = "physverify report", source: str = "") -> str
210
228
  '<div class="wrap"><table><thead><tr><th>Status</th><th>Family</th><th>Check</th><th>Message</th>'
211
229
  "<th>Observed order</th><th>Convergence</th></tr></thead><tbody>"
212
230
  + "".join(rows)
213
- + "</tbody></table></div></body></html>\n"
231
+ + f'</tbody></table></div><p class="meta">{html.escape(NOTE)}</p></body></html>\n'
214
232
  )
@@ -24,6 +24,14 @@ taking n_steps fixed steps from t = 0 and adding source(t), an array of
24
24
  length {dim}, to the right-hand side wherever the solver evaluates it. If the
25
25
  solver has no such hook, write a small wrapper in this file that adds one and
26
26
  pass the wrapper to ode_checks instead.
27
+
28
+ Before trusting a PASS:
29
+ - reference_rhs written from the physics, not copied from the solver?
30
+ - units and signs stated, and matching the solver's conventions?
31
+ - initial and boundary conditions checked separately?
32
+ - the test reaches the states where bugs hide (large amplitudes, boundaries)?
33
+ - a second, independent check (a conserved quantity or a known limit)?
34
+ See https://rcgiri-physics.github.io/physverify/assumptions/
27
35
  """
28
36
 
29
37
  import numpy as np