quantized-lab 0.8.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 (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,194 @@
1
+ """COM "Send to Origin" — push DataStructs into a RUNNING OriginPro instance.
2
+
3
+ Plan item 25 (Windows-only, optional, feature-flagged; architecture guard
4
+ #10): COM ("Send to Origin") is never a hard dependency and never a CI
5
+ requirement. Everywhere else (macOS/Linux, Origin absent, the flag off, or a
6
+ failed dispatch) callers fall back to the existing Origin-ASCII / ``.ogs``
7
+ export (``io/origin.py``, routes ``/api/export/origin`` and
8
+ ``/api/export/origin-project``).
9
+
10
+ Gated behind the ``QZ_ORIGIN_COM=1`` environment variable so a stray pywin32
11
+ install on a Windows dev/CI box never silently starts dispatching COM.
12
+ ``win32com`` is imported LAZILY inside the two public functions, never at
13
+ module scope, so this module imports cleanly on macOS/Linux and keeps the
14
+ ``io/`` pure-layer + no-hard-dependency guarantees (enforced by
15
+ ``tests/test_repo_integrity.py``).
16
+
17
+ Reuses the LabTalk/COM surface proven in ``tools/origin_trial/
18
+ generate_specimens.py`` (the only prior code in this repo to drive Origin's
19
+ COM API): a single ``Origin.ApplicationSI`` instance, ``newbook`` + LabTalk
20
+ ``option:=lsname``, ``PutWorksheet(rows)`` for bulk numeric data (row-major,
21
+ time column first, then one column per value channel), and a single
22
+ ``range ...; ....lname$=...; ....unit$=...;`` LabTalk ``Execute`` call per
23
+ column for long names/units — direct COM property assignment on worksheet
24
+ ranges is flaky in practice; LabTalk string commands are the reliable path
25
+ (see that script's docstring for the hard-won gotchas: only one live
26
+ ``ApplicationSI`` instance at a time — a dead/killed server faults every
27
+ subsequent call).
28
+
29
+ Scope note: this pushes data + long names/units only (matches the plan-item
30
+ ask); it does not set column designations (X/Y/Y-error types) the way the
31
+ ``.ogs`` exporter does — a deliberate smaller surface for the first cut,
32
+ worth revisiting once item 25 is live-verified against real Origin.
33
+
34
+ This repo's own tests never dispatch real COM — see ``tests/test_origin_com.py``
35
+ (mock-based only, a fake ``win32com`` module injected via ``sys.modules``).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import os
41
+ import re
42
+ import sys
43
+ from collections.abc import Sequence
44
+ from typing import Any
45
+
46
+ from quantized.datastruct import DataStruct
47
+
48
+ __all__ = ["com_available", "send_to_origin"]
49
+
50
+ _FLAG_ENV = "QZ_ORIGIN_COM"
51
+ _UNAVAILABLE_HINT = (
52
+ "Use the Origin-ASCII / .ogs export instead "
53
+ "(POST /api/export/origin or /api/export/origin-project)."
54
+ )
55
+
56
+
57
+ def com_available() -> bool:
58
+ """True only on Windows, with ``QZ_ORIGIN_COM=1`` set, AND pywin32
59
+ importable. Never raises — every check is a soft probe, so callers
60
+ (routes, capability checks) can call this unconditionally on any platform.
61
+ """
62
+ if sys.platform != "win32":
63
+ return False
64
+ if os.environ.get(_FLAG_ENV) != "1":
65
+ return False
66
+ try:
67
+ import win32com.client # noqa: F401 (lazy: optional Windows-only dep)
68
+ except ImportError:
69
+ return False
70
+ return True
71
+
72
+
73
+ def _escape_lt(text: str) -> str:
74
+ """Make ``text`` safe inside a LabTalk string literal.
75
+
76
+ LabTalk has NO backslash escape — a ``\\"`` lands literally in Origin
77
+ (live-verified 2026-07-04, see docs/origin_re/validation_log.md), so
78
+ embedded double-quotes are downgraded to single quotes instead.
79
+ """
80
+ return text.replace('"', "'")
81
+
82
+
83
+ def _sanitize_book_name(name: str, index: int, used: set[str]) -> str:
84
+ """A unique, LabTalk-legal workbook short name: non-word chars ->
85
+ underscore, never digit-leading, falls back to ``Book<N>`` when empty,
86
+ and de-duplicated against every name already handed out in this call."""
87
+ cleaned = re.sub(r"\W", "_", name).strip("_") or f"Book{index + 1}"
88
+ if cleaned[0].isdigit():
89
+ cleaned = f"B{cleaned}"
90
+ book, n = cleaned, 1
91
+ while book in used:
92
+ n += 1
93
+ book = f"{cleaned}{n}"
94
+ used.add(book)
95
+ return book
96
+
97
+
98
+ def _label_column(app: Any, book: str, col: int, lname: str, unit: str) -> None:
99
+ """One LabTalk ``Execute`` assigning a column's long name + unit via a
100
+ ``range`` reference — mirrors ``tools/origin_trial/generate_specimens.py``
101
+ (``range ra = [Book]1!col(1); ra.lname$="..."; ra.unit$="...";``)."""
102
+ var = f"__c{col}"
103
+ parts = [f"range {var} = [{book}]1!col({col});"]
104
+ if lname:
105
+ parts.append(f'{var}.lname$ = "{_escape_lt(lname)}";')
106
+ if unit:
107
+ parts.append(f'{var}.unit$ = "{_escape_lt(unit)}";')
108
+ if len(parts) == 1:
109
+ return # nothing to label
110
+ cmd = " ".join(parts)
111
+ if not app.Execute(cmd):
112
+ raise RuntimeError(f"Origin LabTalk command failed: {cmd}")
113
+
114
+
115
+ def send_to_origin(
116
+ datasets: Sequence[DataStruct],
117
+ *,
118
+ book_names: Sequence[str] | None = None,
119
+ ) -> dict[str, Any]:
120
+ """Push each ``DataStruct`` into a new workbook in a RUNNING Origin
121
+ instance via COM, with long names/units set from ``.labels``/``.units``.
122
+
123
+ ``book_names[i]`` (if given and non-empty) names workbook ``i``;
124
+ otherwise falls back to ``metadata['origin_book']`` then ``Book<N>``.
125
+ Returns ``{"books": [<created workbook names>], "rows": [<row count per
126
+ book>]}``.
127
+
128
+ Raises ``RuntimeError`` (never a raw COM exception) when COM is
129
+ unavailable, Origin rejects a command, or dispatch fails — the caller
130
+ (the export route) maps that to an HTTP 409 pointing at the ``.ogs``
131
+ fallback.
132
+ """
133
+ if not datasets:
134
+ raise ValueError("send_to_origin needs at least one dataset")
135
+ if not com_available():
136
+ raise RuntimeError(
137
+ "Origin COM is unavailable on this machine (needs Windows, "
138
+ f"pywin32, {_FLAG_ENV}=1, and a running OriginPro instance). "
139
+ + _UNAVAILABLE_HINT
140
+ )
141
+
142
+ import win32com.client as win32 # lazy: optional Windows-only dep
143
+
144
+ try:
145
+ app = win32.gencache.EnsureDispatch("Origin.ApplicationSI")
146
+ except Exception as exc: # pragma: no cover - real COM only, never in CI
147
+ raise RuntimeError(
148
+ "Could not connect to a running Origin instance via COM. Start "
149
+ "OriginPro first, then retry. " + _UNAVAILABLE_HINT
150
+ ) from exc
151
+
152
+ names = list(book_names) if book_names is not None else []
153
+ used: set[str] = set()
154
+ books: list[str] = []
155
+ rows_created: list[int] = []
156
+ try:
157
+ for i, ds in enumerate(datasets):
158
+ raw = names[i] if i < len(names) and names[i] else ""
159
+ if not raw:
160
+ raw = str(ds.metadata.get("origin_book", "")) or f"Book{i + 1}"
161
+ book = _sanitize_book_name(raw, i, used)
162
+
163
+ if not app.Execute(f"newbook name:={book} option:=lsname;"):
164
+ raise RuntimeError(f"Origin rejected 'newbook' for workbook {book!r}.")
165
+
166
+ cols = [ds.time.tolist()]
167
+ cols.extend(ds.values[:, c].tolist() for c in range(ds.n_channels))
168
+ rows = [list(r) for r in zip(*cols, strict=True)]
169
+ app.PutWorksheet(f"[{book}]1", rows, 0, 0)
170
+
171
+ x_name = str(
172
+ ds.metadata.get("x_column_name")
173
+ or ds.metadata.get("xColumnName")
174
+ or ds.metadata.get("x_column_long") # writer.py's key family
175
+ or "X"
176
+ )
177
+ x_unit = str(
178
+ ds.metadata.get("x_column_unit")
179
+ or ds.metadata.get("xColumnUnit")
180
+ or ds.metadata.get("x_unit")
181
+ or ""
182
+ )
183
+ _label_column(app, book, 1, x_name, x_unit)
184
+ for k, (label, unit) in enumerate(zip(ds.labels, ds.units, strict=True)):
185
+ _label_column(app, book, k + 2, label, unit)
186
+
187
+ books.append(book)
188
+ rows_created.append(ds.n_points)
189
+ except RuntimeError:
190
+ raise
191
+ except Exception as exc: # pragma: no cover - real COM only, never in CI
192
+ raise RuntimeError(f"Origin COM call failed: {exc}") from exc
193
+
194
+ return {"books": books, "rows": rows_created}
@@ -0,0 +1,221 @@
1
+ """Origin project readers (``.opj`` / ``.opju``) — clean-room, no GPL liborigin.
2
+
3
+ Package layout (split per plan item 15 to stay under the module ceiling as
4
+ decoders grow): ``container`` (CPY block primitives), ``opj`` (worksheet data +
5
+ names/units), ``opju`` (FPC column codec, see ``opju_codec``), ``windows``
6
+ (windows-section metadata). Format notes: ``docs/origin_project_format.md`` +
7
+ ``docs/origin_re/``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ from quantized.datastruct import DataStruct
18
+ from quantized.io.origin_project.container import OriginProjectError
19
+ from quantized.io.origin_project.notes import notes_windows, parse_results_log, results_log
20
+ from quantized.io.origin_project.opj import (
21
+ build_opj_books,
22
+ build_opj_primary,
23
+ parse_opj,
24
+ read_opj,
25
+ read_opj_books,
26
+ )
27
+ from quantized.io.origin_project.opju import (
28
+ build_opju_books,
29
+ build_opju_primary,
30
+ parse_opju,
31
+ read_opju,
32
+ read_opju_books,
33
+ )
34
+ from quantized.io.origin_project.tree import opj_folder_paths, opj_project_dates
35
+ from quantized.io.origin_project.tree_opju import opju_folder_paths
36
+
37
+ __all__ = [
38
+ "OriginProjectError",
39
+ "drop_empty_library_books",
40
+ "drop_nonactionable_figures",
41
+ "read_origin_books",
42
+ "read_origin_project",
43
+ "read_origin_project_all",
44
+ ]
45
+
46
+
47
+ def _with_provenance(ds: DataStruct, path: Path, *, raw: bytes | None = None) -> DataStruct:
48
+ """Attach project-global provenance (results log + notes) to one DataStruct.
49
+
50
+ Both are project-global, so they ride only the primary dataset (and the
51
+ first book of a multi-book read) rather than being duplicated per book.
52
+ The file is read once and both scans run over the same bytes (``raw``
53
+ lets a caller that already has the bytes, e.g. :func:`read_origin_books`,
54
+ skip a second read of the file). ``path``'s suffix lets both scans
55
+ restrict themselves to the structurally-derived tail on ``.opj`` (see
56
+ ``notes.py``'s ``_tail_scan_start``) instead of the whole file.
57
+ """
58
+ raw = path.read_bytes() if raw is None else raw
59
+ suffix = path.suffix.lower()
60
+ extra: dict[str, object] = {}
61
+ log = results_log(raw, suffix=suffix)
62
+ if log:
63
+ extra["origin_results_log"] = log
64
+ records = parse_results_log(log)
65
+ if records:
66
+ extra["origin_results_log_records"] = records
67
+ notes = notes_windows(raw, suffix=suffix)
68
+ if notes:
69
+ extra["origin_notes"] = notes
70
+ # .opj project created/modified timestamps (tree.py::opj_project_dates,
71
+ # §13.2 #5); None on the 4.3227 variant / CPYUA bytes — key then absent.
72
+ dates = opj_project_dates(raw)
73
+ if dates:
74
+ extra["origin_project_dates"] = dates
75
+ if not extra:
76
+ return ds
77
+ return dataclasses.replace(ds, metadata={**ds.metadata, **extra})
78
+
79
+
80
+ def read_origin_project(path: Path) -> DataStruct:
81
+ """Dispatch by extension to the clean-room ``.opj`` / ``.opju`` decoder.
82
+
83
+ Origin projects are proprietary binary files; quantized decodes them itself
84
+ (it will not bundle the GPL liborigin). Both containers recover worksheet
85
+ data with real column names/units; the project's results log (analysis
86
+ provenance) lands in ``metadata['origin_results_log']`` (raw text) and
87
+ ``metadata['origin_results_log_records']`` (parsed per-operation records,
88
+ when at least one parses) and any notes-window text in
89
+ ``metadata['origin_notes']`` when present.
90
+ """
91
+ reader = read_opju if path.suffix.lower() == ".opju" else read_opj
92
+ return _with_provenance(reader(path), path)
93
+
94
+
95
+ def _with_folder_path(ds: DataStruct, folder_paths: dict[str, list[str]]) -> DataStruct:
96
+ """Attach ``metadata['origin_folder_path']`` (the Project Explorer folder
97
+ a book lives in, root-exclusive; ``[]`` when unknown or at the root) --
98
+ see ``tree.py``. A ``Book4@2``-style sheet pseudo-book (plan item 5)
99
+ resolves through its base book's name, since sheets aren't separate
100
+ Project Explorer windows."""
101
+ book = str(ds.metadata.get("origin_book", ""))
102
+ base_book, _, _sheet = book.partition("@")
103
+ folder_path = folder_paths.get(base_book, [])
104
+ return dataclasses.replace(ds, metadata={**ds.metadata, "origin_folder_path": folder_path})
105
+
106
+
107
+ def read_origin_books(path: Path) -> list[DataStruct]:
108
+ """Every workbook in an Origin project as its own DataStruct (plan item 3).
109
+
110
+ ``read_origin_project`` keeps the registry's single-DataStruct contract
111
+ (largest book); this is the pure API a multi-dataset import flow (plan
112
+ item 16) builds on. The results log rides the first book only; every
113
+ book gets an ``origin_folder_path`` (plan item: Project Explorer folder
114
+ tree -- see ``tree.py``; decoded for ``.opj`` and both CPYUA ``.opju``
115
+ sub-versions (4.3811 + 4.3380), degrading to ``[]`` only on an unknown
116
+ container or a framing/consistency mismatch).
117
+ """
118
+ is_opju = path.suffix.lower() == ".opju"
119
+ books = read_opju_books(path) if is_opju else read_opj_books(path)
120
+ if not books:
121
+ return books
122
+ raw = path.read_bytes()
123
+ books[0] = _with_provenance(books[0], path, raw=raw)
124
+ folder_paths = opju_folder_paths(raw) if is_opju else opj_folder_paths(raw)
125
+ return [_with_folder_path(b, folder_paths) for b in books]
126
+
127
+
128
+ def read_origin_project_all(
129
+ path: Path, *, raw: bytes | None = None
130
+ ) -> tuple[DataStruct, list[DataStruct]]:
131
+ """Parse an Origin project ONCE and return both ``read_origin_project``'s
132
+ primary DataStruct (with provenance) and ``read_origin_books``'s full
133
+ per-book list (with folder paths + book[0] provenance) — the combined
134
+ result of calling both entry points, without the redundant second
135
+ ``parse_opj``/``parse_opju`` (and repeated file reads) each independently
136
+ performs today.
137
+
138
+ Used by the ``/api/parsers`` import route: profiling a 122-book /
139
+ 8.5M-cell project (``PNR.opj``, 121.56 MB) showed the duplicated
140
+ project-wide parse dominating the ~4s round-trip (2026-07-09) — calling
141
+ ``import_auto`` (-> ``read_origin_project`` -> a full parse) and then
142
+ ``read_origin_books`` (-> another full parse) back to back reads the file
143
+ from disk repeatedly and decodes every column twice.
144
+
145
+ Primary-book construction (``build_opj*_primary``) and all-books
146
+ construction (``build_opj*_books``) stay independent calls sharing only
147
+ the parsed intermediate — mirroring ``read_opj``/``read_opj_books``'s
148
+ separation — so a decode issue confined to one non-primary book still
149
+ degrades the book list to ``[]`` without taking down the primary import,
150
+ exactly as ``routes.parsers._import_with_books`` behaved before (its
151
+ ``except OriginProjectError`` around ``read_origin_books`` only ever
152
+ guarded against that isolated-book-build failure, since a shared-parse
153
+ failure would already have surfaced on the first, unguarded call).
154
+ """
155
+ is_opju = path.suffix.lower() == ".opju"
156
+ raw = path.read_bytes() if raw is None else raw
157
+
158
+ books: list[DataStruct]
159
+ if is_opju:
160
+ opju_parsed = parse_opju(path, raw=raw)
161
+ primary = _with_provenance(build_opju_primary(opju_parsed), path, raw=raw)
162
+ try:
163
+ books = build_opju_books(opju_parsed)
164
+ except OriginProjectError:
165
+ books = []
166
+ else:
167
+ opj_parsed = parse_opj(path, raw=raw)
168
+ primary = _with_provenance(build_opj_primary(opj_parsed), path, raw=raw)
169
+ try:
170
+ books = build_opj_books(opj_parsed)
171
+ except OriginProjectError:
172
+ books = []
173
+
174
+ if books:
175
+ books[0] = _with_provenance(books[0], path, raw=raw)
176
+ folder_paths = opju_folder_paths(raw) if is_opju else opj_folder_paths(raw)
177
+ books = [_with_folder_path(b, folder_paths) for b in books]
178
+ return primary, books
179
+
180
+
181
+ def drop_empty_library_books(books: list[DataStruct]) -> list[DataStruct]:
182
+ """Presentation gate for the multi-book Library listing: hide books with no
183
+ plottable numeric data and no text content.
184
+
185
+ Origin fit/report sheets (plan item 4) surface as pseudo-books whose numeric
186
+ ``.values`` are empty -- their content is unresolved ``cell://`` reference
187
+ stubs in ``metadata['origin_report_sheets']``, not real data -- so listing
188
+ them as top-level Library entries floods it with empty rows (the Hc2 project
189
+ alone produced 48 ``Book2@N`` fit-report shells). Mirrors
190
+ ``drop_nonactionable_figures``: a gate applied at the import boundary, not in
191
+ the decoder, so ``read_origin_books`` stays a complete reader (the report
192
+ metadata still rides the returned DataStruct for a future fit-report surface).
193
+ Text-only books (real content in ``origin_text_columns``) are kept. Never
194
+ returns empty: a degenerate all-empty project passes through unchanged so the
195
+ Library still shows something.
196
+ """
197
+ kept = [
198
+ b
199
+ for b in books
200
+ if (b.values.size and int(np.count_nonzero(np.isfinite(b.values))) > 0)
201
+ or b.metadata.get("origin_text_columns")
202
+ ]
203
+ return kept or books
204
+
205
+
206
+ def drop_nonactionable_figures(figs: list[dict[str, object]]) -> list[dict[str, object]]:
207
+ """Keep only figures a user can act on: those with bound curves OR a resolvable
208
+ source hint.
209
+
210
+ The layer-anchor scan also locates internal storage/thumbnail blocks that carry
211
+ a decodable axis record but no bound curves and no source reference -- the
212
+ decoder can find them, but they are not user graphs and cannot be restored onto
213
+ any imported dataset. Surfacing them just floods the Library's Figures section
214
+ with dead "SYSTEM" rows (the Hc2 project produced 32 of them). This is a
215
+ presentation gate, applied at the import boundary, not in the decoder -- so the
216
+ decoder stays a complete axis-record reader for the synthetic tests. Verified
217
+ against the corpus: keeps all 14 real figures (each has curves), drops all 32
218
+ non-actionable Hc2 anchors. (Moved here from ``figures_opju`` 2026-07-06 --
219
+ it pairs with ``drop_empty_library_books``, the sibling gate above.)
220
+ """
221
+ return [f for f in figs if f.get("curves") or str(f.get("source_hint") or "").strip()]