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,459 @@
1
+ """Read Origin ``.opj`` (CPYA) projects: worksheet data + column names/units.
2
+
3
+ M1 recovered the numeric columns (datasets named ``"<Book>_<Col>"``, 10-byte
4
+ ``<mask><float64>`` records); this adds the windows-section metadata (plan
5
+ item 2): real column long names, units, comments, and X/Y designations. The
6
+ largest book returns as the :class:`~quantized.datastruct.DataStruct` (every
7
+ book's inventory stays in metadata; per-book selection is plan item 3/16).
8
+
9
+ Plan item 4 (non-double column value types) adds inline-text column decode:
10
+ a "Text & Numeric" column reuses the same 10-byte record as a double column,
11
+ but its 8-byte value area holds a short NUL-terminated string instead of a
12
+ raw float64 (`container.decode_inline_text`). Decoded text columns never
13
+ enter `.values` (the data contract is numeric); they attach to metadata as
14
+ `origin_text_columns: {short_name: [str, ...]}`. A column that overflows
15
+ `decode_inline_text`'s 8-byte value area (Origin's FitLinear/NLFit
16
+ auto-generated report-sheet columns, e.g. `"cell://Parameters.Slope.Value"`)
17
+ gets a second try via `container.decode_report_strings` — a wider,
18
+ column-specific record width — attaching to
19
+ `origin_report_sheets: {short_name: [str, ...]}`. Whatever fits neither
20
+ shape keeps the honest drop.
21
+
22
+ Column long names/units/comments and book display titles are Origin
23
+ LabTalk *label* fields — the exact same rich-text escape syntax as graph
24
+ axis titles (`figures.py`/`origin_richtext.py`), since Origin lets a user
25
+ type ``\\+(...)``/``\\g(...)``/etc. into a column's Long Name or Unit row
26
+ just as freely as into an axis title. Confirmed live in the corpus
27
+ (`MnN_Diffusion_PNR.opj`'s "Nuclear SLD" books carry a Unit of
28
+ ``10\\+(-6) A\\+(-2)``): every such field is decoded through
29
+ `clean_richtext` here, at the one `_build_book` shared by both `.opj`
30
+ and `.opju` (`opju.py` reuses it verbatim) — the single chokepoint that
31
+ also feeds every frontend consumer of `.labels`/`.units`/
32
+ `x_column_unit`/`origin_book_long` (axis labels, per-series legends,
33
+ the worksheet header, the Inspector).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from collections import OrderedDict
39
+ from pathlib import Path
40
+ from typing import TypeVar
41
+
42
+ import numpy as np
43
+ from numpy.typing import NDArray
44
+
45
+ from quantized.datastruct import DataStruct
46
+ from quantized.io.origin_project.container import (
47
+ NAME_RE,
48
+ decode_doubles,
49
+ decode_inline_text,
50
+ decode_report_strings,
51
+ fallback,
52
+ plausible_column,
53
+ salvage_column,
54
+ walk_blocks,
55
+ )
56
+ from quantized.io.origin_project.origin_richtext import clean_richtext
57
+ from quantized.io.origin_project.windows import BookMeta, ColumnMeta, window_metadata
58
+
59
+ __all__ = [
60
+ "build_opj_books",
61
+ "build_opj_primary",
62
+ "parse_opj",
63
+ "read_opj",
64
+ "read_opj_books",
65
+ ]
66
+
67
+ _V = TypeVar("_V")
68
+
69
+
70
+ def _columns(
71
+ b: bytes,
72
+ ) -> tuple[
73
+ list[tuple[str, NDArray[np.float64]]], list[tuple[str, list[str]]], list[tuple[str, list[str]]]
74
+ ]:
75
+ """Walk the datasets section, pairing each named header block with its data.
76
+
77
+ Returns ``(numeric_columns, text_columns, report_columns)``. A data block
78
+ that isn't a plausible double column (see `plausible_column`/
79
+ `_looks_textual`) is tried as short inline text (`decode_inline_text`),
80
+ then — if that overflows — as a wider report-sheet record
81
+ (`decode_report_strings`, plan item 4's FitLinear/NLFit residue);
82
+ anything matching none of the three shapes is dropped exactly as before
83
+ (honest-absent, never garbage).
84
+ """
85
+ numeric: list[tuple[str, NDArray[np.float64]]] = []
86
+ text: list[tuple[str, list[str]]] = []
87
+ report: list[tuple[str, list[str]]] = []
88
+ pending: str | None = None
89
+ for size, payload in walk_blocks(b):
90
+ if size == 0:
91
+ continue
92
+ if size % 10 != 0: # a column-header block (never a multiple of 10)
93
+ m = NAME_RE.search(payload)
94
+ pending = m.group(1).decode("latin1") if m else pending
95
+ elif pending is not None and size >= 10: # the paired data block
96
+ vals = decode_doubles(payload)
97
+ # Non-double columns (text/int — plan item 4) reinterpret as float64
98
+ # garbage; drop them rather than emit nonsense. Two tells: absurd
99
+ # magnitudes (int/float32 payloads) and a printable-ASCII payload
100
+ # (text — real float64 arrays run ~35-40% printable bytes, text
101
+ # >90%). All-NaN stays (empty columns are real).
102
+ if plausible_column(vals, allow_all_nan=True) and not _looks_textual(payload):
103
+ numeric.append((pending, vals))
104
+ elif (rows := decode_inline_text(payload)) is not None:
105
+ text.append((pending, rows))
106
+ elif (rows := decode_report_strings(payload)) is not None:
107
+ report.append((pending, rows))
108
+ elif (salvaged := salvage_column(vals)) is not None:
109
+ # Last resort, ORDER MATTERS: only after the text/report
110
+ # decoders pass — a real double column with a couple of stray
111
+ # junk cells (XRD Book6_A) is salvaged with those cells NaN'd;
112
+ # the report-sheet family must never be stolen into numeric.
113
+ numeric.append((pending, salvaged))
114
+ pending = None
115
+ return numeric, text, report
116
+
117
+
118
+ _PRINTABLE = frozenset(range(0x20, 0x7F)) | {0x09, 0x0A, 0x0D}
119
+
120
+
121
+ def _looks_textual(payload: bytes) -> bool:
122
+ """True when a data block's bytes read as text, not float64 records.
123
+
124
+ Two signals must agree: the *non-zero* bytes are across-the-board
125
+ printable (text; float64 mantissa/exponent bytes run well under half —
126
+ but a short column of round values like 10.0 → ``24 40`` can also be
127
+ all-printable), AND the 10-byte record structure is absent — numeric
128
+ records open with a ``00 00`` mask word, text blocks put letters there.
129
+ """
130
+ sample = payload[:400]
131
+ nonzero = [c for c in sample if c != 0]
132
+ if len(nonzero) < 8:
133
+ return False
134
+ if sum(c in _PRINTABLE for c in nonzero) < 0.9 * len(nonzero):
135
+ return False
136
+ n_rec = len(sample) // 10
137
+ masked = sum(1 for k in range(n_rec) if sample[10 * k] == 0 and sample[10 * k + 1] == 0)
138
+ return masked < 0.5 * n_rec
139
+
140
+
141
+ def _label_for(col: str, meta: ColumnMeta | None) -> str:
142
+ """The display label for a value column: its decoded Long Name (Origin
143
+ rich-text escapes translated — see the module docstring) when the windows
144
+ section resolved one, else the bare Origin short designation (A, B, …)."""
145
+ return clean_richtext(meta.long_name) if meta is not None and meta.long_name else col
146
+
147
+
148
+ Columns = list[tuple[str, NDArray[np.float64]]]
149
+ TextColumns = list[tuple[str, list[str]]]
150
+
151
+
152
+ def _group_named(pairs: list[tuple[str, _V]]) -> OrderedDict[str, list[tuple[str, _V]]]:
153
+ """Group ``<Book>_<Col>[@sheet]`` names into per-book column lists.
154
+
155
+ Shared by numeric column grouping (`_group`) and text column grouping —
156
+ the naming/sheet rule doesn't care what a column's values look like.
157
+ """
158
+ books: OrderedDict[str, list[tuple[str, _V]]] = OrderedDict()
159
+ for name, vals in pairs:
160
+ book, _, col = name.rpartition("_")
161
+ col, _, sheet = (col or "A").partition("@")
162
+ if sheet: # sheet N>1 becomes its own pseudo-book "<Book>@N"
163
+ book = f"{book or 'Book'}@{sheet}"
164
+ books.setdefault(book or "Book", []).append((col or "A", vals))
165
+ return books
166
+
167
+
168
+ def _group(columns: Columns) -> OrderedDict[str, Columns]:
169
+ return _group_named(columns)
170
+
171
+
172
+ def _inventory(
173
+ books: OrderedDict[str, Columns],
174
+ books_meta: dict[str, BookMeta],
175
+ report_only: OrderedDict[str, TextColumns] | None = None,
176
+ ) -> list[dict[str, object]]:
177
+ """Book inventory for metadata. ``report_only`` (plan item 4) lists any
178
+ pseudo-book whose columns are entirely Origin's auto-generated
179
+ report-sheet family (e.g. a fit's "FitNL1" sheet with zero plausible
180
+ numeric columns) — without it, such a sheet would be silently absent from
181
+ the inventory despite getting its own DataStruct (see ``_build_book``).
182
+ """
183
+ out = [
184
+ {
185
+ "name": k,
186
+ "long_name": clean_richtext(books_meta[k].long_name) if k in books_meta else k,
187
+ "ncols": len(v),
188
+ "nrows": max((len(a) for _, a in v), default=0),
189
+ }
190
+ for k, v in books.items()
191
+ ]
192
+ for k, v in (report_only or {}).items():
193
+ if k in books:
194
+ continue
195
+ out.append(
196
+ {
197
+ "name": k,
198
+ "long_name": clean_richtext(books_meta[k].long_name) if k in books_meta else k,
199
+ "ncols": len(v),
200
+ "nrows": max((len(rows) for _, rows in v), default=0),
201
+ }
202
+ )
203
+ return out
204
+
205
+
206
+ def _book_long_name(book: str, books_meta: dict[str, BookMeta]) -> str:
207
+ """The book's display title (Origin rich-text escapes translated — see
208
+ the module docstring), with a "(sheet N)" suffix for an extra-sheet
209
+ pseudo-book (``Book@N``)."""
210
+ base_book, _, sheet_no = book.partition("@")
211
+ if sheet_no and base_book in books_meta:
212
+ return f"{clean_richtext(books_meta[base_book].long_name)} (sheet {sheet_no})"
213
+ return clean_richtext(books_meta[book].long_name) if book in books_meta else book
214
+
215
+
216
+ def _build_book(
217
+ book: str,
218
+ cols: Columns,
219
+ books_meta: dict[str, BookMeta],
220
+ inventory: list[dict[str, object]],
221
+ source_format: str = "origin_opj",
222
+ text_cols: TextColumns | None = None,
223
+ report_cols: TextColumns | None = None,
224
+ ) -> DataStruct:
225
+ """Assemble one workbook into a DataStruct.
226
+
227
+ Ragged columns are padded to the book's max length with NaN. The X column
228
+ is the first designation-X column when the windows metadata knows one,
229
+ else the first column; the rest become value columns labelled by their
230
+ long name (falling back to the Origin short designation A, B, …).
231
+
232
+ ``text_cols`` (plan item 4) are this book's inline-text columns — never
233
+ part of `.values` (the data contract is numeric); they attach under
234
+ ``metadata["origin_text_columns"]`` keyed by Origin short name (A, B, …).
235
+ ``report_cols`` (plan item 4, report-sheet residue) are this book's
236
+ report-sheet reference-string columns (Notes/Summary/Parameters/RegStats/
237
+ ANOVA "cell://" columns), attached the same way under
238
+ ``metadata["origin_report_sheets"]``.
239
+
240
+ A sheet made *entirely* of report-sheet columns (e.g. a fit's "FitNL1"
241
+ report, which typically has zero plausible-numeric columns of its own —
242
+ ``cols`` empty) still gets its own pseudo-book: an empty-data DataStruct
243
+ carrying only the report metadata, rather than being silently omitted.
244
+ """
245
+ if not cols:
246
+ book_long = _book_long_name(book, books_meta)
247
+ meta_empty: dict[str, object] = {
248
+ "source_format": source_format,
249
+ "origin_book": book,
250
+ "origin_book_long": book_long,
251
+ "origin_books": inventory,
252
+ "origin_report_sheets": {c: rows for c, rows in (report_cols or [])},
253
+ }
254
+ return DataStruct(time=np.empty(0), values=np.empty((0, 0)), metadata=meta_empty)
255
+
256
+ base_book, _, sheet_no = book.partition("@")
257
+ col_meta = books_meta[base_book].columns if base_book in books_meta and not sheet_no else {}
258
+ maxlen = max((len(v) for _, v in cols), default=0)
259
+
260
+ # Locate the independent (X) axis among the DECODED columns. When a book's
261
+ # windows metadata *declares* an X column but that column failed every decode
262
+ # path (so it is absent from ``cols``), do NOT fall back to promoting the
263
+ # first value column to the x-axis: that silently relabels a Y measurement as
264
+ # the independent variable and drops the real X without a trace (Moke.opj
265
+ # Book3; the hc2convert.opj "A6221Lockin*" TDI family — 34 of 74 books). Use
266
+ # a synthetic row-index axis instead, keep every decoded column as a value
267
+ # series, and flag the loss in metadata (``x_column_recovered``).
268
+ decoded_x = next(
269
+ (j for j, (c, _) in enumerate(cols) if (m := col_meta.get(c)) and m.designation == "X"),
270
+ None,
271
+ )
272
+ declared_x = next((c for c, m in col_meta.items() if m.designation == "X"), None)
273
+ x_unrecovered = decoded_x is None and declared_x is not None
274
+
275
+ if x_unrecovered:
276
+ ordered = list(cols)
277
+ else:
278
+ x_idx = decoded_x if decoded_x is not None else 0
279
+ ordered = [cols[x_idx]] + [cv for j, cv in enumerate(cols) if j != x_idx]
280
+
281
+ def _pad(a: NDArray[np.float64]) -> NDArray[np.float64]:
282
+ return a if len(a) == maxlen else np.concatenate([a, np.full(maxlen - len(a), np.nan)])
283
+
284
+ padded = [_pad(v) for _, v in ordered]
285
+ if x_unrecovered:
286
+ # The designated X column is unrecoverable → synthetic 0..N-1 row index
287
+ # as `.time`; every decoded column stays a value series (none consumed).
288
+ time = np.arange(maxlen, dtype=np.float64)
289
+ values = np.column_stack(padded) if padded else np.empty((maxlen, 0))
290
+ value_cols = [c for c, _ in ordered]
291
+ x_meta = None
292
+ else:
293
+ time = padded[0] if padded else np.empty(0)
294
+ values = np.column_stack(padded[1:]) if len(padded) > 1 else np.empty((maxlen, 0))
295
+ value_cols = [c for c, _ in ordered[1:]]
296
+ x_meta = col_meta.get(ordered[0][0]) if ordered else None
297
+ meta = {
298
+ "source_format": source_format,
299
+ "origin_book": book,
300
+ "origin_book_long": _book_long_name(book, books_meta),
301
+ "origin_books": inventory,
302
+ "x_column_name": "" if x_unrecovered else (ordered[0][0] if ordered else "A"),
303
+ "x_column_long": (
304
+ "Row" if x_unrecovered else (_label_for(ordered[0][0], x_meta) if ordered else "")
305
+ ),
306
+ # `x_unit` is the Origin-subsystem key (writer/COM read it); also emit
307
+ # the canonical `x_column_unit` every other parser uses so the plot +
308
+ # .ogs export layers (which read `x_column_unit`) show the x-axis unit.
309
+ # Both translated (rich-text escapes — see module docstring): a Unit
310
+ # row is exactly as free-form as a Long Name.
311
+ "x_unit": clean_richtext(x_meta.unit) if x_meta is not None else "",
312
+ "x_column_unit": clean_richtext(x_meta.unit) if x_meta is not None else "",
313
+ # False when `.time` is a synthetic row index substituted because the
314
+ # designated X column could not be decoded (its long name, when known,
315
+ # is in ``x_column_unrecovered``); True when `.time` is the real X.
316
+ "x_column_recovered": not x_unrecovered,
317
+ # Origin short names of the value columns, in channel order — lets a
318
+ # figure's curve->column binding (opju_curves) map onto `.values`.
319
+ "origin_column_names": value_cols,
320
+ "column_designations": {c: m.designation for c, m in col_meta.items()},
321
+ "column_comments": {
322
+ c: clean_richtext(m.comment) for c, m in col_meta.items() if m.comment
323
+ },
324
+ "origin_text_columns": {c: rows for c, rows in (text_cols or [])},
325
+ "origin_report_sheets": {c: rows for c, rows in (report_cols or [])},
326
+ }
327
+ if x_unrecovered and declared_x is not None:
328
+ # The declared X's long name, so the Inspector can say *which* column
329
+ # was lost (e.g. "Temperature") rather than just that the axis is a row
330
+ # index.
331
+ meta["x_column_unrecovered"] = _label_for(declared_x, col_meta.get(declared_x))
332
+ return DataStruct(
333
+ time=time,
334
+ values=values,
335
+ labels=tuple(_label_for(c, col_meta.get(c)) for c in value_cols),
336
+ units=tuple(
337
+ clean_richtext(col_meta[c].unit) if c in col_meta else "" for c in value_cols
338
+ ),
339
+ metadata=meta,
340
+ )
341
+
342
+
343
+ _ParseResult = tuple[
344
+ OrderedDict[str, Columns],
345
+ OrderedDict[str, TextColumns],
346
+ OrderedDict[str, TextColumns],
347
+ dict[str, BookMeta],
348
+ list[dict[str, object]],
349
+ ]
350
+
351
+
352
+ def _parse(path: Path, *, raw: bytes | None = None) -> _ParseResult:
353
+ """Decode the project once: numeric/text/report column groups + window
354
+ metadata. ``raw`` lets a caller that already has the file's bytes (e.g.
355
+ :func:`parse_opj`'s callers in ``origin_project/__init__.py``) skip a
356
+ second disk read; ``None`` (the default) reads ``path`` itself, unchanged
357
+ from before."""
358
+ b = path.read_bytes() if raw is None else raw
359
+ if not b.startswith(b"CPYA"):
360
+ raise fallback(path, f"'{path.name}' does not look like a CPYA .opj (bad header).")
361
+ columns, text_columns, report_columns = _columns(b)
362
+ if not columns:
363
+ raise fallback(path, f"no worksheet columns could be decoded from '{path.name}'.")
364
+ books = _group(columns)
365
+ text_books = _group_named(text_columns)
366
+ report_books = _group_named(report_columns)
367
+ books_meta = window_metadata(b)
368
+ return books, text_books, report_books, books_meta, _inventory(books, books_meta, report_books)
369
+
370
+
371
+ def _primary_key(books: OrderedDict[str, Columns]) -> str:
372
+ """The book :func:`read_opj`/:func:`build_opj_primary` treat as *the*
373
+ primary dataset: the largest workbook by total (pre-padding) column
374
+ length, never a sheet pseudo-book (``Book@N``) unless every book is one.
375
+
376
+ Extracted from ``read_opj`` so a single :func:`_parse`/:func:`parse_opj`
377
+ result can serve both the primary DataStruct and the full per-book list
378
+ (:func:`build_opj_books`) without parsing the project twice — the routes
379
+ import path (``read_origin_project_all``) needs both.
380
+ """
381
+ primary_pool = [k for k in books if "@" not in k] or list(books)
382
+ return max(primary_pool, key=lambda k: sum(len(v) for _, v in books[k]))
383
+
384
+
385
+ def parse_opj(path: Path, *, raw: bytes | None = None) -> _ParseResult:
386
+ """Public single-parse entry point: the same intermediate :func:`_parse`
387
+ produces, exposed so a caller that needs BOTH the primary book
388
+ (:func:`build_opj_primary`) and every book (:func:`build_opj_books`) —
389
+ the routes import path — can parse the project once and build each
390
+ independently, instead of each of :func:`read_opj`/:func:`read_opj_books`
391
+ parsing it on their own."""
392
+ return _parse(path, raw=raw)
393
+
394
+
395
+ def build_opj_primary(parsed: _ParseResult) -> DataStruct:
396
+ """Build the primary (largest) book's DataStruct from an already-
397
+ :func:`parse_opj`'d project — the same selection :func:`read_opj`
398
+ performs, factored out so it can share a parse with
399
+ :func:`build_opj_books`."""
400
+ books, text_books, report_books, books_meta, inventory = parsed
401
+ primary = _primary_key(books)
402
+ return _build_book(
403
+ primary,
404
+ books[primary],
405
+ books_meta,
406
+ inventory,
407
+ text_cols=text_books.get(primary),
408
+ report_cols=report_books.get(primary),
409
+ )
410
+
411
+
412
+ def build_opj_books(parsed: _ParseResult) -> list[DataStruct]:
413
+ """Build every book's DataStruct from an already-:func:`parse_opj`'d
414
+ project — identical construction (and order) to :func:`read_opj_books`,
415
+ factored out so it can share a parse with :func:`build_opj_primary`.
416
+
417
+ A sheet made entirely of report-sheet columns (plan item 4 — e.g. a fit's
418
+ "FitNL1" report) has no plausible-numeric columns at all, so it never
419
+ appears as a key in ``books``; the union with ``report_books`` (and
420
+ ``text_books``, for the analogous inline-text case) below still surfaces
421
+ it as its own pseudo-book rather than dropping the whole sheet.
422
+ """
423
+ books, text_books, report_books, books_meta, inventory = parsed
424
+ keys = list(books)
425
+ keys += [k for k in text_books if k not in books and k not in keys]
426
+ keys += [k for k in report_books if k not in books and k not in keys]
427
+ return [
428
+ _build_book(
429
+ k,
430
+ books.get(k, []),
431
+ books_meta,
432
+ inventory,
433
+ text_cols=text_books.get(k),
434
+ report_cols=report_books.get(k),
435
+ )
436
+ for k in keys
437
+ if books.get(k) or text_books.get(k) or report_books.get(k)
438
+ ]
439
+
440
+
441
+ def read_opj(path: Path) -> DataStruct:
442
+ """The single-DataStruct contract: the largest workbook (inventory in metadata).
443
+
444
+ Extra-sheet pseudo-books (``Book@N`` — often fit tables/curves) never win
445
+ the primary slot over measured sheet-1 data, however large they are.
446
+ """
447
+ return build_opj_primary(_parse(path))
448
+
449
+
450
+ def read_opj_books(path: Path) -> list[DataStruct]:
451
+ """Every workbook in the project as its own DataStruct (plan item 3).
452
+
453
+ A sheet made entirely of report-sheet columns (plan item 4 — e.g. a fit's
454
+ "FitNL1" report) has no plausible-numeric columns at all, so it never
455
+ appears as a key in ``books``; the union with ``report_books`` (and
456
+ ``text_books``, for the analogous inline-text case) below still surfaces
457
+ it as its own pseudo-book rather than dropping the whole sheet.
458
+ """
459
+ return build_opj_books(_parse(path))