opensci-engine 0.1.0__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 (104) hide show
  1. opensci_engine-0.1.0/.gitignore +11 -0
  2. opensci_engine-0.1.0/PKG-INFO +121 -0
  3. opensci_engine-0.1.0/README.md +102 -0
  4. opensci_engine-0.1.0/docs/ENGINE_ARCHITECTURE.md +149 -0
  5. opensci_engine-0.1.0/docs/NUMERICAL_CONVENTIONS.md +181 -0
  6. opensci_engine-0.1.0/docs/PHYSICS_SCOPE.md +477 -0
  7. opensci_engine-0.1.0/docs/PYODIDE_COMPATIBILITY.md +68 -0
  8. opensci_engine-0.1.0/docs/generate_docs.py +41 -0
  9. opensci_engine-0.1.0/pyproject.toml +56 -0
  10. opensci_engine-0.1.0/src/opensci_engine/__init__.py +32 -0
  11. opensci_engine-0.1.0/src/opensci_engine/__main__.py +3 -0
  12. opensci_engine-0.1.0/src/opensci_engine/_version.py +24 -0
  13. opensci_engine-0.1.0/src/opensci_engine/builders.py +328 -0
  14. opensci_engine-0.1.0/src/opensci_engine/cli.py +69 -0
  15. opensci_engine-0.1.0/src/opensci_engine/compare.py +79 -0
  16. opensci_engine-0.1.0/src/opensci_engine/config.py +158 -0
  17. opensci_engine-0.1.0/src/opensci_engine/contracts/__init__.py +76 -0
  18. opensci_engine-0.1.0/src/opensci_engine/contracts/base.py +28 -0
  19. opensci_engine-0.1.0/src/opensci_engine/contracts/components.py +143 -0
  20. opensci_engine-0.1.0/src/opensci_engine/contracts/geometry.py +84 -0
  21. opensci_engine-0.1.0/src/opensci_engine/contracts/manifest.py +27 -0
  22. opensci_engine-0.1.0/src/opensci_engine/contracts/materials.py +127 -0
  23. opensci_engine-0.1.0/src/opensci_engine/contracts/optics.py +281 -0
  24. opensci_engine-0.1.0/src/opensci_engine/contracts/polarization.py +77 -0
  25. opensci_engine-0.1.0/src/opensci_engine/contracts/project.py +143 -0
  26. opensci_engine-0.1.0/src/opensci_engine/contracts/request.py +25 -0
  27. opensci_engine-0.1.0/src/opensci_engine/contracts/results.py +218 -0
  28. opensci_engine-0.1.0/src/opensci_engine/control/__init__.py +19 -0
  29. opensci_engine-0.1.0/src/opensci_engine/control/actuation.py +73 -0
  30. opensci_engine-0.1.0/src/opensci_engine/control/analysis.py +135 -0
  31. opensci_engine-0.1.0/src/opensci_engine/control/lm.py +165 -0
  32. opensci_engine-0.1.0/src/opensci_engine/control/readings.py +61 -0
  33. opensci_engine-0.1.0/src/opensci_engine/control/sensitivity.py +108 -0
  34. opensci_engine-0.1.0/src/opensci_engine/errors/__init__.py +6 -0
  35. opensci_engine-0.1.0/src/opensci_engine/errors/codes.py +83 -0
  36. opensci_engine-0.1.0/src/opensci_engine/errors/exceptions.py +19 -0
  37. opensci_engine-0.1.0/src/opensci_engine/errors/issues.py +69 -0
  38. opensci_engine-0.1.0/src/opensci_engine/errors/status.py +42 -0
  39. opensci_engine-0.1.0/src/opensci_engine/layout/__init__.py +6 -0
  40. opensci_engine-0.1.0/src/opensci_engine/layout/checks.py +282 -0
  41. opensci_engine-0.1.0/src/opensci_engine/layout/geometry.py +168 -0
  42. opensci_engine-0.1.0/src/opensci_engine/manifest/__init__.py +13 -0
  43. opensci_engine-0.1.0/src/opensci_engine/manifest/build.py +51 -0
  44. opensci_engine-0.1.0/src/opensci_engine/manifest/canonical.py +62 -0
  45. opensci_engine-0.1.0/src/opensci_engine/math/__init__.py +37 -0
  46. opensci_engine-0.1.0/src/opensci_engine/math/pose.py +61 -0
  47. opensci_engine-0.1.0/src/opensci_engine/math/rng.py +49 -0
  48. opensci_engine-0.1.0/src/opensci_engine/math/rotation.py +74 -0
  49. opensci_engine-0.1.0/src/opensci_engine/math/vectors.py +96 -0
  50. opensci_engine-0.1.0/src/opensci_engine/optics/__init__.py +0 -0
  51. opensci_engine-0.1.0/src/opensci_engine/optics/gaussian/__init__.py +24 -0
  52. opensci_engine-0.1.0/src/opensci_engine/optics/gaussian/ops.py +185 -0
  53. opensci_engine-0.1.0/src/opensci_engine/optics/gaussian/state.py +143 -0
  54. opensci_engine-0.1.0/src/opensci_engine/optics/gaussian/truncation.py +136 -0
  55. opensci_engine-0.1.0/src/opensci_engine/optics/interference/__init__.py +3 -0
  56. opensci_engine-0.1.0/src/opensci_engine/optics/interference/two_beam.py +184 -0
  57. opensci_engine-0.1.0/src/opensci_engine/optics/materials/__init__.py +5 -0
  58. opensci_engine-0.1.0/src/opensci_engine/optics/materials/coatings.py +78 -0
  59. opensci_engine-0.1.0/src/opensci_engine/optics/materials/index.py +107 -0
  60. opensci_engine-0.1.0/src/opensci_engine/optics/materials/library.py +51 -0
  61. opensci_engine-0.1.0/src/opensci_engine/optics/polarization/__init__.py +26 -0
  62. opensci_engine-0.1.0/src/opensci_engine/optics/polarization/fresnel.py +58 -0
  63. opensci_engine-0.1.0/src/opensci_engine/optics/polarization/jones.py +104 -0
  64. opensci_engine-0.1.0/src/opensci_engine/optics/rays/__init__.py +4 -0
  65. opensci_engine-0.1.0/src/opensci_engine/optics/rays/pupil.py +29 -0
  66. opensci_engine-0.1.0/src/opensci_engine/optics/rays/tracer.py +709 -0
  67. opensci_engine-0.1.0/src/opensci_engine/optics/surfaces/__init__.py +14 -0
  68. opensci_engine-0.1.0/src/opensci_engine/optics/surfaces/geometry.py +161 -0
  69. opensci_engine-0.1.0/src/opensci_engine/optics/surfaces/interaction.py +74 -0
  70. opensci_engine-0.1.0/src/opensci_engine/py.typed +0 -0
  71. opensci_engine-0.1.0/src/opensci_engine/scene/__init__.py +0 -0
  72. opensci_engine-0.1.0/src/opensci_engine/scene/lower.py +203 -0
  73. opensci_engine-0.1.0/src/opensci_engine/scene/model.py +100 -0
  74. opensci_engine-0.1.0/src/opensci_engine/units.py +6 -0
  75. opensci_engine-0.1.0/src/opensci_engine/validation/__init__.py +4 -0
  76. opensci_engine-0.1.0/src/opensci_engine/validation/metrics.py +287 -0
  77. opensci_engine-0.1.0/src/opensci_engine/validation/observations.py +196 -0
  78. opensci_engine-0.1.0/src/opensci_engine/validation/pipeline.py +224 -0
  79. opensci_engine-0.1.0/src/opensci_engine/validation/scope.py +38 -0
  80. opensci_engine-0.1.0/tests/fixtures/contract_freeze.json +27 -0
  81. opensci_engine-0.1.0/tests/fixtures/requests/aperture_clipping.json +268 -0
  82. opensci_engine-0.1.0/tests/fixtures/requests/benchmark_standard.json +8712 -0
  83. opensci_engine-0.1.0/tests/fixtures/requests/control_recoverability.json +344 -0
  84. opensci_engine-0.1.0/tests/fixtures/requests/hwp_pbs.json +389 -0
  85. opensci_engine-0.1.0/tests/fixtures/requests/interference_405_500.json +482 -0
  86. opensci_engine-0.1.0/tests/fixtures/requests/invalid_zero_normal.json +223 -0
  87. opensci_engine-0.1.0/tests/fixtures/requests/keplerian_expander.json +341 -0
  88. opensci_engine-0.1.0/tests/fixtures/requests/layout_example.json +527 -0
  89. opensci_engine-0.1.0/tests/fixtures/requests/multi_laser.json +403 -0
  90. opensci_engine-0.1.0/tests/fixtures/requests/mzi_window.json +560 -0
  91. opensci_engine-0.1.0/tests/fixtures/requests/out_of_scope_targets.json +265 -0
  92. opensci_engine-0.1.0/tests/fixtures/requests/tilted_mirror_astigmatism.json +245 -0
  93. opensci_engine-0.1.0/tests/fixtures/snapshots/aperture_clipping.json +377 -0
  94. opensci_engine-0.1.0/tests/fixtures/snapshots/benchmark_standard.json +13625 -0
  95. opensci_engine-0.1.0/tests/fixtures/snapshots/control_recoverability.json +458 -0
  96. opensci_engine-0.1.0/tests/fixtures/snapshots/hwp_pbs.json +578 -0
  97. opensci_engine-0.1.0/tests/fixtures/snapshots/interference_405_500.json +702 -0
  98. opensci_engine-0.1.0/tests/fixtures/snapshots/invalid_zero_normal.json +151 -0
  99. opensci_engine-0.1.0/tests/fixtures/snapshots/keplerian_expander.json +401 -0
  100. opensci_engine-0.1.0/tests/fixtures/snapshots/layout_example.json +427 -0
  101. opensci_engine-0.1.0/tests/fixtures/snapshots/multi_laser.json +875 -0
  102. opensci_engine-0.1.0/tests/fixtures/snapshots/mzi_window.json +1106 -0
  103. opensci_engine-0.1.0/tests/fixtures/snapshots/out_of_scope_targets.json +393 -0
  104. opensci_engine-0.1.0/tests/fixtures/snapshots/tilted_mirror_astigmatism.json +340 -0
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .coverage
6
+ .pyodide_final/
7
+ .pyodide_report_out/
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ tests/pyodide/node_modules/
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.5
2
+ Name: opensci-engine
3
+ Version: 0.1.0
4
+ Summary: Deterministic, stateless, offline 3D optical / layout / control engineering engine for OpenSci (CPython + Pyodide).
5
+ Author: OpenSci
6
+ License: Proprietary
7
+ Classifier: Programming Language :: Python :: 3.14
8
+ Classifier: Topic :: Scientific/Engineering :: Physics
9
+ Classifier: Typing :: Typed
10
+ Requires-Python: >=3.14
11
+ Requires-Dist: numpy<3,>=2.3
12
+ Requires-Dist: pydantic<3,>=2.11
13
+ Requires-Dist: scipy<2,>=1.16
14
+ Requires-Dist: shapely<3,>=2.1
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest-cov>=5; extra == 'test'
17
+ Requires-Dist: pytest>=8; extra == 'test'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # opensci-engine
21
+
22
+ Deterministic, stateless, **offline** engineering computation core for OpenSci: 3D geometric optics, Gaussian beams
23
+ (x/y-independent q-parameter), Jones polarization with a transported 3D transverse basis, analytic two-beam interference,
24
+ 2.5D layout, and control / recoverability analysis. One pure-Python package, one algorithm core for native CPython,
25
+ Pyodide/WASM (Web Worker), CI and controlled server verification.
26
+
27
+ * Python **3.14**, `numpy`, `scipy`, `pydantic>=2`, `shapely` (layout only). No compiler, CMake, CUDA or system library.
28
+ * Same input + same engine version + same numerical settings = same result within the declared tolerance.
29
+ * Everything out of scope returns `OUT_OF_MODEL_SCOPE` / `UNKNOWN` — never `PASS`.
30
+
31
+ ```bash
32
+ pip install opensci-engine
33
+ opensci-engine validate request.json --pretty
34
+ ```
35
+
36
+ ## Quick example (405 nm / 500 nm two-beam interference)
37
+
38
+ Every example in this repository is an executable fixture (`tests/fixtures/requests/*.json`, built by
39
+ `tests/fixture_scenes.py`). The 405 nm / 500 nm case:
40
+
41
+ ```python
42
+ import json
43
+ from opensci_engine import ValidationRequest, validate
44
+
45
+ request = ValidationRequest.model_validate_json(open("tests/fixtures/requests/interference_405_500.json").read())
46
+ result = validate(request)
47
+
48
+ print(result.status) # PASS
49
+ pair = result.interference[0]
50
+ print(f"{pair.fringe_period_nm:.6f} nm, {pair.half_angle_deg:.4f} deg") # 500.000000 nm, 23.8911 deg (traced 3D wave vectors)
51
+ print(f"{pair.fringe_visibility:.6f} {pair.coherence_margin:.6f}") # 1.000000 1.000000
52
+ print(result.manifest.input_hash, result.manifest.result_hash)
53
+ ```
54
+
55
+ Build a scene programmatically with the factories in `opensci_engine.builders`:
56
+
57
+ ```python
58
+ import math
59
+ from opensci_engine import builders as B
60
+ from opensci_engine.contracts import ProjectSnapshot, Target
61
+ from opensci_engine import ValidationRequest, validate
62
+
63
+ project = ProjectSnapshot(
64
+ components=(
65
+ B.laser("L", wavelength_nm=532.0, waist_radius_mm=1.0, position=(0, 0, 20), direction=(1, 0, 0)),
66
+ B.thin_lens("Lens", focal_length_mm=100.0, clear_radius_mm=12.7, position=(10, 0, 20), axis=(1, 0, 0)),
67
+ ),
68
+ observations=(B.observation_plane("focus", position=(110, 0, 20), normal=(1, 0, 0), radius_mm=5.0),),
69
+ targets=(Target(target_id="w", metric="beam_radius_mm", observation_id="focus", maximum=0.05),),
70
+ )
71
+ result = validate(ValidationRequest(project=project))
72
+ ```
73
+
74
+ ## Public API
75
+
76
+ | Object | Purpose |
77
+ |---|---|
78
+ | `validate(request)` / `validate_json(text)` | pure function: request -> `ValidationResult` (never raises for physical / contract validation outcomes represented by the result model) |
79
+ | `ValidationRequest`, `ProjectSnapshot`, `EngineConfiguration`, `NumericalConfig` | inputs (Pydantic v2, JSON friendly) |
80
+ | `ValidationResult`, `ValidationIssue`, `ValidationManifest` | outputs (finite numbers only, structured issues, hashes) |
81
+ | `opensci_engine.builders` | element factories producing contract objects |
82
+ | `opensci_engine.compare.compare_results` | cross-runtime comparison within `cross_runtime_rel_tol/abs_tol` |
83
+ | `opensci-engine validate|schema|compare` | command line |
84
+
85
+ Status vocabulary: `PASS`, `WARNING`, `FAIL`, `UNKNOWN`, `NOT_APPLICABLE`, `OUT_OF_MODEL_SCOPE`.
86
+ CLI exit codes: 0 PASS/WARNING/NOT_APPLICABLE, 1 FAIL, 2 UNKNOWN, 3 OUT_OF_MODEL_SCOPE, 64 usage error.
87
+
88
+ The "never raises" guarantee above applies to the `validate()` / `validate_json()` entry points: physical and
89
+ contract-validation outcomes are always represented by the `ValidationResult` model, never raised as exceptions.
90
+ Direct construction of the frozen Pydantic contract models (e.g. `builders.laser(wavelength_nm=float("nan"))` or
91
+ `SourceSpec(...)`) can still raise a Pydantic `ValidationError` *before* those entry points are called, because the
92
+ contract models deliberately set `allow_inf_nan=False`. Pass such input through `validate_json` (the CLI / Pyodide
93
+ bridge) to receive it as a structured `FAIL` / `INVALID_INPUT` result instead.
94
+
95
+ ## Conventions (short; details in `docs/NUMERICAL_CONVENTIONS.md`)
96
+
97
+ Right-handed 3D, lengths mm, wavelengths nm (vacuum), angles degrees at the API, power W, `w` = 1/e² intensity **radius**.
98
+ Element local `+z` = optical axis; surface normals are explicit; Jones vectors live in a transported transverse basis
99
+ `(e1, e2)`, `e1 × e2 = d`. `E ∝ exp[i(k·r − ωt)]`.
100
+
101
+ ## Documentation
102
+
103
+ * `docs/ENGINE_ARCHITECTURE.md` — layers, contracts (frozen vs provisional), dependency decisions
104
+ * `docs/PHYSICS_SCOPE.md` — equations, assumptions, applicability, failure modes, independent references per module
105
+ * `docs/NUMERICAL_CONVENTIONS.md` — units, frames, every tolerance and its reason, determinism, hashing
106
+ * `docs/PYODIDE_COMPATIBILITY.md` — Pyodide policy and the automated tests
107
+ * `KNOWN_LIMITATIONS.md`, `GOLDEN_CASE_REPORT.md`, `NUMERICAL_ACCURACY_REPORT.md`, `PYODIDE_COMPATIBILITY_REPORT.md`,
108
+ `DEPENDENCY_REPORT.md`, `ENGINE_IMPLEMENTATION_REPORT.md`
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ uv venv --python 3.14 .venv
114
+ uv pip install -e ".[test]"
115
+ pytest # native tests (golden physics, robustness, layout, control, determinism, hygiene)
116
+ python tests/generate_fixtures.py # regenerate fixtures / snapshots
117
+ python benchmarks/bench_validation.py # standard local-validation benchmark
118
+ cd tests/pyodide && npm ci && cd ../..
119
+ pytest -m pyodide # real Pyodide install + native/Pyodide parity (needs node + network on a cold cache)
120
+ pytest -m install # build the wheel, install into a clean venv, run a Golden Case
121
+ ```
@@ -0,0 +1,102 @@
1
+ # opensci-engine
2
+
3
+ Deterministic, stateless, **offline** engineering computation core for OpenSci: 3D geometric optics, Gaussian beams
4
+ (x/y-independent q-parameter), Jones polarization with a transported 3D transverse basis, analytic two-beam interference,
5
+ 2.5D layout, and control / recoverability analysis. One pure-Python package, one algorithm core for native CPython,
6
+ Pyodide/WASM (Web Worker), CI and controlled server verification.
7
+
8
+ * Python **3.14**, `numpy`, `scipy`, `pydantic>=2`, `shapely` (layout only). No compiler, CMake, CUDA or system library.
9
+ * Same input + same engine version + same numerical settings = same result within the declared tolerance.
10
+ * Everything out of scope returns `OUT_OF_MODEL_SCOPE` / `UNKNOWN` — never `PASS`.
11
+
12
+ ```bash
13
+ pip install opensci-engine
14
+ opensci-engine validate request.json --pretty
15
+ ```
16
+
17
+ ## Quick example (405 nm / 500 nm two-beam interference)
18
+
19
+ Every example in this repository is an executable fixture (`tests/fixtures/requests/*.json`, built by
20
+ `tests/fixture_scenes.py`). The 405 nm / 500 nm case:
21
+
22
+ ```python
23
+ import json
24
+ from opensci_engine import ValidationRequest, validate
25
+
26
+ request = ValidationRequest.model_validate_json(open("tests/fixtures/requests/interference_405_500.json").read())
27
+ result = validate(request)
28
+
29
+ print(result.status) # PASS
30
+ pair = result.interference[0]
31
+ print(f"{pair.fringe_period_nm:.6f} nm, {pair.half_angle_deg:.4f} deg") # 500.000000 nm, 23.8911 deg (traced 3D wave vectors)
32
+ print(f"{pair.fringe_visibility:.6f} {pair.coherence_margin:.6f}") # 1.000000 1.000000
33
+ print(result.manifest.input_hash, result.manifest.result_hash)
34
+ ```
35
+
36
+ Build a scene programmatically with the factories in `opensci_engine.builders`:
37
+
38
+ ```python
39
+ import math
40
+ from opensci_engine import builders as B
41
+ from opensci_engine.contracts import ProjectSnapshot, Target
42
+ from opensci_engine import ValidationRequest, validate
43
+
44
+ project = ProjectSnapshot(
45
+ components=(
46
+ B.laser("L", wavelength_nm=532.0, waist_radius_mm=1.0, position=(0, 0, 20), direction=(1, 0, 0)),
47
+ B.thin_lens("Lens", focal_length_mm=100.0, clear_radius_mm=12.7, position=(10, 0, 20), axis=(1, 0, 0)),
48
+ ),
49
+ observations=(B.observation_plane("focus", position=(110, 0, 20), normal=(1, 0, 0), radius_mm=5.0),),
50
+ targets=(Target(target_id="w", metric="beam_radius_mm", observation_id="focus", maximum=0.05),),
51
+ )
52
+ result = validate(ValidationRequest(project=project))
53
+ ```
54
+
55
+ ## Public API
56
+
57
+ | Object | Purpose |
58
+ |---|---|
59
+ | `validate(request)` / `validate_json(text)` | pure function: request -> `ValidationResult` (never raises for physical / contract validation outcomes represented by the result model) |
60
+ | `ValidationRequest`, `ProjectSnapshot`, `EngineConfiguration`, `NumericalConfig` | inputs (Pydantic v2, JSON friendly) |
61
+ | `ValidationResult`, `ValidationIssue`, `ValidationManifest` | outputs (finite numbers only, structured issues, hashes) |
62
+ | `opensci_engine.builders` | element factories producing contract objects |
63
+ | `opensci_engine.compare.compare_results` | cross-runtime comparison within `cross_runtime_rel_tol/abs_tol` |
64
+ | `opensci-engine validate|schema|compare` | command line |
65
+
66
+ Status vocabulary: `PASS`, `WARNING`, `FAIL`, `UNKNOWN`, `NOT_APPLICABLE`, `OUT_OF_MODEL_SCOPE`.
67
+ CLI exit codes: 0 PASS/WARNING/NOT_APPLICABLE, 1 FAIL, 2 UNKNOWN, 3 OUT_OF_MODEL_SCOPE, 64 usage error.
68
+
69
+ The "never raises" guarantee above applies to the `validate()` / `validate_json()` entry points: physical and
70
+ contract-validation outcomes are always represented by the `ValidationResult` model, never raised as exceptions.
71
+ Direct construction of the frozen Pydantic contract models (e.g. `builders.laser(wavelength_nm=float("nan"))` or
72
+ `SourceSpec(...)`) can still raise a Pydantic `ValidationError` *before* those entry points are called, because the
73
+ contract models deliberately set `allow_inf_nan=False`. Pass such input through `validate_json` (the CLI / Pyodide
74
+ bridge) to receive it as a structured `FAIL` / `INVALID_INPUT` result instead.
75
+
76
+ ## Conventions (short; details in `docs/NUMERICAL_CONVENTIONS.md`)
77
+
78
+ Right-handed 3D, lengths mm, wavelengths nm (vacuum), angles degrees at the API, power W, `w` = 1/e² intensity **radius**.
79
+ Element local `+z` = optical axis; surface normals are explicit; Jones vectors live in a transported transverse basis
80
+ `(e1, e2)`, `e1 × e2 = d`. `E ∝ exp[i(k·r − ωt)]`.
81
+
82
+ ## Documentation
83
+
84
+ * `docs/ENGINE_ARCHITECTURE.md` — layers, contracts (frozen vs provisional), dependency decisions
85
+ * `docs/PHYSICS_SCOPE.md` — equations, assumptions, applicability, failure modes, independent references per module
86
+ * `docs/NUMERICAL_CONVENTIONS.md` — units, frames, every tolerance and its reason, determinism, hashing
87
+ * `docs/PYODIDE_COMPATIBILITY.md` — Pyodide policy and the automated tests
88
+ * `KNOWN_LIMITATIONS.md`, `GOLDEN_CASE_REPORT.md`, `NUMERICAL_ACCURACY_REPORT.md`, `PYODIDE_COMPATIBILITY_REPORT.md`,
89
+ `DEPENDENCY_REPORT.md`, `ENGINE_IMPLEMENTATION_REPORT.md`
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ uv venv --python 3.14 .venv
95
+ uv pip install -e ".[test]"
96
+ pytest # native tests (golden physics, robustness, layout, control, determinism, hygiene)
97
+ python tests/generate_fixtures.py # regenerate fixtures / snapshots
98
+ python benchmarks/bench_validation.py # standard local-validation benchmark
99
+ cd tests/pyodide && npm ci && cd ../..
100
+ pytest -m pyodide # real Pyodide install + native/Pyodide parity (needs node + network on a cold cache)
101
+ pytest -m install # build the wheel, install into a clean venv, run a Golden Case
102
+ ```
@@ -0,0 +1,149 @@
1
+ # opensci-engine — Architecture
2
+
3
+ Status: Phase 1 baseline (engine v0.1.0). This document is normative for module
4
+ boundaries, public contracts and dependency choices. Physics scope is in
5
+ `PHYSICS_SCOPE.md`; numeric conventions in `NUMERICAL_CONVENTIONS.md`; Pyodide
6
+ constraints in `PYODIDE_COMPATIBILITY.md`.
7
+
8
+ ## 1. What the engine is
9
+
10
+ `opensci-engine` is a **deterministic, stateless, offline engineering
11
+ computation library**. One import, one call:
12
+
13
+ ```
14
+ ValidationRequest (Pydantic, JSON) -> validate() -> ValidationResult (Pydantic, JSON)
15
+ ```
16
+
17
+ It is not a database, web API, cloud service, supplier resolver, catalog or
18
+ file store. It performs no I/O of any kind while computing (no network, no
19
+ filesystem reads of user data, no environment probing other than the
20
+ Python/NumPy versions recorded in the manifest, no clocks, no global
21
+ mutable caches, no multiprocessing).
22
+
23
+ Invariant: same canonical input + same engine version + same
24
+ `NumericalConfig` = same result within the declared numerical tolerance.
25
+
26
+ ## 2. Layering
27
+
28
+ ```
29
+ JSON / Python objects
30
+ |
31
+ v
32
+ contracts/ Pydantic v2, frozen, extra="forbid", JSON friendly <-- public boundary
33
+ | (validated, then lowered once)
34
+ v
35
+ scene/ lowering: contracts -> numpy-backed internal structures (dataclasses)
36
+ |
37
+ v
38
+ math/ optics/ layout/ control/ pure numerical code, NumPy arrays / dataclasses, no Pydantic in loops
39
+ |
40
+ v
41
+ validation/ targets, aggregation, issue synthesis -> Pydantic results
42
+ |
43
+ v
44
+ manifest/ canonical serialization, hashing
45
+ ```
46
+
47
+ Rules:
48
+
49
+ * Pydantic objects are created only at the boundary (input validation and
50
+ result assembly), never inside ray/beam/optimizer inner loops.
51
+ * `math/` depends on NumPy only. `optics/`, `layout/`, `control/` depend on
52
+ `math/`, `config` and `errors/`, never on `contracts/` request/result types
53
+ except through `scene/` lowering.
54
+ * `contracts/` never import `optics/` etc. (one-way dependency), so contracts
55
+ can be frozen independently of algorithms.
56
+ * No module reads a module-level mutable. All tolerances travel in an explicit
57
+ `NumericalConfig` object (frozen, hashed into the manifest).
58
+
59
+ ## 3. Package layout
60
+
61
+ ```
62
+ src/opensci_engine/
63
+ __init__.py public API: validate, validate_json, __version__, contracts re-exports
64
+ _version.py ENGINE_VERSION, SCHEMA_VERSION, NUMERICAL_CONFIG_VERSION, MODEL_VERSIONS
65
+ config.py NumericalConfig (all epsilons/tolerances, each documented)
66
+ errors/ Status, ErrorCode, ValidationIssue, EngineInputError
67
+ math/ vectors, rotation (quaternion), pose, rng (PCG64 -> uniform/normal)
68
+ contracts/ frozen public models (see 4)
69
+ scene/ contracts -> internal lowering
70
+ optics/
71
+ materials/ constant / Sellmeier / tabulated n(lambda), coatings, range checks
72
+ surfaces/ plane & sphere intersection, aperture, Snell/reflect/TIR, thin lens
73
+ polarization/ Jones calculus, transverse-basis transport, Fresnel, elements
74
+ gaussian/ q-parameter / ABCD (x,y independent), truncation, applicability
75
+ rays/ vectorized ray bundles, sequential-agnostic (non-sequential) tracer, pupil sampling
76
+ interference/ two-beam analytic model
77
+ layout/ footprint, keepout, beam envelope, moving envelope (Shapely, 2D/2.5D only)
78
+ control/ actuators, sensors, Jacobian, Levenberg-Marquardt, recoverability, Monte Carlo
79
+ validation/ target metric registry, out-of-scope registry, pipeline
80
+ manifest/ canonical JSON, hashing, ValidationManifest assembly
81
+ cli.py `opensci-engine validate request.json`
82
+ ```
83
+
84
+ ## 4. Public contracts
85
+
86
+ ### 4.1 Frozen (physics/math layer; independent of the database team)
87
+
88
+ `Vector3`, `Quaternion`, `Pose3D`, `Ray`, `RaySegment`, `OpticalSurface`
89
+ (plane, sphere; apertures; interactions), `OpticalPort`, `OpticalPath`,
90
+ `BeamState`, `GaussianBeamState`, `JonesVector`, `PolarizationBasis`,
91
+ `MaterialOpticalModel` (+ `CoatingModel`), `Target`, `Observation`,
92
+ `ValidationResult`, `ValidationIssue`, `ValidationManifest`, `NumericalConfig`.
93
+
94
+ "Frozen" means: field names and semantics may only change via a
95
+ `SCHEMA_VERSION` bump plus migration. This is enforced: `tests/fixtures/contract_freeze.json` stores the JSON-Schema hash of
96
+ each frozen model and `tests/test_fixtures_cli_freeze.py::test_frozen_contracts_are_unchanged_since_the_recorded_baseline`
97
+ fails on any change without a version bump (`python tests/generate_fixtures.py --freeze` rewrites the baseline).
98
+
99
+ ### 4.2 Provisional (awaiting the database / Product & Supply team)
100
+
101
+ `EngineeringComponentSnapshot`, `EngineeringEnvelopeSnapshot`,
102
+ `ActuatorSpec`, `ControlSpec`, `SourceSpec` (wrapper fields other than the
103
+ physical beam parameters), `ProjectSnapshot` (container fields such as ids,
104
+ names, revision metadata), `ValidationRequest.project` envelope.
105
+
106
+ The envelope's plan-parallel frame (`EngineeringEnvelopeSnapshot.frame_pose`) and the element `mount_margin_mm` semantics
107
+ were introduced during implementation because the first draft (footprints in the optical local frame; an infinite blocking plane)
108
+ was physically wrong (documented in `ENGINE_IMPLEMENTATION_REPORT.md`).
109
+
110
+ These are *engine-side input snapshots*. The engine never imports an ORM or
111
+ `DesignPart`/`SupplierPart`/`AssemblyImplementation`. The future adapter
112
+ direction is `Database model -> adapter -> Engineering*Snapshot -> engine`.
113
+
114
+ ## 5. Dependency decisions
115
+
116
+ | Package | Decision | Reason |
117
+ |---|---|---|
118
+ | numpy | required, `>=2.3,<3` | vector math; shipped by Pyodide 314 (2.4.6) |
119
+ | scipy | required, `>=1.16,<2` | interpolation (`PchipInterpolator`, `RegularGridInterpolator`); shipped by Pyodide (1.18.0) |
120
+ | pydantic | required, `>=2.11,<3` | public contracts; Pyodide ships 2.12.5 + pydantic-core wasm |
121
+ | shapely | required, `>=2.1,<3` | *only* `layout/` footprint / keepout polygon operations; Pyodide ships 2.1.2 |
122
+ | networkx | not adopted | Pyodide's build depends on matplotlib; graph needs here are trees |
123
+ | trimesh | not adopted | not in Pyodide lock; M1 needs only analytic plane/sphere geometry, so no Rtree/Embree question arises |
124
+
125
+ Shapely does 2D/2.5D footprint booleans, distances and buffering. It does
126
+ not replace 3D physics: beam/ray propagation, apertures and surface
127
+ intersection are NumPy.
128
+
129
+ ## 6. Runtime targets
130
+
131
+ Native CPython 3.14, Pyodide 314.0.x in a Web Worker, CI, controlled server
132
+ verification. The package is pure Python (`py3-none-any` wheel); all binary
133
+ dependencies come from the target's own distribution (PyPI wheels / Pyodide
134
+ lock). No compiler, CMake, CUDA or system shared library.
135
+
136
+ ## 7. Statefulness
137
+
138
+ None. `validate()` is a pure function of `(request)`. Caches (e.g., material
139
+ index lookups inside one trace) live for one call. There are no module-level
140
+ caches or mutable globals. `engine_build_id` is recomputed per call from the
141
+ installed package's `.py` source bytes (read-only, ~ms) and is used only for
142
+ the manifest; it cannot influence physics.
143
+
144
+ ## 8. Extension points
145
+
146
+ * New element type: add a discriminated-union member in `contracts/optics.py`
147
+ and an interaction handler in `optics/surfaces/interaction.py`.
148
+ * New metric: register in `validation/metrics.py`; unsupported physics goes
149
+ into the explicit out-of-scope registry (returns `OUT_OF_MODEL_SCOPE`).
@@ -0,0 +1,181 @@
1
+ # opensci-engine — Numerical Conventions
2
+
3
+ ## 1. Units (fixed by schema, not by field-level choice)
4
+
5
+ | Quantity | Unit | Notes |
6
+ |---|---|---|
7
+ | length | mm | every `*_mm` field; radius vs diameter is explicit in the field name (`*_radius_mm`) — never both |
8
+ | wavelength | nm (vacuum) | `wavelength_nm`; fringe periods in nm |
9
+ | angle (API) | degree | `*_deg`; internal computation in radian |
10
+ | power | W | `power_W` |
11
+ | irradiance | W/mm² | conversion to mW/cm² only in exposure metrics |
12
+ | time | s | |
13
+ | dose | mJ/cm² | |
14
+ | phase | rad | |
15
+ | Gaussian radius | mm | `w` is the **1/e² intensity radius** (not FWHM, not diameter) |
16
+ | divergence | rad | half-angle, `θ = M²λ/(π n w₀)` |
17
+
18
+ ## 2. Coordinates
19
+
20
+ * Global frame: right-handed, `x` right, `y` forward, `z` up (board normal).
21
+ The baseplate top plane is `z = 0` unless the project says otherwise; the
22
+ optical axis height is an absolute `z` of the ray.
23
+ * Element local frame: right-handed, origin at the element reference point.
24
+ For transmissive elements local `+z` is the nominal optical axis (light
25
+ travels along `+z`); surface normals are given explicitly per surface, so no
26
+ hidden convention is required.
27
+ * `Pose3D = (position_mm, quaternion (w,x,y,z))`; *active* transform
28
+ `x_g = R(q) x_l + p`. Composition `T_parent ∘ T_child`: `R = R_p R_c`,
29
+ `p = R_p p_c + p_p`. Quaternions are Hamilton, unit norm within
30
+ `quaternion_norm_tol`, renormalized on lowering (the deviation is not
31
+ hidden: outside tolerance is a validation error).
32
+ * `Pose3D.from_axis_direction(position, axis, up)` (the `compute_pose` helper):
33
+ local `z := axis`, local `y := up` orthogonalized against `axis`, local
34
+ `x := y × z`.
35
+ * Angle sign: positive rotation is right-handed about the stated axis.
36
+ * Surface normal `n̂`: "positive side" is the side `n̂` points toward.
37
+ `medium_positive` / `medium_negative` name the media on the two sides. A ray
38
+ with `d̂·n̂ < 0` arrives from the positive side.
39
+ * Mirror: `reflective_side ∈ {positive, negative, both}`. Sphere normal is the
40
+ outward radial vector.
41
+ * Signed radius for ABCD: `R>0` if the centre of curvature lies ahead of the
42
+ surface along the propagation direction (refraction) / on the incident
43
+ side (mirror, concave).
44
+ * Transverse basis of a ray: `(e₁, e₂)`, `e₁ × e₂ = d̂`.
45
+ * Polarization phase convention: `E ∝ exp[i(k·r − ωt)]`; a retarder's slow axis
46
+ gets `+δ/2`. Ideal metal mirror: `(r_s, r_p) = (−1, +1)` in the `(ŝ, d̂×ŝ)` triad, equivalently
47
+ `E_out = −(I − 2 n̂ n̂ᵀ) E_in` (tested against exact vector reflection through non-coplanar folds).
48
+ * Mount margin: a surface with a clear aperture may declare `mount_margin_mm` — the width of the
49
+ opaque ring around the aperture. A ray hitting the ring is blocked (`CLIPPED`); a ray beyond it
50
+ (or beyond the aperture when the margin is 0, e.g. a mirror edge) *misses* the element and passes by.
51
+ An infinite plane never blocks anything by itself.
52
+ * Observation planes are virtual and unbounded. Without an active `aperture` every crossing is an
53
+ arrival. With one, a beam counts as an arrival only if its chief ray is inside the area or at least
54
+ `capture_min_power_fraction` of its Gaussian power falls inside it; otherwise the crossing is
55
+ reported as `OBSERVATION_NOT_REACHED` with `nearest_miss_mm`. Two observation planes must not be
56
+ coincident (the second would be hidden by the ray-advance guard).
57
+ * Envelope frame: `EngineeringEnvelopeSnapshot.frame_pose` places the envelope frame relative to the
58
+ component frame. The footprint / z range / keepout / tool / cable regions live in that frame and
59
+ must be plan-parallel. For elements built with local `z` = beam axis use
60
+ `Pose3D.plan_frame_for_optical_axis()` (envelope x = beam axis, y = transverse horizontal, z = up).
61
+ * Two-beam phase difference: `phase_difference_rad = wrap[k₀ (OPL_a − OPL_b) − arg⟨E_a, E_b⟩]`
62
+ with `⟨E_a, E_b⟩ = conj(E_a)·E_b` (3D fields, interface phases included, spatial fringe term
63
+ excluded).
64
+
65
+ ## 3. Tolerances (all in `NumericalConfig`, each with a stated reason)
66
+
67
+ No numeric literal used as a tolerance appears outside `config.py`, named module constants
68
+ or `units.py` (enforced by `tests/test_hygiene.py`, which AST-scans the physics packages for
69
+ unnamed floats and large integers). The table below is generated from `NumericalConfig`
70
+ (`python docs/generate_docs.py`); `tests/test_docs_in_sync.py` fails if it drifts.
71
+
72
+ <!-- BEGIN TOLERANCES (generated by docs/generate_docs.py; do not edit) -->
73
+
74
+ | Field | Default | Meaning / reason |
75
+ |---|---|---|
76
+ | `ray_min_advance_mm` | `1e-07` | Minimum ray parameter t for a valid hit; prevents re-intersecting the surface just left. |
77
+ | `parallel_dot_tol` | `1e-12` | |n.d| below this is 'parallel': no intersection is reported. |
78
+ | `grazing_warn_aoi_deg` | `85.0` | AOI above which GRAZING_INCIDENCE warns. |
79
+ | `grazing_fail_aoi_deg` | `89.9` | AOI above which the interaction is rejected. |
80
+ | `antiparallel_tol` | `1e-12` | 1+cos(angle) below which two directions are treated as antiparallel (basis transport). |
81
+ | `unit_vector_tol` | `1e-09` | Allowed |1-|v|| for user-supplied unit vectors. |
82
+ | `zero_vector_tol` | `1e-12` | Vectors with norm below this are zero-length. |
83
+ | `quaternion_norm_tol` | `1e-06` | Allowed |1-|q|| for user-supplied quaternions. |
84
+ | `aperture_edge_tol_mm` | `1e-09` | Inclusive aperture boundary tolerance (floating-point edge). |
85
+ | `tir_warn_margin` | `0.001` | Relative distance to the critical angle that triggers TIR_BOUNDARY. |
86
+ | `power_epsilon_W` | `1e-15` | Power at or below this terminates a path (ZERO_POWER). |
87
+ | `energy_conservation_tol` | `1e-09` | Tolerance for R+T<=1 and unit-norm Jones checks. |
88
+ | `basis_orthonormality_tol` | `1e-09` | Allowed deviation of a transported transverse basis before a warning. |
89
+ | `max_interactions_per_path` | `64` | Guard against infinite cavity loops. |
90
+ | `max_paths` | `4096` | Guard against unbounded branching. |
91
+ | `retarder_warn_aoi_deg` | `20.0` | Retarder/polarizer AOI above which RETARDER_OBLIQUE_INCIDENCE warns. |
92
+ | `sellmeier_pole_rel_tol` | `1e-09` | Relative |l^2 - C_i| / l^2 below which a Sellmeier pole is considered hit. |
93
+ | `tabulated_range_tol_nm` | `1e-09` | Slack (nm) on the inclusive wavelength validity bounds of materials and coatings. |
94
+ | `tabulated_aoi_tol_deg` | `1e-09` | Slack (deg) on the AOI bounds of tabulated coatings. |
95
+ | `gaussian_warn_divergence_rad` | `0.1` | Half-angle divergence (NA~0.1) warning threshold. |
96
+ | `gaussian_scope_divergence_rad` | `0.3` | Half-angle divergence above which the paraxial Gaussian model is out of scope. |
97
+ | `gaussian_max_w_over_radius` | `0.2` | Beam radius / |surface radius of curvature| limit for ABCD validity. |
98
+ | `gaussian_max_aoi_deg` | `60.0` | Tilted-surface ABCD validity limit on AOI. |
99
+ | `gaussian_clip_warn_fraction` | `0.001` | Truncation power loss that warns. |
100
+ | `gaussian_clip_scope_fraction` | `0.1` | Truncation power loss above which the Gaussian state is invalidated. |
101
+ | `gaussian_astig_align_tol` | `1e-09` | |sin(phi)cos(phi)| below which beam axes are aligned with (t,s). |
102
+ | `gaussian_round_tol` | `1e-09` | Relative difference below which the two transverse axes are 'round'. |
103
+ | `gaussian_plane_axial_spread_fraction` | `0.1` | Axial spread across a tilted plane footprint vs z_R that triggers a warning. |
104
+ | `aperture_quad_angular` | `256` | Angular trapezoid nodes for circular-aperture Gaussian truncation (periodic, spectral). |
105
+ | `aperture_quad_sector_nodes` | `48` | Gauss-Legendre nodes per panel/sector for off-axis circular and rectangular truncation. |
106
+ | `aperture_quad_extent_sigmas` | `6.0` | Gaussian integrals are truncated at this many beam radii from the beam centre (exp(-72) ~ 0). |
107
+ | `pupil_ring_count` | `8` | Equal-power rings in the default pupil sampling. |
108
+ | `pupil_points_per_ring` | `12` | Points on each ring of the default pupil sampling. |
109
+ | `interference_min_crossing_deg` | `1e-06` | Crossing angle below which the fringe period is undefined. |
110
+ | `interference_wavelength_tol_nm` | `1e-09` | Two arrivals must agree in wavelength within this to interfere. |
111
+ | `interference_min_overlap` | `0.001` | Spatial overlap ratio below which beams are considered not overlapping (visibility < 0.1 %). |
112
+ | `interference_collinear_period_ratio` | `10.0` | Fringe period / beam diameter above which two beams are treated as collinear (single-fringe detector). |
113
+ | `max_interference_pairs` | `256` | Cap on automatically analysed beam pairs per observation. |
114
+ | `interference_det_tol` | `1e-30` | Determinant floor for the summed envelope matrix. |
115
+ | `fd_step_translation_mm` | `0.001` | Central-difference step for translation DOFs. |
116
+ | `fd_step_rotation_rad` | `1e-05` | Central-difference step for rotation DOFs. |
117
+ | `lm_initial_damping` | `0.001` | Initial Levenberg-Marquardt damping. |
118
+ | `lm_damping_up` | `10.0` | Damping multiplier after a rejected step. |
119
+ | `lm_damping_down` | `0.1` | Damping multiplier after an accepted step. |
120
+ | `lm_max_damping` | `1000000000000.0` | Damping above which LM gives up. |
121
+ | `lm_max_iterations` | `100` | Iteration cap for LM. |
122
+ | `lm_cost_tol` | `1e-18` | Converged when the cost falls below this. |
123
+ | `lm_step_tol` | `1e-12` | Converged when the scaled step norm falls below this. |
124
+ | `lm_residual_tol` | `1e-06` | Default weighted-residual norm regarded as converged when the spec gives none (sensor units, e.g. mm). |
125
+ | `lm_scaling_floor` | `1e-12` | Floor on diag(J^T J) in Marquardt scaling so unobserved DOFs stay bounded. |
126
+ | `lm_gain_accept` | `0.0` | Minimum actual/predicted reduction ratio to accept a step. |
127
+ | `jacobian_rcond` | `1e-10` | Singular values below rcond*sigma_max are rank-deficient. |
128
+ | `observability_participation_min` | `0.25` | An actuator is reported unobservable when at least this fraction of its unit motion lies in the null space of the sensor Jacobian (0.5 would sit exactly on the symmetric two-DOF case). |
129
+ | `jacobian_condition_warn` | `1000000.0` | Jacobian condition number that warns. |
130
+ | `capture_min_power_fraction` | `0.001` | Detected/available power below this means the beam is lost. |
131
+ | `capture_scan_max_points` | `4096` | Maximum points of the coarse-capture raster scan. |
132
+ | `layout_geometry_tol_mm` | `1e-06` | Overlaps/clearance deficits below this are ignored. |
133
+ | `layout_arc_resolution` | `32` | Shapely buffer segments per quarter circle. |
134
+ | `beam_envelope_k` | `2.0` | Beam envelope radius = k*w (k=2 -> 1-exp(-8)=99.97% power). |
135
+ | `moving_envelope_rotation_samples` | `9` | Angles sampled for rotational sweeps. |
136
+ | `layout_height_tol_mm` | `1e-06` | Height intervals overlapping by less than this are disjoint. |
137
+ | `hash_float_sig_digits` | `9` | Significant digits kept when hashing results. |
138
+ | `hash_float_abs_floor` | `1e-12` | Result values below this magnitude hash as 0. |
139
+ | `cross_runtime_rel_tol` | `1e-09` | Relative tolerance for cross-runtime comparison. |
140
+ | `cross_runtime_abs_tol` | `1e-12` | Absolute tolerance for cross-runtime comparison. |
141
+
142
+ <!-- END TOLERANCES -->
143
+
144
+ Changing a default increments `NUMERICAL_CONFIG_VERSION`.
145
+
146
+ ## 4. Determinism
147
+
148
+ * No wall-clock, no `id()`, no dict-order dependence in results (all
149
+ collections are sorted by stable ids before output).
150
+ * Random numbers only in Monte Carlo, from `numpy.random.PCG64(seed)` raw
151
+ 64-bit words; uniform `u = (w >> 11) · 2⁻⁵³`; normal via Box–Muller using
152
+ `math.log`/`math.cos`. The PRNG identifier `PCG64+BoxMuller-v1` and the seed
153
+ go into the manifest.
154
+ * NumPy `BitGenerator` raw streams are stable across versions and platforms;
155
+ `Generator` distribution methods are deliberately not used.
156
+ * Floating-point differences between runtimes come from libm/BLAS; results are
157
+ compared with `cross_runtime_rel_tol` / `_abs_tol`, never bitwise.
158
+
159
+ ## 5. Error reporting, never NaN
160
+
161
+ All public numeric outputs are finite. If a computation cannot produce a
162
+ finite meaningful number it returns a structured `ValidationIssue`
163
+ (`code`, `message`, `location`, `affected_targets`, `numerical_context`,
164
+ `suggested_action`) and the dependent metric has status `UNKNOWN`,
165
+ `FAIL` or `OUT_OF_MODEL_SCOPE` with `value = null`. Result models forbid
166
+ `NaN`/`Inf` (`allow_inf_nan=False`).
167
+
168
+ ## 6. Canonical serialization and hashing
169
+
170
+ * JSON, UTF-8, keys sorted, no whitespace (`separators=(",",":")`).
171
+ * Inputs hashed from the validated model dump (`mode="json"`, so defaults are
172
+ materialized and aliases normalized).
173
+ * Floats in **results** are quantized before hashing: value `v` with
174
+ `|v| < hash_float_abs_floor` → `0.0`; else round to `hash_float_sig_digits`
175
+ significant digits and print with `repr`. This makes `result_hash` stable
176
+ against last-bit libm differences; cross-runtime comparison still uses
177
+ explicit tolerances (`compare_results`), because rounding can straddle a
178
+ boundary.
179
+ * Hash = SHA-256 hex. No secret keys: hashes are integrity/reproducibility
180
+ aids, not authenticity proofs.
181
+ * `trace_hash` covers the ordered ray-event list of the chief rays.