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.
- opensci_engine/__init__.py +32 -0
- opensci_engine/__main__.py +3 -0
- opensci_engine/_version.py +24 -0
- opensci_engine/builders.py +328 -0
- opensci_engine/cli.py +69 -0
- opensci_engine/compare.py +79 -0
- opensci_engine/config.py +158 -0
- opensci_engine/contracts/__init__.py +76 -0
- opensci_engine/contracts/base.py +28 -0
- opensci_engine/contracts/components.py +143 -0
- opensci_engine/contracts/geometry.py +84 -0
- opensci_engine/contracts/manifest.py +27 -0
- opensci_engine/contracts/materials.py +127 -0
- opensci_engine/contracts/optics.py +281 -0
- opensci_engine/contracts/polarization.py +77 -0
- opensci_engine/contracts/project.py +143 -0
- opensci_engine/contracts/request.py +25 -0
- opensci_engine/contracts/results.py +218 -0
- opensci_engine/control/__init__.py +19 -0
- opensci_engine/control/actuation.py +73 -0
- opensci_engine/control/analysis.py +135 -0
- opensci_engine/control/lm.py +165 -0
- opensci_engine/control/readings.py +61 -0
- opensci_engine/control/sensitivity.py +108 -0
- opensci_engine/errors/__init__.py +6 -0
- opensci_engine/errors/codes.py +83 -0
- opensci_engine/errors/exceptions.py +19 -0
- opensci_engine/errors/issues.py +69 -0
- opensci_engine/errors/status.py +42 -0
- opensci_engine/layout/__init__.py +6 -0
- opensci_engine/layout/checks.py +282 -0
- opensci_engine/layout/geometry.py +168 -0
- opensci_engine/manifest/__init__.py +13 -0
- opensci_engine/manifest/build.py +51 -0
- opensci_engine/manifest/canonical.py +62 -0
- opensci_engine/math/__init__.py +37 -0
- opensci_engine/math/pose.py +61 -0
- opensci_engine/math/rng.py +49 -0
- opensci_engine/math/rotation.py +74 -0
- opensci_engine/math/vectors.py +96 -0
- opensci_engine/optics/__init__.py +0 -0
- opensci_engine/optics/gaussian/__init__.py +24 -0
- opensci_engine/optics/gaussian/ops.py +185 -0
- opensci_engine/optics/gaussian/state.py +143 -0
- opensci_engine/optics/gaussian/truncation.py +136 -0
- opensci_engine/optics/interference/__init__.py +3 -0
- opensci_engine/optics/interference/two_beam.py +184 -0
- opensci_engine/optics/materials/__init__.py +5 -0
- opensci_engine/optics/materials/coatings.py +78 -0
- opensci_engine/optics/materials/index.py +107 -0
- opensci_engine/optics/materials/library.py +51 -0
- opensci_engine/optics/polarization/__init__.py +26 -0
- opensci_engine/optics/polarization/fresnel.py +58 -0
- opensci_engine/optics/polarization/jones.py +104 -0
- opensci_engine/optics/rays/__init__.py +4 -0
- opensci_engine/optics/rays/pupil.py +29 -0
- opensci_engine/optics/rays/tracer.py +709 -0
- opensci_engine/optics/surfaces/__init__.py +14 -0
- opensci_engine/optics/surfaces/geometry.py +161 -0
- opensci_engine/optics/surfaces/interaction.py +74 -0
- opensci_engine/py.typed +0 -0
- opensci_engine/scene/__init__.py +0 -0
- opensci_engine/scene/lower.py +203 -0
- opensci_engine/scene/model.py +100 -0
- opensci_engine/units.py +6 -0
- opensci_engine/validation/__init__.py +4 -0
- opensci_engine/validation/metrics.py +287 -0
- opensci_engine/validation/observations.py +196 -0
- opensci_engine/validation/pipeline.py +224 -0
- opensci_engine/validation/scope.py +38 -0
- opensci_engine-0.1.0.dist-info/METADATA +121 -0
- opensci_engine-0.1.0.dist-info/RECORD +74 -0
- opensci_engine-0.1.0.dist-info/WHEEL +4 -0
- 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,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"))
|