opensci-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. opensci_engine/__init__.py +32 -0
  2. opensci_engine/__main__.py +3 -0
  3. opensci_engine/_version.py +24 -0
  4. opensci_engine/builders.py +328 -0
  5. opensci_engine/cli.py +69 -0
  6. opensci_engine/compare.py +79 -0
  7. opensci_engine/config.py +158 -0
  8. opensci_engine/contracts/__init__.py +76 -0
  9. opensci_engine/contracts/base.py +28 -0
  10. opensci_engine/contracts/components.py +143 -0
  11. opensci_engine/contracts/geometry.py +84 -0
  12. opensci_engine/contracts/manifest.py +27 -0
  13. opensci_engine/contracts/materials.py +127 -0
  14. opensci_engine/contracts/optics.py +281 -0
  15. opensci_engine/contracts/polarization.py +77 -0
  16. opensci_engine/contracts/project.py +143 -0
  17. opensci_engine/contracts/request.py +25 -0
  18. opensci_engine/contracts/results.py +218 -0
  19. opensci_engine/control/__init__.py +19 -0
  20. opensci_engine/control/actuation.py +73 -0
  21. opensci_engine/control/analysis.py +135 -0
  22. opensci_engine/control/lm.py +165 -0
  23. opensci_engine/control/readings.py +61 -0
  24. opensci_engine/control/sensitivity.py +108 -0
  25. opensci_engine/errors/__init__.py +6 -0
  26. opensci_engine/errors/codes.py +83 -0
  27. opensci_engine/errors/exceptions.py +19 -0
  28. opensci_engine/errors/issues.py +69 -0
  29. opensci_engine/errors/status.py +42 -0
  30. opensci_engine/layout/__init__.py +6 -0
  31. opensci_engine/layout/checks.py +282 -0
  32. opensci_engine/layout/geometry.py +168 -0
  33. opensci_engine/manifest/__init__.py +13 -0
  34. opensci_engine/manifest/build.py +51 -0
  35. opensci_engine/manifest/canonical.py +62 -0
  36. opensci_engine/math/__init__.py +37 -0
  37. opensci_engine/math/pose.py +61 -0
  38. opensci_engine/math/rng.py +49 -0
  39. opensci_engine/math/rotation.py +74 -0
  40. opensci_engine/math/vectors.py +96 -0
  41. opensci_engine/optics/__init__.py +0 -0
  42. opensci_engine/optics/gaussian/__init__.py +24 -0
  43. opensci_engine/optics/gaussian/ops.py +185 -0
  44. opensci_engine/optics/gaussian/state.py +143 -0
  45. opensci_engine/optics/gaussian/truncation.py +136 -0
  46. opensci_engine/optics/interference/__init__.py +3 -0
  47. opensci_engine/optics/interference/two_beam.py +184 -0
  48. opensci_engine/optics/materials/__init__.py +5 -0
  49. opensci_engine/optics/materials/coatings.py +78 -0
  50. opensci_engine/optics/materials/index.py +107 -0
  51. opensci_engine/optics/materials/library.py +51 -0
  52. opensci_engine/optics/polarization/__init__.py +26 -0
  53. opensci_engine/optics/polarization/fresnel.py +58 -0
  54. opensci_engine/optics/polarization/jones.py +104 -0
  55. opensci_engine/optics/rays/__init__.py +4 -0
  56. opensci_engine/optics/rays/pupil.py +29 -0
  57. opensci_engine/optics/rays/tracer.py +709 -0
  58. opensci_engine/optics/surfaces/__init__.py +14 -0
  59. opensci_engine/optics/surfaces/geometry.py +161 -0
  60. opensci_engine/optics/surfaces/interaction.py +74 -0
  61. opensci_engine/py.typed +0 -0
  62. opensci_engine/scene/__init__.py +0 -0
  63. opensci_engine/scene/lower.py +203 -0
  64. opensci_engine/scene/model.py +100 -0
  65. opensci_engine/units.py +6 -0
  66. opensci_engine/validation/__init__.py +4 -0
  67. opensci_engine/validation/metrics.py +287 -0
  68. opensci_engine/validation/observations.py +196 -0
  69. opensci_engine/validation/pipeline.py +224 -0
  70. opensci_engine/validation/scope.py +38 -0
  71. opensci_engine-0.1.0.dist-info/METADATA +121 -0
  72. opensci_engine-0.1.0.dist-info/RECORD +74 -0
  73. opensci_engine-0.1.0.dist-info/WHEEL +4 -0
  74. opensci_engine-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,32 @@
1
+ """opensci-engine: deterministic, stateless, offline 3D optical / layout / control engineering engine.
2
+
3
+ Typical use::
4
+
5
+ from opensci_engine import ValidationRequest, validate
6
+ result = validate(ValidationRequest(project=...))
7
+ """
8
+
9
+ from ._version import ENGINE_VERSION, MODEL_VERSIONS, NUMERICAL_CONFIG_VERSION, SCHEMA_VERSION, __version__
10
+ from .config import DEFAULT_NUMERICAL_CONFIG, NumericalConfig
11
+ from .contracts.request import EngineConfiguration, ValidationRequest
12
+ from .contracts.results import ValidationResult
13
+ from .errors import ErrorCode, Status, ValidationIssue
14
+ from .validation import validate, validate_json
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "ENGINE_VERSION",
19
+ "SCHEMA_VERSION",
20
+ "NUMERICAL_CONFIG_VERSION",
21
+ "MODEL_VERSIONS",
22
+ "NumericalConfig",
23
+ "DEFAULT_NUMERICAL_CONFIG",
24
+ "EngineConfiguration",
25
+ "ValidationRequest",
26
+ "ValidationResult",
27
+ "ValidationIssue",
28
+ "ErrorCode",
29
+ "Status",
30
+ "validate",
31
+ "validate_json",
32
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,24 @@
1
+ """Single source of truth for engine / schema / model versions."""
2
+
3
+ __version__ = "0.1.0"
4
+ ENGINE_VERSION = __version__
5
+
6
+ # Public contract (JSON) schema version. Bump on any field-level change; see docs/ENGINE_ARCHITECTURE.md §4.
7
+ SCHEMA_VERSION = "1.0.0"
8
+ MANIFEST_VERSION = "1.0.0"
9
+ # Bump whenever any default in NumericalConfig changes.
10
+ NUMERICAL_CONFIG_VERSION = "1.0.0"
11
+
12
+ # Physics model versions recorded in every ValidationManifest.
13
+ MODEL_VERSIONS: dict[str, str] = {
14
+ "ray_geometry": "1.0",
15
+ "materials": "1.0",
16
+ "fresnel": "1.0",
17
+ "gaussian_q": "1.0",
18
+ "polarization_jones": "1.0",
19
+ "interference_two_beam": "1.0",
20
+ "layout_2p5d": "1.0",
21
+ "control_lm": "1.0",
22
+ }
23
+
24
+ PRNG_ID = "PCG64+BoxMuller-v1"
@@ -0,0 +1,328 @@
1
+ """Convenience factories that build *contract objects* (no physics here).
2
+
3
+ Element frame convention: local ``+z`` is the nominal optical axis (light travels along ``+z`` through transmissive
4
+ elements; for mirrors/splitters local ``+z`` is the front-face normal pointing toward the incident side), local ``+y``
5
+ is the ``up`` direction orthogonalized against the axis, local ``x = y × z``. Angles of polarizer / waveplate axes are
6
+ measured in the local ``x-y`` plane from local ``x`` toward local ``y``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import numpy as np
14
+
15
+ from .contracts import (
16
+ AbsorberInteraction,
17
+ ApertureStopInteraction,
18
+ BeamSplitterInteraction,
19
+ CircularAperture,
20
+ ConstantCoating,
21
+ EngineeringComponentSnapshot,
22
+ EngineeringEnvelopeSnapshot,
23
+ JonesVector,
24
+ MirrorInteraction,
25
+ Observation,
26
+ OpticalPort,
27
+ PlaneSurface,
28
+ Pose3D,
29
+ PolarizerInteraction,
30
+ RectangularAperture,
31
+ RefractInteraction,
32
+ RetarderInteraction,
33
+ SourceSpec,
34
+ SphereSurface,
35
+ ThinLensInteraction,
36
+ )
37
+ from .config import DEFAULT_NUMERICAL_CONFIG
38
+ from .contracts.base import Vector3
39
+ from .contracts.components import ActuatorSpec
40
+
41
+ _Z = (0.0, 0.0, 1.0)
42
+
43
+ # Default opaque mount ring (mm) around the clear aperture of transmissive elements: rays hitting it are clipped.
44
+ DEFAULT_MOUNT_MARGIN_MM = 2.0
45
+ INCH_MM = 25.4
46
+ ONE_INCH_CLEAR_RADIUS_MM = 12.7 # clear radius of a 1-inch optic (half of 25.4 mm)
47
+ DEFAULT_LASER_POWER_W = 1e-3
48
+ _MOSTLY_PARALLEL = 0.9 # |axis.x| above which the x axis is a poor helper axis
49
+ # An iris / aperture plate extends well beyond its opening.
50
+ DEFAULT_STOP_PLATE_MARGIN_MM = 50.0 # an iris / aperture plate extends well beyond its opening
51
+
52
+
53
+ def _pose(position: Vector3, axis: Vector3, up: Vector3 = _Z) -> Pose3D:
54
+ """Pose with local z = axis. If ``axis`` is parallel to ``up`` a horizontal reference is used instead."""
55
+ a = np.array(axis, dtype=float)
56
+ a = a / np.linalg.norm(a)
57
+ u = np.array(up, dtype=float)
58
+ if abs(float(a @ u / np.linalg.norm(u))) > 1.0 - DEFAULT_NUMERICAL_CONFIG.unit_vector_tol:
59
+ u = np.array([1.0, 0.0, 0.0]) if abs(a[0]) < _MOSTLY_PARALLEL else np.array([0.0, 1.0, 0.0])
60
+ return Pose3D.from_axis_direction(position, tuple(float(x) for x in a), tuple(float(x) for x in u)) # type: ignore[arg-type]
61
+
62
+
63
+ def rect_envelope(width_mm: float, depth_mm: float, height_mm: float = INCH_MM, *, base_z_mm: float = 0.0, optical: bool = False,
64
+ **kwargs: object) -> EngineeringEnvelopeSnapshot:
65
+ """Rectangular footprint ``width (envelope x) × depth (envelope y)`` centred on the element origin.
66
+
67
+ For elements built with the other factories (local ``+z`` = beam axis) pass ``optical=True``: the envelope frame is then the
68
+ plan frame with ``x`` along the beam axis and ``y`` transverse horizontal (see ``Pose3D.plan_frame_for_optical_axis``)."""
69
+ hw, hd = width_mm / 2.0, depth_mm / 2.0
70
+ return EngineeringEnvelopeSnapshot(
71
+ frame_pose=Pose3D.plan_frame_for_optical_axis() if optical else Pose3D(),
72
+ footprint_mm=((-hw, -hd), (hw, -hd), (hw, hd), (-hw, hd)), z_range_mm=(base_z_mm, base_z_mm + height_mm), **kwargs # type: ignore[arg-type]
73
+ )
74
+
75
+
76
+ def laser(
77
+ instance_id: str,
78
+ *,
79
+ wavelength_nm: float | None = None,
80
+ spectrum: tuple[tuple[float, float], ...] = (),
81
+ power_W: float = DEFAULT_LASER_POWER_W,
82
+ waist_radius_mm: float = 0.5,
83
+ waist_radius_y_mm: float | None = None,
84
+ position: Vector3 = (0.0, 0.0, 0.0),
85
+ direction: Vector3 = (1.0, 0.0, 0.0),
86
+ up: Vector3 = _Z,
87
+ m2: float = 1.0,
88
+ m2_y: float | None = None,
89
+ waist_position_z_mm: float = 0.0,
90
+ polarization: JonesVector | None = None,
91
+ coherence_length_mm: float | None = None,
92
+ linewidth_nm: float | None = None,
93
+ coherence_group: str | None = None,
94
+ medium: str = "air",
95
+ envelope: EngineeringEnvelopeSnapshot | None = None,
96
+ source_id: str | None = None,
97
+ ) -> EngineeringComponentSnapshot:
98
+ from .contracts import SpectralLine
99
+
100
+ src = SourceSpec(
101
+ source_id=source_id or instance_id,
102
+ wavelength_nm=wavelength_nm,
103
+ spectrum=tuple(SpectralLine(wavelength_nm=w, weight=wt) for w, wt in spectrum),
104
+ power_W=power_W,
105
+ waist_radius_x_mm=waist_radius_mm,
106
+ waist_radius_y_mm=waist_radius_y_mm if waist_radius_y_mm is not None else waist_radius_mm,
107
+ waist_position_z_mm=waist_position_z_mm,
108
+ m2_x=m2,
109
+ m2_y=m2_y if m2_y is not None else m2,
110
+ polarization=polarization or JonesVector.linear(0.0),
111
+ coherence_length_mm=coherence_length_mm,
112
+ linewidth_nm=linewidth_nm,
113
+ coherence_group=coherence_group,
114
+ medium=medium,
115
+ )
116
+ return EngineeringComponentSnapshot(
117
+ instance_id=instance_id, role="source", pose=_pose(position, direction, up), source=src, envelope=envelope,
118
+ ports=(OpticalPort(port_id="out", position_mm=(0.0, 0.0, 0.0), axis=_Z, kind="output"),),
119
+ )
120
+
121
+
122
+ def thin_lens(
123
+ instance_id: str, *, focal_length_mm: float, clear_radius_mm: float, position: Vector3, axis: Vector3 = (1.0, 0.0, 0.0),
124
+ up: Vector3 = _Z, transmittance: float = 1.0, envelope: EngineeringEnvelopeSnapshot | None = None,
125
+ actuators: tuple[ActuatorSpec, ...] = (), medium: str = "air",
126
+ ) -> EngineeringComponentSnapshot:
127
+ surf = PlaneSurface(
128
+ surface_id="lens", origin_mm=(0.0, 0.0, 0.0), normal=_Z, aperture=CircularAperture(radius_mm=clear_radius_mm),
129
+ mount_margin_mm=DEFAULT_MOUNT_MARGIN_MM, medium_positive=medium, medium_negative=medium,
130
+ interaction=ThinLensInteraction(focal_length_mm=focal_length_mm, transmittance=transmittance),
131
+ )
132
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="lens", pose=_pose(position, axis, up), surfaces=(surf,),
133
+ envelope=envelope, actuators=actuators)
134
+
135
+
136
+ def mirror(
137
+ instance_id: str, *, position: Vector3, normal: Vector3, clear_radius_mm: float | None = ONE_INCH_CLEAR_RADIUS_MM,
138
+ half_width_mm: float | None = None, half_height_mm: float | None = None, up: Vector3 = _Z,
139
+ reflectance: float | None = None, envelope: EngineeringEnvelopeSnapshot | None = None,
140
+ actuators: tuple[ActuatorSpec, ...] = (), mount_margin_mm: float = 0.0, medium: str = "air",
141
+ ) -> EngineeringComponentSnapshot:
142
+ """Flat mirror; ``normal`` points toward the incident (reflective) side."""
143
+ aperture = (
144
+ RectangularAperture(half_width_mm=half_width_mm, half_height_mm=half_height_mm)
145
+ if half_width_mm is not None and half_height_mm is not None
146
+ else (CircularAperture(radius_mm=clear_radius_mm) if clear_radius_mm else None)
147
+ )
148
+ coating = None if reflectance is None else ConstantCoating(reflectance_s=reflectance, reflectance_p=reflectance,
149
+ transmittance_s=0.0, transmittance_p=0.0)
150
+ surf = PlaneSurface(surface_id="face", origin_mm=(0.0, 0.0, 0.0), normal=_Z, aperture=aperture,
151
+ mount_margin_mm=mount_margin_mm,
152
+ medium_positive=medium, medium_negative=medium,
153
+ interaction=MirrorInteraction(reflective_side="positive", coating=coating))
154
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="mirror", pose=_pose(position, normal, up), surfaces=(surf,),
155
+ envelope=envelope, actuators=actuators)
156
+
157
+
158
+ def mirror_for_reflection(instance_id: str, *, position: Vector3, d_in: Vector3, d_out: Vector3, **kwargs: object) -> EngineeringComponentSnapshot:
159
+ """Flat mirror oriented so that ``d_in`` reflects into ``d_out`` (normal ∝ d_out - d_in, toward the incident side)."""
160
+ a, b = np.array(d_in, dtype=float), np.array(d_out, dtype=float)
161
+ a, b = a / np.linalg.norm(a), b / np.linalg.norm(b)
162
+ n = b - a
163
+ if np.linalg.norm(n) < DEFAULT_NUMERICAL_CONFIG.zero_vector_tol:
164
+ raise ValueError("d_in and d_out are identical: no mirror can produce that")
165
+ n = n / np.linalg.norm(n)
166
+ return mirror(instance_id, position=position, normal=tuple(float(x) for x in n), **kwargs) # type: ignore[arg-type]
167
+
168
+
169
+ def spherical_mirror(
170
+ instance_id: str, *, position: Vector3, normal: Vector3, radius_of_curvature_mm: float, clear_radius_mm: float,
171
+ up: Vector3 = _Z, envelope: EngineeringEnvelopeSnapshot | None = None, medium: str = "air",
172
+ ) -> EngineeringComponentSnapshot:
173
+ """Spherical mirror; ``radius_of_curvature_mm > 0`` is concave (centre of curvature on the incident side)."""
174
+ r = abs(radius_of_curvature_mm)
175
+ concave = radius_of_curvature_mm > 0
176
+ center = (0.0, 0.0, r if concave else -r)
177
+ cap = (0.0, 0.0, -1.0 if concave else 1.0)
178
+ surf = SphereSurface(
179
+ surface_id="face", center_mm=center, radius_mm=r, cap_axis=cap, aperture_radius_mm=clear_radius_mm,
180
+ medium_positive=medium, medium_negative=medium,
181
+ interaction=MirrorInteraction(reflective_side="negative" if concave else "positive"),
182
+ )
183
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="mirror", pose=_pose(position, normal, up), surfaces=(surf,),
184
+ envelope=envelope)
185
+
186
+
187
+ def singlet_lens(
188
+ instance_id: str, *, r1_mm: float, r2_mm: float, thickness_mm: float, clear_radius_mm: float, material: str,
189
+ position: Vector3, axis: Vector3 = (1.0, 0.0, 0.0), up: Vector3 = _Z, surround: str = "air",
190
+ envelope: EngineeringEnvelopeSnapshot | None = None,
191
+ ) -> EngineeringComponentSnapshot:
192
+ """Thick spherical singlet (exact refraction). ``r1``/``r2`` follow the usual sign convention: positive radius = centre
193
+ on the ``+z`` (exit) side; ``math.inf`` = flat. The element origin is the lens mid-plane; light travels along ``+z``."""
194
+ half = thickness_mm / 2.0
195
+
196
+ def surface(name: str, vertex_z: float, radius: float, source_side: str, exit_side: str) -> PlaneSurface | SphereSurface:
197
+ if math.isinf(radius):
198
+ return PlaneSurface(surface_id=name, origin_mm=(0.0, 0.0, vertex_z), normal=(0.0, 0.0, -1.0),
199
+ aperture=CircularAperture(radius_mm=clear_radius_mm), mount_margin_mm=DEFAULT_MOUNT_MARGIN_MM,
200
+ medium_positive=source_side, medium_negative=exit_side, interaction=RefractInteraction())
201
+ r = abs(radius)
202
+ center_z = vertex_z + radius
203
+ cap_z = -1.0 if radius > 0 else 1.0 # from the centre toward the vertex
204
+ centre_on_exit_side = radius > 0
205
+ # sphere interior = the side that contains the centre
206
+ inside, outside = (exit_side, source_side) if centre_on_exit_side else (source_side, exit_side)
207
+ return SphereSurface(surface_id=name, center_mm=(0.0, 0.0, center_z), radius_mm=r, cap_axis=(0.0, 0.0, cap_z),
208
+ aperture_radius_mm=min(clear_radius_mm, r), mount_margin_mm=DEFAULT_MOUNT_MARGIN_MM,
209
+ medium_positive=outside, medium_negative=inside,
210
+ interaction=RefractInteraction())
211
+
212
+ surfaces = (
213
+ surface("front", -half, r1_mm, surround, material),
214
+ surface("back", half, r2_mm, material, surround),
215
+ )
216
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="lens", pose=_pose(position, axis, up), surfaces=surfaces,
217
+ envelope=envelope)
218
+
219
+
220
+ def window(
221
+ instance_id: str, *, thickness_mm: float, clear_radius_mm: float, material: str, position: Vector3,
222
+ axis: Vector3 = (1.0, 0.0, 0.0), up: Vector3 = _Z, surround: str = "air", trace_fresnel_reflection: bool = False,
223
+ envelope: EngineeringEnvelopeSnapshot | None = None,
224
+ ) -> EngineeringComponentSnapshot:
225
+ """Plane-parallel plate (two refracting planes); light travels along ``axis`` at the plate's mid-plane."""
226
+ half = thickness_mm / 2.0
227
+ ap = CircularAperture(radius_mm=clear_radius_mm)
228
+ front = PlaneSurface(surface_id="front", origin_mm=(0.0, 0.0, -half), normal=(0.0, 0.0, -1.0), aperture=ap,
229
+ mount_margin_mm=DEFAULT_MOUNT_MARGIN_MM, medium_positive=surround, medium_negative=material,
230
+ interaction=RefractInteraction(trace_fresnel_reflection=trace_fresnel_reflection))
231
+ back = PlaneSurface(surface_id="back", origin_mm=(0.0, 0.0, half), normal=(0.0, 0.0, 1.0), aperture=ap,
232
+ mount_margin_mm=DEFAULT_MOUNT_MARGIN_MM, medium_positive=surround, medium_negative=material,
233
+ interaction=RefractInteraction(trace_fresnel_reflection=trace_fresnel_reflection))
234
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="window", pose=_pose(position, axis, up),
235
+ surfaces=(front, back), envelope=envelope)
236
+
237
+
238
+ def beam_splitter(
239
+ instance_id: str, *, position: Vector3, normal: Vector3, coating: ConstantCoating, clear_radius_mm: float = ONE_INCH_CLEAR_RADIUS_MM,
240
+ reflection_phase_rad: float = math.pi / 2, up: Vector3 = _Z, envelope: EngineeringEnvelopeSnapshot | None = None,
241
+ medium: str = "air",
242
+ ) -> EngineeringComponentSnapshot:
243
+ surf = PlaneSurface(surface_id="coating", origin_mm=(0.0, 0.0, 0.0), normal=_Z,
244
+ aperture=CircularAperture(radius_mm=clear_radius_mm),
245
+ medium_positive=medium, medium_negative=medium,
246
+ interaction=BeamSplitterInteraction(coating=coating, reflection_phase_rad=reflection_phase_rad))
247
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="splitter", pose=_pose(position, normal, up), surfaces=(surf,),
248
+ envelope=envelope)
249
+
250
+
251
+ def npbs(instance_id: str, *, reflectance: float = 0.5, **kwargs: object) -> EngineeringComponentSnapshot:
252
+ """Non-polarizing (lossless) splitter with power reflectance ``reflectance``."""
253
+ coating = ConstantCoating(reflectance_s=reflectance, reflectance_p=reflectance,
254
+ transmittance_s=1.0 - reflectance, transmittance_p=1.0 - reflectance)
255
+ return beam_splitter(instance_id, coating=coating, **kwargs) # type: ignore[arg-type]
256
+
257
+
258
+ def pbs(instance_id: str, *, extinction_reflect_p: float = 0.0, extinction_transmit_s: float = 0.0, **kwargs: object) -> EngineeringComponentSnapshot:
259
+ """Polarizing splitter: p transmitted, s reflected; leakage fractions ``R_p`` and ``T_s`` (default ideal 0)."""
260
+ coating = ConstantCoating(reflectance_s=1.0 - extinction_transmit_s, reflectance_p=extinction_reflect_p,
261
+ transmittance_s=extinction_transmit_s, transmittance_p=1.0 - extinction_reflect_p)
262
+ return beam_splitter(instance_id, coating=coating, **kwargs) # type: ignore[arg-type]
263
+
264
+
265
+ def polarizer(instance_id: str, *, position: Vector3, axis: Vector3, angle_deg: float, clear_radius_mm: float = ONE_INCH_CLEAR_RADIUS_MM,
266
+ up: Vector3 = _Z, t_par: float = 1.0, t_perp: float = 0.0, envelope: EngineeringEnvelopeSnapshot | None = None,
267
+ medium: str = "air") -> EngineeringComponentSnapshot:
268
+ a = math.radians(angle_deg)
269
+ surf = PlaneSurface(surface_id="plate", origin_mm=(0.0, 0.0, 0.0), normal=_Z, aperture=CircularAperture(radius_mm=clear_radius_mm),
270
+ mount_margin_mm=DEFAULT_MOUNT_MARGIN_MM, medium_positive=medium, medium_negative=medium,
271
+ interaction=PolarizerInteraction(axis_local=(math.cos(a), math.sin(a), 0.0),
272
+ transmittance_parallel=t_par, transmittance_perpendicular=t_perp))
273
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="polarizer", pose=_pose(position, axis, up), surfaces=(surf,),
274
+ envelope=envelope)
275
+
276
+
277
+ def waveplate(instance_id: str, *, position: Vector3, axis: Vector3, fast_axis_angle_deg: float, retardance_rad: float,
278
+ design_wavelength_nm: float, clear_radius_mm: float = ONE_INCH_CLEAR_RADIUS_MM, up: Vector3 = _Z, achromatic: bool = False,
279
+ envelope: EngineeringEnvelopeSnapshot | None = None, medium: str = "air") -> EngineeringComponentSnapshot:
280
+ a = math.radians(fast_axis_angle_deg)
281
+ surf = PlaneSurface(surface_id="plate", origin_mm=(0.0, 0.0, 0.0), normal=_Z, aperture=CircularAperture(radius_mm=clear_radius_mm),
282
+ mount_margin_mm=DEFAULT_MOUNT_MARGIN_MM, medium_positive=medium, medium_negative=medium,
283
+ interaction=RetarderInteraction(fast_axis_local=(math.cos(a), math.sin(a), 0.0), retardance_rad=retardance_rad,
284
+ design_wavelength_nm=design_wavelength_nm, achromatic=achromatic))
285
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="waveplate", pose=_pose(position, axis, up), surfaces=(surf,),
286
+ envelope=envelope)
287
+
288
+
289
+ def half_waveplate(instance_id: str, **kwargs: object) -> EngineeringComponentSnapshot:
290
+ return waveplate(instance_id, retardance_rad=math.pi, **kwargs) # type: ignore[arg-type]
291
+
292
+
293
+ def quarter_waveplate(instance_id: str, **kwargs: object) -> EngineeringComponentSnapshot:
294
+ return waveplate(instance_id, retardance_rad=math.pi / 2.0, **kwargs) # type: ignore[arg-type]
295
+
296
+
297
+ def aperture_stop(instance_id: str, *, position: Vector3, axis: Vector3, radius_mm: float, up: Vector3 = _Z,
298
+ envelope: EngineeringEnvelopeSnapshot | None = None, medium: str = "air") -> EngineeringComponentSnapshot:
299
+ surf = PlaneSurface(surface_id="stop", origin_mm=(0.0, 0.0, 0.0), normal=_Z, aperture=CircularAperture(radius_mm=radius_mm),
300
+ mount_margin_mm=DEFAULT_STOP_PLATE_MARGIN_MM, medium_positive=medium, medium_negative=medium, interaction=ApertureStopInteraction())
301
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="aperture", pose=_pose(position, axis, up), surfaces=(surf,),
302
+ envelope=envelope)
303
+
304
+
305
+ def beam_dump(instance_id: str, *, position: Vector3, axis: Vector3, radius_mm: float = ONE_INCH_CLEAR_RADIUS_MM, up: Vector3 = _Z,
306
+ medium: str = "air") -> EngineeringComponentSnapshot:
307
+ surf = PlaneSurface(surface_id="dump", origin_mm=(0.0, 0.0, 0.0), normal=_Z, aperture=CircularAperture(radius_mm=radius_mm),
308
+ medium_positive=medium, medium_negative=medium, interaction=AbsorberInteraction())
309
+ return EngineeringComponentSnapshot(instance_id=instance_id, role="dump", pose=_pose(position, axis, up), surfaces=(surf,))
310
+
311
+
312
+ def observation_plane(observation_id: str, *, position: Vector3, normal: Vector3, radius_mm: float | None = None,
313
+ half_width_mm: float | None = None, half_height_mm: float | None = None, up: Vector3 = _Z,
314
+ acceptance_half_angle_deg: float | None = None, absorbing: bool = False, medium: str = "air",
315
+ device_id: str | None = None) -> Observation:
316
+ aperture = None
317
+ if radius_mm is not None:
318
+ aperture = CircularAperture(radius_mm=radius_mm)
319
+ elif half_width_mm is not None and half_height_mm is not None:
320
+ aperture = RectangularAperture(half_width_mm=half_width_mm, half_height_mm=half_height_mm)
321
+ return Observation(observation_id=observation_id, device_id=device_id, pose=_pose(position, normal, up), aperture=aperture,
322
+ acceptance_half_angle_deg=acceptance_half_angle_deg, absorbing=absorbing, medium=medium)
323
+
324
+
325
+ def unit(v: Vector3) -> Vector3:
326
+ a = np.array(v, dtype=float)
327
+ a = a / np.linalg.norm(a)
328
+ return (float(a[0]), float(a[1]), float(a[2]))
opensci_engine/cli.py ADDED
@@ -0,0 +1,69 @@
1
+ """Command line interface: ``opensci-engine validate request.json``.
2
+
3
+ Exit codes: 0 = PASS / WARNING / NOT_APPLICABLE, 1 = FAIL, 2 = UNKNOWN, 3 = OUT_OF_MODEL_SCOPE, 64 = usage / unreadable input.
4
+ The CLI reads the given file and writes the result; the engine itself performs no I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+ from .compare import compare_results
16
+ from .contracts.request import ValidationRequest
17
+ from .contracts.results import ValidationResult
18
+ from .validation import validate_json
19
+
20
+ EXIT_CODES = {"PASS": 0, "WARNING": 0, "NOT_APPLICABLE": 0, "FAIL": 1, "UNKNOWN": 2, "OUT_OF_MODEL_SCOPE": 3}
21
+ USAGE_ERROR = 64
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ parser = argparse.ArgumentParser(prog="opensci-engine", description="Deterministic optical / layout / control validation engine.")
26
+ parser.add_argument("--version", action="version", version=f"opensci-engine {__version__}")
27
+ sub = parser.add_subparsers(dest="command", required=True)
28
+ p_val = sub.add_parser("validate", help="validate a ValidationRequest JSON file")
29
+ p_val.add_argument("request", help="path to request JSON ('-' for stdin)")
30
+ p_val.add_argument("-o", "--output", help="write the ValidationResult JSON here (default: stdout)")
31
+ p_val.add_argument("--pretty", action="store_true", help="indent the output JSON")
32
+ p_schema = sub.add_parser("schema", help="print the JSON Schema of the public request or result contract")
33
+ p_schema.add_argument("which", choices=["request", "result"])
34
+ p_cmp = sub.add_parser("compare", help="compare two ValidationResult JSON files within the cross-runtime tolerances")
35
+ p_cmp.add_argument("left")
36
+ p_cmp.add_argument("right")
37
+ args = parser.parse_args(argv)
38
+
39
+ if args.command == "schema":
40
+ model = ValidationRequest if args.which == "request" else ValidationResult
41
+ print(json.dumps(model.model_json_schema(), indent=2, sort_keys=True))
42
+ return 0
43
+ if args.command == "compare":
44
+ try:
45
+ diffs = compare_results(Path(args.left).read_text(encoding="utf-8"), Path(args.right).read_text(encoding="utf-8"))
46
+ except (OSError, json.JSONDecodeError) as exc:
47
+ print(f"error: {exc}", file=sys.stderr)
48
+ return USAGE_ERROR
49
+ for d in diffs:
50
+ print(d)
51
+ print("equivalent" if not diffs else f"{len(diffs)} difference(s)")
52
+ return 0 if not diffs else 1
53
+ try:
54
+ text = sys.stdin.read() if args.request == "-" else Path(args.request).read_text(encoding="utf-8")
55
+ except OSError as exc:
56
+ print(f"error: cannot read request: {exc}", file=sys.stderr)
57
+ return USAGE_ERROR
58
+ out = validate_json(text)
59
+ doc = json.loads(out)
60
+ rendered = json.dumps(doc, indent=2) if args.pretty else out
61
+ if args.output:
62
+ Path(args.output).write_text(rendered, encoding="utf-8")
63
+ else:
64
+ print(rendered)
65
+ return EXIT_CODES[doc["status"]]
66
+
67
+
68
+ if __name__ == "__main__": # pragma: no cover
69
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ """Cross-runtime result comparison (native CPython vs Pyodide vs controlled verification runtime).
2
+
3
+ Rules (plan §15.3.1): canonical input hash identical; every status / code / string / integer / boolean identical; floats equal
4
+ within ``cross_runtime_rel_tol`` / ``cross_runtime_abs_tol``; timestamps, runtime descriptors and JSON key order are not compared.
5
+ ``result_hash`` is reported as information only (it is quantized but a value straddling a rounding boundary can differ).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from .config import NumericalConfig
16
+
17
+ # Fields that legitimately differ between runtimes.
18
+ IGNORED_PATHS = frozenset({"manifest.runtime", "manifest.result_hash", "manifest.trace_hash"})
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Difference:
23
+ path: str
24
+ left: Any
25
+ right: Any
26
+ kind: str # "type" | "value" | "float" | "length" | "keys"
27
+
28
+ def __str__(self) -> str:
29
+ return f"{self.path}: {self.kind}: {self.left!r} != {self.right!r}"
30
+
31
+
32
+ def compare_results(
33
+ left: str | dict, right: str | dict, cfg: NumericalConfig | None = None, *, extra_ignored_paths: frozenset[str] = frozenset()
34
+ ) -> list[Difference]:
35
+ """Return the list of differences (empty = equivalent within tolerance).
36
+
37
+ ``extra_ignored_paths`` adds dotted paths to skip (e.g. ``manifest.engine_build_id`` when comparing against a stored snapshot
38
+ produced by an earlier source revision)."""
39
+ cfg = cfg or NumericalConfig()
40
+ a = json.loads(left) if isinstance(left, (str, bytes)) else left
41
+ b = json.loads(right) if isinstance(right, (str, bytes)) else right
42
+ diffs: list[Difference] = []
43
+ _walk(a, b, "", cfg, diffs, IGNORED_PATHS | extra_ignored_paths)
44
+ return diffs
45
+
46
+
47
+ def _is_number(x: Any) -> bool:
48
+ return isinstance(x, (int, float)) and not isinstance(x, bool)
49
+
50
+
51
+ def _walk(a: Any, b: Any, path: str, cfg: NumericalConfig, out: list[Difference], ignored: frozenset[str]) -> None:
52
+ if path in ignored:
53
+ return
54
+ if isinstance(a, dict) and isinstance(b, dict):
55
+ if set(a) != set(b):
56
+ out.append(Difference(path, sorted(set(a) - set(b)), sorted(set(b) - set(a)), "keys"))
57
+ for k in sorted(set(a) & set(b)):
58
+ _walk(a[k], b[k], f"{path}.{k}" if path else k, cfg, out, ignored)
59
+ return
60
+ if isinstance(a, list) and isinstance(b, list):
61
+ if len(a) != len(b):
62
+ out.append(Difference(path, len(a), len(b), "length"))
63
+ return
64
+ for i, (x, y) in enumerate(zip(a, b)):
65
+ _walk(x, y, f"{path}[{i}]", cfg, out, ignored)
66
+ return
67
+ if isinstance(a, float) or isinstance(b, float):
68
+ if _is_number(a) and _is_number(b):
69
+ fa, fb = float(a), float(b)
70
+ if not (math.isfinite(fa) and math.isfinite(fb)):
71
+ out.append(Difference(path, a, b, "float"))
72
+ elif abs(fa - fb) > cfg.cross_runtime_abs_tol + cfg.cross_runtime_rel_tol * max(abs(fa), abs(fb)):
73
+ out.append(Difference(path, a, b, "float"))
74
+ return
75
+ if type(a) is not type(b) and not (_is_number(a) and _is_number(b)):
76
+ out.append(Difference(path, a, b, "type"))
77
+ return
78
+ if a != b:
79
+ out.append(Difference(path, a, b, "value"))