optivibe-reference 0.2.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 (42) hide show
  1. optivibe_reference-0.2.0/PKG-INFO +44 -0
  2. optivibe_reference-0.2.0/README.md +26 -0
  3. optivibe_reference-0.2.0/pyproject.toml +60 -0
  4. optivibe_reference-0.2.0/setup.cfg +4 -0
  5. optivibe_reference-0.2.0/src/optivibe_reference/__init__.py +14 -0
  6. optivibe_reference-0.2.0/src/optivibe_reference/_envelope.py +19 -0
  7. optivibe_reference-0.2.0/src/optivibe_reference/_paths.py +66 -0
  8. optivibe_reference-0.2.0/src/optivibe_reference/_probe_common.py +142 -0
  9. optivibe_reference-0.2.0/src/optivibe_reference/build_data.py +271 -0
  10. optivibe_reference-0.2.0/src/optivibe_reference/build_steps.py +182 -0
  11. optivibe_reference-0.2.0/src/optivibe_reference/catalog_build.py +1138 -0
  12. optivibe_reference-0.2.0/src/optivibe_reference/data/operand_semantics.json +2192 -0
  13. optivibe_reference-0.2.0/src/optivibe_reference/data/operand_synonyms.json +1687 -0
  14. optivibe_reference-0.2.0/src/optivibe_reference/data/provenance_grants.txt +5 -0
  15. optivibe_reference-0.2.0/src/optivibe_reference/data/tolerance_operand_synonyms.json +307 -0
  16. optivibe_reference-0.2.0/src/optivibe_reference/errors.py +57 -0
  17. optivibe_reference-0.2.0/src/optivibe_reference/glass_build.py +433 -0
  18. optivibe_reference-0.2.0/src/optivibe_reference/glass_dispersion.py +207 -0
  19. optivibe_reference-0.2.0/src/optivibe_reference/glass_math.py +383 -0
  20. optivibe_reference-0.2.0/src/optivibe_reference/glass_parse.py +168 -0
  21. optivibe_reference-0.2.0/src/optivibe_reference/manual_build.py +672 -0
  22. optivibe_reference-0.2.0/src/optivibe_reference/manual_enrich.py +490 -0
  23. optivibe_reference-0.2.0/src/optivibe_reference/probe_operands.py +226 -0
  24. optivibe_reference-0.2.0/src/optivibe_reference/probe_tolerances.py +371 -0
  25. optivibe_reference-0.2.0/src/optivibe_reference/server.py +547 -0
  26. optivibe_reference-0.2.0/src/optivibe_reference/tolerance_build.py +554 -0
  27. optivibe_reference-0.2.0/src/optivibe_reference/tools/__init__.py +5 -0
  28. optivibe_reference-0.2.0/src/optivibe_reference/tools/_glass_common.py +137 -0
  29. optivibe_reference-0.2.0/src/optivibe_reference/tools/_glass_kind.py +133 -0
  30. optivibe_reference-0.2.0/src/optivibe_reference/tools/_query_params.py +57 -0
  31. optivibe_reference-0.2.0/src/optivibe_reference/tools/_rag_match.py +92 -0
  32. optivibe_reference-0.2.0/src/optivibe_reference/tools/find_glass_pair.py +574 -0
  33. optivibe_reference-0.2.0/src/optivibe_reference/tools/find_glasses.py +339 -0
  34. optivibe_reference-0.2.0/src/optivibe_reference/tools/lookup_glass.py +262 -0
  35. optivibe_reference-0.2.0/src/optivibe_reference/tools/lookup_operand.py +463 -0
  36. optivibe_reference-0.2.0/src/optivibe_reference/tools/search_reference.py +321 -0
  37. optivibe_reference-0.2.0/src/optivibe_reference.egg-info/PKG-INFO +44 -0
  38. optivibe_reference-0.2.0/src/optivibe_reference.egg-info/SOURCES.txt +40 -0
  39. optivibe_reference-0.2.0/src/optivibe_reference.egg-info/dependency_links.txt +1 -0
  40. optivibe_reference-0.2.0/src/optivibe_reference.egg-info/entry_points.txt +2 -0
  41. optivibe_reference-0.2.0/src/optivibe_reference.egg-info/requires.txt +9 -0
  42. optivibe_reference-0.2.0/src/optivibe_reference.egg-info/top_level.txt +1 -0
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: optivibe-reference
3
+ Version: 0.2.0
4
+ Summary: OptiVibe reference layer: lookup tables + RAG + intent->ZOS-API grounding
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/chuang44-tiff/optivibe-mcp
7
+ Project-URL: Issues, https://github.com/chuang44-tiff/optivibe-mcp/issues
8
+ Classifier: License :: OSI Approved :: Apache Software License
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Provides-Extra: manual
13
+ Requires-Dist: pymupdf; extra == "manual"
14
+ Provides-Extra: probe
15
+ Requires-Dist: pythonnet; extra == "probe"
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+
19
+ # optivibe-reference
20
+
21
+ The grounding layer for optivibe-mcp: deterministic lookup tables, a SQLite
22
+ **FTS5** full-text store over the OpticStudio manual, and an intent→operand
23
+ grounding index. It lets the agent consult operand meanings, glass properties,
24
+ and manual passages so its choices are grounded rather than guessed.
25
+
26
+ It is composed **in-process** by `optivibe-mcp` (not a standalone server) and
27
+ exposes five tools: `lookup_operand`, `search_reference`, `lookup_glass`,
28
+ `find_glasses`, and `find_glass_pair`.
29
+
30
+ ## Data is built locally
31
+
32
+ No vendor data is committed. The glass catalog (recomputed from your install's
33
+ `.agf` files), the manual FTS5 corpus (indexed from your manual PDF), and the
34
+ description-bearing operand/tolerance catalogs are all **built on your machine
35
+ from your own licensed OpticStudio install** and are gitignored. Only the
36
+ author's own sources ship — the `*_synonyms.json` intent vocabularies and
37
+ `operand_semantics.json`. Build the data with the `optivibe-build-data` command that
38
+ comes with the package; the [repository README](https://github.com/chuang44-tiff/optivibe-mcp#readme) has the
39
+ steps and `PROVENANCE.md` the data-source ledger.
40
+
41
+ Any tool whose data has not been built degrades to a typed "unavailable" result
42
+ instead of failing — the rest of the server keeps working.
43
+
44
+ See `AGENTS.md` for more detail. Python 3.11.
@@ -0,0 +1,26 @@
1
+ # optivibe-reference
2
+
3
+ The grounding layer for optivibe-mcp: deterministic lookup tables, a SQLite
4
+ **FTS5** full-text store over the OpticStudio manual, and an intent→operand
5
+ grounding index. It lets the agent consult operand meanings, glass properties,
6
+ and manual passages so its choices are grounded rather than guessed.
7
+
8
+ It is composed **in-process** by `optivibe-mcp` (not a standalone server) and
9
+ exposes five tools: `lookup_operand`, `search_reference`, `lookup_glass`,
10
+ `find_glasses`, and `find_glass_pair`.
11
+
12
+ ## Data is built locally
13
+
14
+ No vendor data is committed. The glass catalog (recomputed from your install's
15
+ `.agf` files), the manual FTS5 corpus (indexed from your manual PDF), and the
16
+ description-bearing operand/tolerance catalogs are all **built on your machine
17
+ from your own licensed OpticStudio install** and are gitignored. Only the
18
+ author's own sources ship — the `*_synonyms.json` intent vocabularies and
19
+ `operand_semantics.json`. Build the data with the `optivibe-build-data` command that
20
+ comes with the package; the [repository README](https://github.com/chuang44-tiff/optivibe-mcp#readme) has the
21
+ steps and `PROVENANCE.md` the data-source ledger.
22
+
23
+ Any tool whose data has not been built degrades to a typed "unavailable" result
24
+ instead of failing — the rest of the server keeps working.
25
+
26
+ See `AGENTS.md` for more detail. Python 3.11.
@@ -0,0 +1,60 @@
1
+ # pyproject.toml — minimal packaging metadata for optivibe_reference.
2
+ #
3
+ # Exists so the harness env can `pip install -e ../optivibe-reference` and import
4
+ # optivibe_reference in-process (the single OptiVibe MCP composes both tool sets;
5
+ # the harness imports the reference one-way). setuptools src-layout.
6
+ #
7
+ # Runtime `dependencies = []`: the reference glass/operand path is pure-stdlib
8
+ # (sqlite + FTS5 are stdlib), so the editable install pulls CODE only and adds zero
9
+ # dep surface to the harness env (it never competes for the single-seat license).
10
+ # The manual-PDF ingest dep (pymupdf) and the test runner (pytest) are OPTIONAL
11
+ # extras so the runtime install stays lean.
12
+ #
13
+ # package-data is an EXPLICIT list of the committed files that are ours (resolved via
14
+ # __file__-relative anchors, which an editable install preserves). Never a glob: the
15
+ # data folder also holds the files a user builds from their own licensed install, and
16
+ # none of those may enter a wheel. It also ships provenance_grants.txt, the ledger-row
17
+ # markers the two fail-closed build gates read in an installed package.
18
+ #
19
+ # `optivibe-build-data` rebuilds every vendor-derived plane from the user's own
20
+ # licensed install, with no clone. The `probe` extra adds pythonnet for
21
+ # `optivibe-build-data --probe`; the optivibe-mcp install already carries it.
22
+ [build-system]
23
+ requires = ["setuptools>=61"]
24
+ build-backend = "setuptools.build_meta"
25
+
26
+ [project]
27
+ name = "optivibe-reference"
28
+ version = "0.2.0"
29
+ description = "OptiVibe reference layer: lookup tables + RAG + intent->ZOS-API grounding"
30
+ requires-python = ">=3.11"
31
+ readme = "README.md"
32
+ license = {text = "Apache-2.0"}
33
+ classifiers = [
34
+ "License :: OSI Approved :: Apache Software License",
35
+ "Programming Language :: Python :: 3.11",
36
+ ]
37
+ dependencies = []
38
+
39
+ [project.optional-dependencies]
40
+ manual = ["pymupdf"]
41
+ probe = ["pythonnet"]
42
+ dev = ["pytest"]
43
+
44
+ [project.scripts]
45
+ optivibe-build-data = "optivibe_reference.build_data:main"
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/chuang44-tiff/optivibe-mcp"
49
+ Issues = "https://github.com/chuang44-tiff/optivibe-mcp/issues"
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
53
+
54
+ [tool.setuptools.package-data]
55
+ optivibe_reference = [
56
+ "data/operand_semantics.json",
57
+ "data/operand_synonyms.json",
58
+ "data/tolerance_operand_synonyms.json",
59
+ "data/provenance_grants.txt",
60
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,14 @@
1
+ """optivibe_reference — lookup tables + RAG + intent->ZOS-API grounding index.
2
+
3
+ The grounding layer that makes a smaller local orchestrator viable: an FTS5 RAG
4
+ over reference material plus an intent->ZOS-API mapping index the agent consults
5
+ FIRST (probe-first).
6
+
7
+ Layer 1 (this increment): the ``MeritOperandType`` operand catalog (438 live
8
+ codes, authored/manual descriptions pending, live-probed parameter-cell layout
9
+ battery) + the typed, session-free, never-raise ``lookup_operand`` MCP tool that
10
+ grounds design intent onto a merit-function operand. The catalog is built from a
11
+ gitignored, user-built normalized-LF JSON at runtime; the ``.db`` is never committed.
12
+ """
13
+
14
+ __version__ = "0.2.0"
@@ -0,0 +1,19 @@
1
+ """_envelope.py — the reference-layer expected-failure envelope.
2
+
3
+ Re-implemented (NOT imported) from the harness ``_analysis_common.error_envelope``
4
+ shape so the reference package stays decoupled. A handler constructs this for an
5
+ EXPECTED failure class (unknown operand, malformed FTS query); it does NOT raise
6
+ past its own boundary for those.
7
+ """
8
+
9
+
10
+ def error_envelope(tool, error_family, error, **extra):
11
+ """Build the locked expected-failure envelope (never raised past boundary).
12
+
13
+ ``{"ok": False, "tool": tool, "error_family": error_family, "error": error}``
14
+ plus any tool-specific diagnostic fields in ``extra`` (e.g. ``query=...``).
15
+ Mirrors the harness 4-key+extra shape exactly.
16
+ """
17
+ env = {"ok": False, "tool": tool, "error_family": error_family, "error": error}
18
+ env.update(extra)
19
+ return env
@@ -0,0 +1,66 @@
1
+ """_paths.py — the ONE resolver for where build inputs live (clone vs installed wheel).
2
+
3
+ Two locations used to be derived from ``__file__`` as ``<package root>/scripts/...`` and
4
+ ``<repo root>/PROVENANCE.md``. Both exist in a source checkout and NEITHER exists in an
5
+ installed wheel (``site-packages/optivibe_reference`` has no ``scripts/`` sibling and no
6
+ repo root), so every reader and writer routes through this module instead:
7
+
8
+ * ``captures_dir()`` — the live-probe captures + verbatim raw oracles. A source checkout
9
+ keeps them at ``packages/optivibe-reference/scripts/captures`` (unchanged, gitignored);
10
+ an installed package keeps them at ``<package>/data/captures`` (created on first write).
11
+ * ``provenance_ledger_path()`` — the file the two fail-closed provenance gates
12
+ (``glass_build.assert_agf_grant_on_file``, ``manual_build.assert_manual_grant_on_file``)
13
+ read. A source checkout reads the repo-root ledger and ONLY that — deleting it, or the
14
+ row in it, still refuses, exactly as before. An installed package has no repo ledger, so
15
+ it reads the marker file shipped beside the code (``data/provenance_grants.txt``). The
16
+ marker check itself is unchanged; this only chooses which file it reads.
17
+
18
+ "Source checkout" is decided by the CLONE LAYOUT (``pyproject.toml`` + ``scripts/`` at the
19
+ package root), never by whether ``scripts/captures`` exists: that dir is gitignored and
20
+ absent on a fresh clone until the probes run, and keying on it would send a fresh clone's
21
+ probe output into the package's ``data/`` tree instead.
22
+
23
+ Stdlib only; imports nothing from this package (every build module imports it).
24
+ """
25
+ import os
26
+
27
+ _HERE = os.path.dirname(os.path.abspath(__file__)) # .../optivibe_reference
28
+ # packages/optivibe-reference in a clone (src/optivibe_reference -> ../..); some unrelated
29
+ # dir (e.g. ``<venv>/Lib``) in an installed wheel, which is why it is CHECKED, not trusted.
30
+ _PKG_ROOT = os.path.dirname(os.path.dirname(_HERE))
31
+ _REPO_ROOT = os.path.dirname(os.path.dirname(_PKG_ROOT))
32
+
33
+ # The repo-root provenance ledger a source checkout must carry.
34
+ LEDGER_NAME = "PROVENANCE.md"
35
+ # The marker file an installed package ships (package-data) in place of the ledger. It
36
+ # holds the ledger-row marker strings the two gates look for, one per line.
37
+ PROVENANCE_MARKER_NAME = "provenance_grants.txt"
38
+
39
+
40
+ def is_source_checkout(pkg_root=_PKG_ROOT):
41
+ """True iff ``pkg_root`` is a package root in a clone (pyproject + scripts/ beside src)."""
42
+ return (os.path.isfile(os.path.join(pkg_root, "pyproject.toml"))
43
+ and os.path.isdir(os.path.join(pkg_root, "scripts")))
44
+
45
+
46
+ def captures_dir(pkg_root=_PKG_ROOT, package_dir=_HERE):
47
+ """Return the live-probe captures dir (NOT created here — writers create it).
48
+
49
+ Clone: ``<pkg_root>/scripts/captures`` (the historic location, unchanged).
50
+ Installed: ``<package_dir>/data/captures``.
51
+ """
52
+ if is_source_checkout(pkg_root):
53
+ return os.path.join(pkg_root, "scripts", "captures")
54
+ return os.path.join(package_dir, "data", "captures")
55
+
56
+
57
+ def provenance_ledger_path(pkg_root=_PKG_ROOT, repo_root=_REPO_ROOT, package_dir=_HERE):
58
+ """Return the file the provenance gates read (fail-closed either way).
59
+
60
+ Clone: the repo-root ledger, whether or not it exists — a missing ledger in a clone
61
+ must refuse, never fall through to the shipped marker. Installed: the shipped marker
62
+ file; if that is missing too the returned path does not exist and the gate refuses.
63
+ """
64
+ if is_source_checkout(pkg_root):
65
+ return os.path.join(repo_root, LEDGER_NAME)
66
+ return os.path.join(package_dir, "data", PROVENANCE_MARKER_NAME)
@@ -0,0 +1,142 @@
1
+ """_probe_common.py — the shared plumbing of the two live operand probes.
2
+
3
+ ``probe_operands`` (MeritOperandType) and ``probe_tolerances`` (ToleranceOperandType)
4
+ each boot a headless OpticStudio, dump an enum surface to the captures dir, and reap the
5
+ session. This module holds what they share: loading pythonnet, resolving the ZOS-API
6
+ assemblies without a hardcoded install path, the never-raise ``capture`` helper, and the
7
+ JSON writer.
8
+
9
+ Importing this module (or either probe) needs NO pythonnet: ``clr`` is imported inside
10
+ ``load_clr`` only, and the ``PYTHONNET_RUNTIME`` selector is set there too, so the
11
+ harness publication suite (which imports every shipped module) never touches .NET.
12
+ It imports nothing from optivibe_harness (zero cross-package coupling).
13
+ """
14
+ import glob
15
+ import json
16
+ import os
17
+
18
+ from . import _paths
19
+
20
+ # The install hint a missing pythonnet names: the probes are an OPTIONAL extra.
21
+ PROBE_EXTRA_HINT = 'install optivibe-reference[probe] (pip install "optivibe-reference[probe]")'
22
+
23
+
24
+ class PythonnetMissing(ImportError):
25
+ """pythonnet (``clr``) is not importable, so no live probe can run."""
26
+
27
+
28
+ def load_clr():
29
+ """Select the .NET Framework runtime, then import and return ``clr``.
30
+
31
+ The runtime selector MUST be set before ``import clr``. Raises ``PythonnetMissing``
32
+ (an ``ImportError``) naming the ``[probe]`` extra when pythonnet is absent.
33
+ """
34
+ os.environ.setdefault("PYTHONNET_RUNTIME", "netfx")
35
+ try:
36
+ import clr # noqa: E402 (pythonnet; after the runtime selector is set)
37
+ except ImportError as exc:
38
+ raise PythonnetMissing(
39
+ "pythonnet is not installed, so the live probes cannot run - "
40
+ + PROBE_EXTRA_HINT) from exc
41
+ return clr
42
+
43
+
44
+ def out_dir():
45
+ """Where the probes write: the captures dir (``_paths.captures_dir``)."""
46
+ return _paths.captures_dir()
47
+
48
+
49
+ def find_nethelper():
50
+ """Resolve ZOSAPI_NetHelper.dll without hardcoding an install path."""
51
+ override = os.environ.get("ZOSAPI_NETHELPER")
52
+ if override and os.path.isfile(override):
53
+ return override
54
+ docs = os.path.join(os.path.expanduser("~"), "Documents", "Zemax", "ZOS-API", "Libraries")
55
+ candidate = os.path.join(docs, "ZOSAPI_NetHelper.dll")
56
+ if os.path.isfile(candidate):
57
+ return candidate
58
+ root = os.path.join(os.path.expanduser("~"), "Documents", "Zemax")
59
+ hits = glob.glob(os.path.join(root, "**", "ZOSAPI_NetHelper.dll"), recursive=True)
60
+ if hits:
61
+ return hits[0]
62
+ raise FileNotFoundError(
63
+ "ZOSAPI_NetHelper.dll not found; set ZOSAPI_NETHELPER to its path."
64
+ )
65
+
66
+
67
+ def resolve_zemax_dir(initializer):
68
+ """Resolve the OpticStudio install dir (holding ZOSAPI.dll) without hardcoding."""
69
+ def has_dll(d):
70
+ return bool(d) and os.path.isfile(os.path.join(d, "ZOSAPI.dll"))
71
+
72
+ override = os.environ.get("ZEMAX_DIR")
73
+ if has_dll(override):
74
+ initializer.Initialize(override)
75
+ return override, "env:ZEMAX_DIR"
76
+ if initializer.Initialize():
77
+ d = initializer.GetZemaxDirectory()
78
+ if has_dll(d):
79
+ return d, "registry-autodetect"
80
+ candidates = []
81
+ for base in (os.environ.get("ProgramFiles", r"C:\Program Files"),
82
+ os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")):
83
+ candidates += glob.glob(os.path.join(base, "*Zemax OpticStudio*"))
84
+ candidates = sorted((c for c in candidates if has_dll(c)), reverse=True)
85
+ if candidates:
86
+ d = candidates[0]
87
+ initializer.Initialize(d)
88
+ return d, "program-files-scan"
89
+ raise RuntimeError("Could not resolve an OpticStudio install dir with ZOSAPI.dll.")
90
+
91
+
92
+ def load_zosapi(clr):
93
+ """Reference the NetHelper + ZOS-API assemblies; return the ``ZOSAPI`` namespace."""
94
+ nethelper = find_nethelper()
95
+ print(f"[probe] NetHelper: {nethelper}")
96
+ clr.AddReference(nethelper)
97
+ import ZOSAPI_NetHelper # noqa: E402
98
+
99
+ zemax_dir, how = resolve_zemax_dir(ZOSAPI_NetHelper.ZOSAPI_Initializer)
100
+ print(f"[probe] Zemax directory: {zemax_dir} (via {how})")
101
+ clr.AddReference(os.path.join(zemax_dir, "ZOSAPI.dll"))
102
+ clr.AddReference(os.path.join(zemax_dir, "ZOSAPI_Interfaces.dll"))
103
+ import ZOSAPI # noqa: E402
104
+ return ZOSAPI
105
+
106
+
107
+ def enum_members(enum_type):
108
+ """Member names of a .NET enum via System.Enum.GetNames (the live truth)."""
109
+ from System import Enum # noqa: E402
110
+ return [str(n) for n in Enum.GetNames(enum_type)]
111
+
112
+
113
+ def public_members(obj):
114
+ """Non-underscore attribute names on a live object (defensive discovery)."""
115
+ try:
116
+ return sorted(n for n in dir(obj) if not n.startswith("_"))
117
+ except Exception as exc: # noqa: BLE001
118
+ return f"<unavailable: {type(exc).__name__}: {exc}>"
119
+
120
+
121
+ def capture(fn):
122
+ """Call fn(), returning its value or a recorded failure string (never raises)."""
123
+ try:
124
+ v = fn()
125
+ # keep JSON-serializable
126
+ if isinstance(v, (str, int, float, bool)) or v is None:
127
+ return v
128
+ return str(v)
129
+ except Exception as exc: # noqa: BLE001
130
+ return f"<err: {type(exc).__name__}: {exc}>"[:200]
131
+
132
+
133
+ def write_json(name, payload):
134
+ """Write ``payload`` to ``<captures>/<name>`` (dir created on write); return the path."""
135
+ target = out_dir()
136
+ os.makedirs(target, exist_ok=True)
137
+ path = os.path.join(target, name)
138
+ with open(path, "w", encoding="utf-8", newline="\n") as fh:
139
+ json.dump(payload, fh, indent=2)
140
+ fh.write("\n")
141
+ print(f"[probe] wrote {path}")
142
+ return path
@@ -0,0 +1,271 @@
1
+ """build_data.py — one-shot local rebuild of all user-side vendor data (``optivibe-build-data``).
2
+
3
+ OptiVibe ships code only: the glass catalog, the manual FTS5 corpus, and the merit +
4
+ tolerance operand catalogs are built LOCALLY from YOUR licensed Zemax OpticStudio install
5
+ and are never committed. Run this once after install (and again after upgrading
6
+ OpticStudio):
7
+
8
+ optivibe-build-data # installed (pip install optivibe-mcp)
9
+ optivibe-build-data --probe # first run: probe the operand surfaces, then build
10
+
11
+ It is the in-package equivalent of the published ``scripts/build_vendor_data.py`` and
12
+ needs no clone. Steps (each calls the package-level builder; nothing is duplicated here):
13
+
14
+ 0. (``--probe`` only) the live operand + tolerance probes (``probe_operands.main``,
15
+ ``probe_tolerances.main``): boot a headless OpticStudio, write the captures steps 2-4
16
+ need, reap the engine. They need pythonnet (``optivibe-reference[probe]``; the
17
+ optivibe-mcp install already has it). A missing pythonnet or a probe error FAILS
18
+ that probe, and the build steps still run.
19
+ 1. Glass catalog — ``build_steps.build_glass_catalog``: the ``.agf`` files from your
20
+ install's Glasscat dir (``ZEMAX_GLASSCAT`` env override, else
21
+ ``~/Documents/Zemax/Glasscat``) -> ``data/glass_catalog.json``.
22
+ 2. Manual corpus — ``build_steps.build_manual_corpus``: your install's
23
+ ``OpticStudio_UserManual_en.pdf`` (``OPTIVIBE_MANUAL_PDF`` env override, else the
24
+ standard install dir) -> the local ``data/manual_corpus.db`` + text-free
25
+ ``data/manual_manifest.json``, plus (``--emit-raw``) the verbatim raw
26
+ operand/tolerance oracles steps 3-4 merge as local descriptions. Needs PyMuPDF
27
+ (``optivibe-reference[manual]``).
28
+ 3. Merit operand catalog — ``catalog_build.main`` -> ``data/operand_catalog.json``.
29
+ 4. Tolerance operand catalog — ``tolerance_build.main`` ->
30
+ ``data/tolerance_operand_catalog.json``.
31
+
32
+ A missing input (no Glasscat dir, no manual PDF, no PyMuPDF, no live-probe captures) is
33
+ reported and SKIPPED: that tool family answers a typed ``*_unavailable`` envelope until
34
+ the build runs. An unexpected error FAILS the step. Exit code = number of FAILED (not
35
+ skipped) steps. ASCII status output only.
36
+ """
37
+ import argparse
38
+ import importlib.util
39
+ import json
40
+ import os
41
+ import re
42
+ import sys
43
+
44
+ from . import _paths, build_steps, catalog_build, tolerance_build
45
+
46
+ # The live-probe captures each operand-catalog build REQUIRES (regenerated from the
47
+ # user's OpticStudio install); absent -> that step is a fresh-install SKIP.
48
+ OPERAND_CAPTURES = ("operand_inventory_438.json", "probe_operands_capture.json")
49
+ TOLERANCE_CAPTURES = ("tolerance_inventory_62.json", "probe_tolerances_capture.json")
50
+ # The capture the manual step's ``--emit-raw`` opens unconditionally (tolerance oracle).
51
+ TOLERANCE_INVENTORY_CAPTURE = "tolerance_inventory_62.json"
52
+ # ``--emit-raw`` opens BOTH inventories: the merit one for the glued-code gate, the
53
+ # tolerance one for the tolerance oracle. Either absent -> no ``--emit-raw``.
54
+ EMIT_RAW_CAPTURES = (OPERAND_CAPTURES[0], TOLERANCE_INVENTORY_CAPTURE)
55
+
56
+ _DESCRIPTION = (
57
+ "Build the OptiVibe reference data (glass catalog, manual corpus, merit and "
58
+ "tolerance operand catalogs) locally from your licensed OpticStudio install. "
59
+ "A missing input is SKIPPED; the exit code is the number of FAILED steps."
60
+ )
61
+
62
+
63
+ def _ascii(text):
64
+ """``text`` with every non-ASCII character replaced (status output is ASCII-only)."""
65
+ return str(text).encode("ascii", "replace").decode("ascii")
66
+
67
+
68
+ def _say(text):
69
+ print(_ascii(text))
70
+
71
+
72
+ def _error_text(exc):
73
+ return _ascii("%s: %s" % (type(exc).__name__, exc))
74
+
75
+
76
+ def _captures_present(names, captures=None):
77
+ captures = _paths.captures_dir() if captures is None else captures
78
+ return all(os.path.isfile(os.path.join(captures, n)) for n in names)
79
+
80
+
81
+ def probed_count(capture_path):
82
+ """The ``inventory_count`` a probe recorded in its capture, or None.
83
+
84
+ None when the capture is absent, unreadable, or carries no integer count.
85
+ """
86
+ try:
87
+ with open(capture_path, encoding="utf-8") as fh:
88
+ count = json.load(fh).get("inventory_count")
89
+ except (OSError, ValueError, AttributeError):
90
+ return None
91
+ return count if isinstance(count, int) and not isinstance(count, bool) else None
92
+
93
+
94
+ def catalog_step_blocker(captures, captures_dir=None):
95
+ """Why this operand/tolerance catalog cannot be built for the probed install, or None.
96
+
97
+ ``captures`` is ``(<kind>_inventory_<N>.json, probe_<kind>s_capture.json)``; N is the
98
+ operand count the shipped curated data (sign conventions, run-verdict buckets) was
99
+ built against. The probe names its inventory by the LIVE count, so an OpticStudio
100
+ with a different operand list writes a different file and the curated data does not
101
+ cover it. That is not a missing probe, so it must not be reported as one.
102
+ """
103
+ captures_dir = _paths.captures_dir() if captures_dir is None else captures_dir
104
+ curated = int(re.search(r"_(\d+)\.json$", captures[0]).group(1))
105
+ probed = probed_count(os.path.join(captures_dir, captures[1]))
106
+ if probed is None or probed == curated:
107
+ return None
108
+ kind = "tolerance" if captures[0].startswith("tolerance") else "merit"
109
+ return ("your OpticStudio exposes %d %s operands; the curated data shipped with "
110
+ "this release covers the %d it was built against, so this catalog cannot "
111
+ "be built for your OpticStudio version yet (re-running the probes will not "
112
+ "change that)." % (probed, kind, curated))
113
+
114
+
115
+ def _run_probe(label, probe_main):
116
+ """Run one live probe; return 1 iff it FAILED (a missing pythonnet included)."""
117
+ # Lazy: importing the probe modules is cheap and clr-free, but keep them off the
118
+ # import path of a plain build run.
119
+ from ._probe_common import PROBE_EXTRA_HINT, PythonnetMissing
120
+ try:
121
+ rc = probe_main()
122
+ except PythonnetMissing:
123
+ _say("[%s] FAILED - pythonnet is not installed, so the live probe cannot run. "
124
+ "%s." % (label, PROBE_EXTRA_HINT))
125
+ return 1
126
+ except Exception as exc: # noqa: BLE001 — report per-step, keep going
127
+ _say("[%s] FAILED - %s" % (label, _error_text(exc)))
128
+ return 1
129
+ if rc:
130
+ _say("[%s] FAILED - probe returned exit code %d (see messages above)." % (label, rc))
131
+ return 1
132
+ return 0
133
+
134
+
135
+ def run_probes():
136
+ """Step 0: the two live probes, merit first. Returns the number that FAILED."""
137
+ from . import probe_operands, probe_tolerances
138
+ failures = 0
139
+ _say("=== [probe 1/2] merit operand surface (boots a headless OpticStudio) ===")
140
+ failures += _run_probe("probe-operands", lambda: probe_operands.main())
141
+ _say("=== [probe 2/2] tolerance operand surface (boots a headless OpticStudio) ===")
142
+ failures += _run_probe("probe-tolerances", lambda: probe_tolerances.main())
143
+ return failures
144
+
145
+
146
+ def _run_catalog_step(label, captures, build_main):
147
+ """Run a user-built operand/tolerance catalog build; return 1 iff it FAILED.
148
+
149
+ Absent live-probe captures = a fresh install that has not probed yet -> SKIP
150
+ (that domain's operand tools answer operand_catalog_unavailable until built).
151
+ Present captures + a raised error OR a nonzero build exit code -> FAILED.
152
+ """
153
+ blocker = catalog_step_blocker(captures)
154
+ if blocker is not None:
155
+ _say("[%s] SKIPPED - %s" % (label, blocker))
156
+ _say("[%s] this operand family answers operand_catalog_unavailable."
157
+ % label)
158
+ return 0
159
+ if not _captures_present(captures):
160
+ _say("[%s] SKIPPED - live-probe captures not present in %s. Run the probes "
161
+ "against your OpticStudio first: optivibe-build-data --probe"
162
+ % (label, _paths.captures_dir()))
163
+ _say("[%s] this operand family answers operand_catalog_unavailable until "
164
+ "the build runs." % label)
165
+ return 0
166
+ try:
167
+ rc = build_main()
168
+ except Exception as exc: # noqa: BLE001 — report per-step, keep going
169
+ _say("[%s] FAILED - %s" % (label, _error_text(exc)))
170
+ return 1
171
+ if rc:
172
+ _say("[%s] FAILED - build returned exit code %d (see messages above)."
173
+ % (label, rc))
174
+ return 1
175
+ return 0
176
+
177
+
178
+ def _run_glass_step():
179
+ """Step 1; return 1 iff it FAILED. No Glasscat dir -> SKIPPED."""
180
+ try:
181
+ build_steps.build_glass_catalog()
182
+ except FileNotFoundError as exc:
183
+ _say("[glass-build] SKIPPED - %s" % _ascii(exc))
184
+ _say("[glass-build] glass tools answer glass_catalog_unavailable until "
185
+ "this build runs.")
186
+ return 0
187
+ except Exception as exc: # noqa: BLE001 — report per-step, keep going
188
+ _say("[glass-build] FAILED - %s" % _error_text(exc))
189
+ return 1
190
+ return 0
191
+
192
+
193
+ def _run_manual_step():
194
+ """Step 2; return 1 iff it FAILED. No PDF or no PyMuPDF -> SKIPPED."""
195
+ pdf = build_steps.find_manual_pdf()
196
+ if pdf is None:
197
+ _say("[manual-build] SKIPPED - manual PDF not present (set OPTIVIBE_MANUAL_PDF "
198
+ "or install OpticStudio).")
199
+ _say("[manual-build] search_reference answers corpus_unavailable until this "
200
+ "build runs.")
201
+ return 0
202
+ if importlib.util.find_spec("fitz") is None:
203
+ _say("[manual-build] SKIPPED - PyMuPDF is not installed, so the manual PDF "
204
+ "cannot be read. install optivibe-reference[manual] "
205
+ '(pip install "optivibe-reference[manual]").')
206
+ return 0
207
+ try:
208
+ # --emit-raw ALSO writes the gitignored operand/tolerance raw oracles that
209
+ # steps 3-4 merge as verbatim-local descriptions. It opens BOTH inventory
210
+ # captures unconditionally (merit for the glued-code gate, tolerance for the
211
+ # tolerance oracle) — either absent, --emit-raw raises FileNotFoundError mid-run
212
+ # and the whole manual step is mis-reported FAILED. Preflight both: emit the
213
+ # oracles only when both are present, else build the corpus WITHOUT --emit-raw —
214
+ # the SAME fresh-install degrade steps 3-4 take (operand/tolerance stay
215
+ # synonyms-only / unavailable), never a FAIL.
216
+ if _captures_present(EMIT_RAW_CAPTURES):
217
+ emit_args = ["--emit-raw"]
218
+ else:
219
+ emit_args = []
220
+ _say("[manual-build] NOTE: an operand inventory capture is absent -> building "
221
+ "corpus WITHOUT --emit-raw; operand/tolerance catalogs stay "
222
+ "synonyms-only until the live-probe captures are regenerated.")
223
+ build_steps.build_manual_corpus(emit_args, find_pdf=lambda: pdf)
224
+ except Exception as exc: # noqa: BLE001 — report per-step, keep going
225
+ _say("[manual-build] FAILED - %s" % _error_text(exc))
226
+ return 1
227
+ return 0
228
+
229
+
230
+ def parse_args(argv):
231
+ parser = argparse.ArgumentParser(prog="optivibe-build-data", description=_DESCRIPTION)
232
+ parser.add_argument(
233
+ "--probe", action="store_true",
234
+ help="first run the live operand + tolerance probes (boots a headless "
235
+ "OpticStudio; needs pythonnet: optivibe-reference[probe])")
236
+ return parser.parse_args(argv)
237
+
238
+
239
+ def main(argv=None):
240
+ """Run every step; return the number of FAILED steps (``--help`` exits 0)."""
241
+ args = parse_args(list(sys.argv[1:] if argv is None else argv))
242
+ failures = 0
243
+
244
+ if args.probe:
245
+ failures += run_probes()
246
+
247
+ _say("=== [1/4] glass catalog (from your install's .agf Glasscat) ===")
248
+ failures += _run_glass_step()
249
+
250
+ _say("=== [2/4] manual corpus + verbatim raw oracles (from your install's PDF) ===")
251
+ failures += _run_manual_step()
252
+
253
+ _say("=== [3/4] merit operand catalog (user-built; synonyms + local oracle) ===")
254
+ failures += _run_catalog_step(
255
+ "operand-build", OPERAND_CAPTURES, lambda: catalog_build.main()
256
+ )
257
+
258
+ _say("=== [4/4] tolerance operand catalog (user-built; synonyms + local oracle) ===")
259
+ failures += _run_catalog_step(
260
+ "tolerance-build", TOLERANCE_CAPTURES, lambda: tolerance_build.main()
261
+ )
262
+
263
+ if failures:
264
+ _say("done with %d FAILED step(s) - see messages above." % failures)
265
+ else:
266
+ _say("done. Built data is local-only and gitignored - never commit it.")
267
+ return failures
268
+
269
+
270
+ if __name__ == "__main__":
271
+ sys.exit(main())