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,288 @@
1
+ """Positioned free-text annotation marks for Origin figures (both containers).
2
+
3
+ Origin graphs carry floating text objects ("Field applied in-plane\\r\\nT =
4
+ 1.3 K") whose text the figure decoders already recover into the flat
5
+ ``annotations: list[str]`` field. This module adds the POSITION: each text
6
+ object stores its box's **top-left corner** as two little-endian float64
7
+ layer-fractions ``(frac_a, frac_b)``, converted to data coordinates with the
8
+ layer's own axis range::
9
+
10
+ x1 = x_from + frac_a * (x_to - x_from)
11
+ y1 = y_to - frac_b * (y_to - y_from) # y measured from the TOP
12
+
13
+ Validated against a live-COM oracle (``ground_truth/<stem>/annotations.json``,
14
+ the box corner Origin itself reports as ``x1``/``y1``): all 5 captured
15
+ instances (hc2convert.opj Graph1/2/3, Hc2 data.opju Graph1/2) reproduce to
16
+ ~1e-9. The oracle graphs are all linear-scale; how Origin maps the fraction
17
+ on a LOG axis is unverified — the linear formula above is applied regardless
18
+ (it is the confirmed model; no log-axis oracle instance exists yet).
19
+
20
+ **Where the fractions live**
21
+
22
+ * ``.opj``: the text object's 133-byte header block (ASCII name at payload
23
+ offset 70, see ``figures.py``) holds ``frac_a`` at payload offset **19**
24
+ and ``frac_b`` at **27** — two consecutive LE doubles. Confirmed exact on
25
+ every oracle instance and plausibility-scanned across ~2000 ``Text*``/
26
+ ``Line*`` headers corpus-wide (all decode to sane layer fractions).
27
+ * ``.opju`` (CPYUA): a tagged field ``85 13 <frac_a:8> <frac_b:8> 80 00 …``
28
+ ending exactly **32 bytes before** the object-name header that
29
+ ``opju_figure_text._object_headers`` locates (i.e. tag at ``h-32``,
30
+ ``frac_a`` at ``h-30``, ``frac_b`` at ``h-22``, and the constant ``80 00``
31
+ field boundary right after ``frac_b`` at ``h-14``). Confirmed at that
32
+ exact distance for every positioned ``Text*`` header in the 5-file real
33
+ corpus, and exact against both oracle instances.
34
+
35
+ **Known negatives (omitted, never guessed):** RockingCurve.opju's composite
36
+ panel-label Texts use an ``86 13`` tag and UnpolPlots.opju one ``85 1f``
37
+ variant — same neighbourhood, different framing, no oracle coverage — so
38
+ those objects keep their text in ``annotations`` but ship no position. The
39
+ oracle's ``attach`` mode (all captured instances are 0 = layer-scale) is not
40
+ decoded; a non-zero attach presumably reinterprets the fractions.
41
+
42
+ Pure library: bytes in → dicts out. No fastapi/pydantic/routes imports.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import math
48
+ import re
49
+ import struct
50
+ from typing import Any
51
+
52
+ from quantized.io.origin_project.origin_richtext import clean_richtext
53
+
54
+ __all__ = [
55
+ "build_mark",
56
+ "frac_to_data",
57
+ "opj_object_box",
58
+ "opj_object_fractions",
59
+ "opj_text_fractions",
60
+ "opju_text_fractions",
61
+ "page_point_fractions",
62
+ ]
63
+
64
+ # ── shared annotation-text cleanup (moved here from figures.py so this module
65
+ # stays a leaf both containers' figure decoders can import) ──────────────────
66
+
67
+ _AUTO_TITLE = re.compile(r"^%\(\?[XY]\)")
68
+
69
+ # Internal Origin storage/style markers that leak into raw text scans — see
70
+ # figures.py's module docstring ("Axis-title / legend-label routing").
71
+ # ``SYSTEM`` and ``SRCINFO`` are matched as UPPERCASE whole words only (the
72
+ # internal tokens are always all-caps): the old case-insensitive substring
73
+ # match silently dropped legitimate annotations like "System pressure = 3
74
+ # bar" (2026-07-06 genericity audit #12). The remaining tokens are unusual
75
+ # enough that a case-insensitive substring can't hit real prose.
76
+ _INTERNAL_ANN_RE = re.compile(
77
+ r"\bSYSTEM\b|\bSRCINFO\b"
78
+ r"|(?i:STYLEHOLDER|OriginStorage|AxesDlgSettings|UseSameOptions)"
79
+ )
80
+ _SHEETREF_ANN_RE = re.compile(r"^Sheet\d+<?$") # internal sheet source reference, not a title
81
+
82
+
83
+ def _clean_annotations(titles: list[str]) -> list[str]:
84
+ """Drop internal Origin storage/style markers and bare sheet references
85
+ from recovered floating-text lines. Shared by BOTH containers' figure
86
+ decoders (``figures.py`` re-exports it; ``figures_opju.py`` additionally
87
+ truncates at its embedded PNG thumbnail first) and by ``build_mark``
88
+ below, so the flat ``annotations`` list and the positioned marks can
89
+ never disagree on what counts as user text."""
90
+ out: list[str] = []
91
+ for t in titles:
92
+ s = t.strip()
93
+ if not s or _INTERNAL_ANN_RE.search(s) or _SHEETREF_ANN_RE.match(s):
94
+ continue
95
+ out.append(s)
96
+ return out
97
+
98
+
99
+ # ── position decode ──────────────────────────────────────────────────────────
100
+
101
+ # .opj object BOX: four u16 LE at header payload offsets 3-10 = the object's
102
+ # bounding box (x1, y1, x2, y2) in PAGE units, post-rotation (solved
103
+ # 2026-07-06 against the 111-instance annotations.json oracle: XRD's rotated
104
+ # peak labels store their pre-rotation anchor in the FRACTION pair, so a
105
+ # fraction-only model is off by a font-proportional vector — the page box is
106
+ # exact for every object kind). Mapped to data coordinates through the layer
107
+ # FRAME quad (figures.py, layer-block offsets 113-119, same page units).
108
+ _OPJ_BOX_OFFSET = 3
109
+
110
+ # .opj: fraction doubles inside the 133-byte object header payload — the
111
+ # object's pre-rotation ANCHOR. Kept as the fallback when the box/frame quad
112
+ # is missing or implausible (older 4.3227 layer blocks); exact for
113
+ # horizontal text, offset for rotated labels.
114
+ _OPJ_FRACA_OFFSET = 19
115
+ _OPJ_FRACB_OFFSET = 27
116
+
117
+ # .opju: the position field's tag pair and its fixed distance before the
118
+ # object-name header (see module docstring). The `80 00` right after frac_b
119
+ # is the next field's boundary — constant across every corpus instance, kept
120
+ # as a cheap structural check against false tag matches. Three tag flavours
121
+ # carry the same <fracA:8><fracB:8> payload, each at its own fixed distance:
122
+ # `85 13` at header-32 (Text* objects, the original 2026-07-05 decode),
123
+ # `86 13` at header-32 (panel-label Text objects — verified 2026-07-06 when
124
+ # the expanded annotations COM oracle finally covered RockingCurve's
125
+ # instances under the identical fraction model; previously a known-negative),
126
+ # and `85 1f` at header-33 (Legend objects and some panel Texts — resolved
127
+ # 2026-07-06 via the legend-position oracle).
128
+ _OPJU_POS_TAG_LOOKBACK = {b"\x85\x13": 32, b"\x86\x13": 32, b"\x85\x1f": 33}
129
+ _OPJU_POS_SENTINEL = b"\x80\x00"
130
+
131
+ # Corpus-wide the fractions stay within ~[-0.2, 4] (text can sit outside the
132
+ # layer frame); a misread double is astronomically large or non-finite.
133
+ _FRAC_BOUND = 50.0
134
+
135
+
136
+ def _plausible(v: float) -> bool:
137
+ return math.isfinite(v) and abs(v) <= _FRAC_BOUND
138
+
139
+
140
+ def _interp(frac: float, lo: float, hi: float, log: bool) -> float:
141
+ """Axis-fraction -> data value, linear or log10-space.
142
+
143
+ The log mapping was CONFIRMED 2026-07-06 against the legend-position COM
144
+ oracle on two log-Y graphs (XRD Graph2: linear read 9.73e4 vs oracle
145
+ 7.291e4; log10 read 7.291e4 exact — same for Si-YIG-Py): on a log axis
146
+ Origin stores the fraction of the DECADE span. Non-positive bounds can't
147
+ be a real log axis — degrade to linear rather than crash."""
148
+ if log and lo > 0 and hi > 0:
149
+ return float(10 ** (math.log10(lo) + frac * (math.log10(hi) - math.log10(lo))))
150
+ return lo + frac * (hi - lo)
151
+
152
+
153
+ def frac_to_data(
154
+ frac_a: float,
155
+ frac_b: float,
156
+ x_from: float,
157
+ x_to: float,
158
+ y_from: float,
159
+ y_to: float,
160
+ x_log: bool = False,
161
+ y_log: bool = False,
162
+ ) -> tuple[float, float]:
163
+ """The confirmed fraction→data model (module docstring): the box top-left
164
+ corner in data coordinates. ``frac_b`` measures down from the axis TOP;
165
+ log axes interpolate in log10 space (see ``_interp``)."""
166
+ return (
167
+ _interp(frac_a, x_from, x_to, x_log),
168
+ _interp(frac_b, y_to, y_from, y_log), # from the TOP: hi -> lo
169
+ )
170
+
171
+
172
+ def opj_object_box(payload: bytes) -> tuple[int, int, int, int] | None:
173
+ """The object's page-unit bounding box ``(x1, y1, x2, y2)`` from a
174
+ ``.opj`` 133-byte object header, or ``None`` when the quad is not a
175
+ plausible box (degenerate/reversed — dropped, never guessed)."""
176
+ if len(payload) < _OPJ_BOX_OFFSET + 8:
177
+ return None
178
+ x1, y1, x2, y2 = struct.unpack_from("<4H", payload, _OPJ_BOX_OFFSET)
179
+ if not (x1 < x2 and y1 < y2 and x2 - x1 < 60_000 and y2 - y1 < 60_000):
180
+ return None
181
+ return int(x1), int(y1), int(x2), int(y2)
182
+
183
+
184
+ def page_point_fractions(
185
+ x: float, y: float, frame: tuple[int, int, int, int]
186
+ ) -> tuple[float, float] | None:
187
+ """A page-unit point as layer-frame fractions (x from left, y from the
188
+ frame TOP — the same convention :func:`frac_to_data` takes), or ``None``
189
+ on a degenerate frame."""
190
+ fx1, fy1, fx2, fy2 = frame
191
+ if fx2 <= fx1 or fy2 <= fy1:
192
+ return None
193
+ return (x - fx1) / (fx2 - fx1), (y - fy1) / (fy2 - fy1)
194
+
195
+
196
+ def opj_text_fractions(payload: bytes) -> tuple[float, float] | None:
197
+ """``(frac_a, frac_b)`` from a ``.opj`` text object's 133-byte header
198
+ payload, or ``None`` when the doubles are missing/implausible (the text
199
+ then still ships in ``annotations``, just without a position)."""
200
+ if len(payload) < _OPJ_FRACB_OFFSET + 8:
201
+ return None
202
+ frac_a = float(struct.unpack_from("<d", payload, _OPJ_FRACA_OFFSET)[0])
203
+ frac_b = float(struct.unpack_from("<d", payload, _OPJ_FRACB_OFFSET)[0])
204
+ if not (_plausible(frac_a) and _plausible(frac_b)):
205
+ return None
206
+ return frac_a, frac_b
207
+
208
+
209
+ def opj_object_fractions(
210
+ payload: bytes, frame: tuple[int, int, int, int] | None
211
+ ) -> tuple[float, float] | None:
212
+ """Anchor fractions for one ``.opj`` object header (solved 2026-07-06 vs
213
+ the 111-instance annotations oracle; hoisted out of ``figures.py``
214
+ 2026-07-11, unchanged). Two independent position fields exist: the
215
+ FRACTION pair (the text anchor — exact for every unrotated object,
216
+ bordered or not) and the page-unit bounding BOX (post-rotation
217
+ geometry). For a 90-degree-rotated label the fraction pair stores only
218
+ the PRE-rotation anchor, which lands at exactly ``(+d, -d)`` page units
219
+ from the box's bottom-left — the signature of rotation about the first
220
+ character's baseline point (d = the font ascent; measured
221
+ equal-magnitude on every rotated corpus instance, XRD's 46 peak
222
+ labels). A bordered horizontal label's anchor sits at
223
+ ``(+inset, -boxheight+inset)`` instead — never the equal-magnitude
224
+ diagonal — so the test is geometric, not a corpus threshold. Rotated:
225
+ anchor at the box bottom-left (the text-start corner Origin itself
226
+ reports); everything else: the fraction pair (the pre-2026-07-06
227
+ behaviour, still exact for those)."""
228
+ fracs = opj_text_fractions(payload)
229
+ if frame is None or fracs is None:
230
+ return fracs
231
+ box = opj_object_box(payload)
232
+ if box is None:
233
+ return fracs
234
+ fl, ft, fr, fb = frame
235
+ anchor_x = fl + fracs[0] * (fr - fl)
236
+ anchor_y = ft + fracs[1] * (fb - ft)
237
+ dx = anchor_x - box[0]
238
+ dy = anchor_y - box[3]
239
+ if dx > 0 > dy and abs(dx + dy) <= 0.25 * dx:
240
+ return page_point_fractions(box[0], box[3], frame)
241
+ return fracs
242
+
243
+
244
+ def opju_text_fractions(b: bytes, header_pos: int) -> tuple[float, float] | None:
245
+ """``(frac_a, frac_b)`` for the ``.opju`` text object whose name header
246
+ starts at ``header_pos``, or ``None`` when the fixed-distance ``85 13``
247
+ field isn't there (the known ``86 13``/``85 1f`` variants, or no position
248
+ at all — omitted, never guessed; see the module docstring)."""
249
+ for tag, lookback in _OPJU_POS_TAG_LOOKBACK.items():
250
+ q = header_pos - lookback
251
+ if q < 0 or b[q : q + 2] != tag:
252
+ continue
253
+ if b[q + 18 : q + 20] != _OPJU_POS_SENTINEL:
254
+ continue
255
+ frac_a = float(struct.unpack_from("<d", b, q + 2)[0])
256
+ frac_b = float(struct.unpack_from("<d", b, q + 10)[0])
257
+ if _plausible(frac_a) and _plausible(frac_b):
258
+ return frac_a, frac_b
259
+ return None
260
+
261
+
262
+ def build_mark(
263
+ fracs: tuple[float, float] | None,
264
+ lines: list[str],
265
+ x_from: float,
266
+ x_to: float,
267
+ y_from: float,
268
+ y_to: float,
269
+ x_log: bool = False,
270
+ y_log: bool = False,
271
+ ) -> dict[str, Any] | None:
272
+ """One positioned annotation mark ``{"text", "x", "y"}`` — or ``None``
273
+ when the position never decoded or no user text survives cleanup.
274
+
275
+ ``lines`` are the object's raw recovered text runs, one per line; they go
276
+ through the exact same cleanup pipeline as the flat ``annotations`` field
277
+ (auto-title/legend filtering, internal-noise drop, rich-text decode) and
278
+ re-join with ``"\\n"`` — one mark per text OBJECT, multi-line preserved.
279
+ """
280
+ if fracs is None:
281
+ return None
282
+ kept = [t for t in lines if not _AUTO_TITLE.match(t) and "\\l(" not in t]
283
+ cleaned = [clean_richtext(t) for t in _clean_annotations(kept)]
284
+ text = "\n".join(s for s in cleaned if s.strip())
285
+ if not text:
286
+ return None
287
+ x, y = frac_to_data(fracs[0], fracs[1], x_from, x_to, y_from, y_to, x_log, y_log)
288
+ return {"text": text, "x": x, "y": y}
@@ -0,0 +1,262 @@
1
+ """CPY container primitives shared by the Origin ``.opj``/``.opju`` readers.
2
+
3
+ The ``.opj`` (CPYA) container is a stream of
4
+ ``<uint32 size LE><0x0A><payload><0x0A>`` blocks (``size==0`` = 5-byte spacer);
5
+ worksheet columns, window definitions, and graph windows all live in that one
6
+ walkable stream (see ``docs/origin_re/``). Format facts derived clean-room from
7
+ local specimens — no GPL code consulted (``docs/origin_project_format.md``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import struct
14
+ from collections.abc import Iterator
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ from numpy.typing import NDArray
19
+
20
+ __all__ = [
21
+ "ORIGIN_MISSING",
22
+ "NAME_RE",
23
+ "OriginProjectError",
24
+ "decode_doubles",
25
+ "decode_inline_text",
26
+ "decode_report_strings",
27
+ "fallback",
28
+ "plausible_column",
29
+ "salvage_column",
30
+ "walk_blocks",
31
+ ]
32
+
33
+ _VIEWER = "the free Origin Viewer (https://www.originlab.com/viewer/)"
34
+
35
+ # Origin's "missing value" sentinel for an empty numeric cell (-1.23456789e-300);
36
+ # stored in the data, not flagged by the mask, so we map it to NaN on decode.
37
+ ORIGIN_MISSING = struct.unpack("<d", b"\x0e\x2c\x13\x1c\xfe\x74\xaa\x81")[0]
38
+
39
+ # A dataset name inside a column-header block: "<book>_<col>[@sheet]\0"
40
+ # (the @N suffix marks columns of sheet N>1 in multi-sheet workbooks).
41
+ # Bounds are deliberately WIDER than anything corpus-observed (book 62,
42
+ # column 16, sheet 999): the old {0,40}/{1,4}/{1,2} caps were corpus maxima,
43
+ # and a user-renamed 5+-char column short name (e.g. "Book1_Field") silently
44
+ # lost its whole column when the regex refused the name (2026-07-06
45
+ # genericity audit). The NUL terminator + the paired data-block structure in
46
+ # ``opj.py`` remain the actual validity gates.
47
+ NAME_RE = re.compile(rb"([A-Za-z][\w ]{0,62}_[A-Za-z0-9]{1,16}(?:@\d{1,3})?)\x00")
48
+
49
+
50
+ class OriginProjectError(ValueError):
51
+ """An Origin project can't (yet) be read directly; the message explains why
52
+ and how to recover the data (subclasses ValueError so the import route maps
53
+ it to a 422 with the message intact)."""
54
+
55
+
56
+ def fallback(path: Path, detail: str) -> OriginProjectError:
57
+ return OriginProjectError(
58
+ f"{detail} For now, open '{path.name}' in {_VIEWER} and export the "
59
+ f"worksheet(s) to CSV or ASCII, then import that file here."
60
+ )
61
+
62
+
63
+ def walk_blocks(b: bytes) -> Iterator[tuple[int, bytes]]:
64
+ """Yield ``(size, payload)`` for each CPY block after the header line.
65
+
66
+ A ``size==0`` block is a section spacer yielded as ``(0, b"")``. Iteration
67
+ stops at the first framing break — the start of the trailing global-storage
68
+ / analysis-log area (which is framed differently).
69
+ """
70
+ nl = b.find(b"\n")
71
+ if nl < 0:
72
+ return
73
+ pos = nl + 1
74
+ n = len(b)
75
+ while pos + 5 <= n:
76
+ size = int.from_bytes(b[pos : pos + 4], "little")
77
+ if b[pos + 4] != 0x0A: # expected delimiter missing → framing ended
78
+ return
79
+ pos += 5
80
+ if size == 0:
81
+ yield 0, b""
82
+ continue
83
+ if pos + size >= n or b[pos + size] != 0x0A:
84
+ return
85
+ yield size, b[pos : pos + size]
86
+ pos += size + 1
87
+
88
+
89
+ def decode_doubles(data: bytes) -> NDArray[np.float64]:
90
+ """Decode a data block of 10-byte ``<uint16 mask><float64>`` records → values.
91
+
92
+ Byte-offset explicit (slice cols 2:10 of each record and view as float64) so
93
+ it is independent of numpy structured-dtype alignment.
94
+ """
95
+ n = len(data) // 10
96
+ rows = np.frombuffer(data, dtype=np.uint8, count=n * 10).reshape(n, 10)
97
+ vals = np.ascontiguousarray(rows[:, 2:]).view("<f8").reshape(n).copy()
98
+ vals[vals == ORIGIN_MISSING] = np.nan # empty cells → NaN, not a bogus -1e-300
99
+ return vals
100
+
101
+
102
+ _INLINE_TEXT_PRINTABLE = frozenset(range(0x20, 0x7F))
103
+
104
+
105
+ def decode_inline_text(data: bytes) -> list[str] | None:
106
+ """Decode a data block as one short inline-text string per 10-byte record,
107
+ or ``None`` if it doesn't fit that shape (plan item 4 — the non-double
108
+ "text" column case).
109
+
110
+ A double column's record is ``<u16 mask><f8 value>``; a **Text & Numeric**
111
+ column reuses the exact same 10-byte record, but the 8-byte value area
112
+ holds a NUL-terminated ASCII/latin-1 string (up to 7 chars) followed by a
113
+ single ``0x00``/``0x01`` tag byte and zero padding out to 8 bytes, instead
114
+ of a raw float64. Pinned against ``hc2convert.opj``: every one of its 58
115
+ dropped "text" columns is exactly this shape, ``prefix="NaN"`` (Origin's
116
+ literal text sentinel for a fit that produced no critical-field value),
117
+ tag byte at value-offset 3 — 112,887 matching records, zero
118
+ counter-examples in a 6-file real-corpus scan. No header type-field byte
119
+ was found that reliably discriminates this from a double column (every
120
+ offset in the 147-byte column-storage header matches between the two);
121
+ this is a content-shape detector, same spirit as ``plausible_column`` and
122
+ the existing ``_looks_textual`` gate.
123
+
124
+ A record with no NUL in its 8-byte value area (a string longer than 7
125
+ chars, overflowing into the next record — seen in Origin's FitLinear/
126
+ NLFit auto-generated "Notes"/"Summary" report-sheet columns) makes the
127
+ WHOLE column unsafe to decode this way: return ``None`` rather than emit
128
+ misaligned rows. That family gets a second, dedicated try via
129
+ :func:`decode_report_strings` (a completely different, wider record
130
+ shape) before being dropped for good.
131
+ """
132
+ if not data or len(data) % 10 != 0:
133
+ return None
134
+ n = len(data) // 10
135
+ rows: list[str] = []
136
+ for k in range(n):
137
+ value = data[10 * k + 2 : 10 * k + 10]
138
+ nul = value.find(b"\x00")
139
+ if nul < 0:
140
+ return None
141
+ prefix = value[:nul]
142
+ if prefix and not all(c in _INLINE_TEXT_PRINTABLE for c in prefix):
143
+ return None
144
+ tail = value[nul + 1 :]
145
+ if tail and not (tail[0] in (0x00, 0x01) and all(c == 0 for c in tail[1:])):
146
+ return None
147
+ rows.append(prefix.decode("latin1"))
148
+ return rows
149
+
150
+
151
+ _REPORT_MASK = b"\x01\x00"
152
+
153
+
154
+ def decode_report_strings(data: bytes) -> list[str] | None:
155
+ """Decode a data block as Origin's auto-generated *report-sheet* column
156
+ shape — the ``decode_inline_text`` overflow residue of plan item 4:
157
+ FitLinear/NLFit "Notes"/"Summary"/"Parameters"/"RegStats"/"ANOVA" columns
158
+ whose cells hold a variable-length ``cell://<Section>.<Row>.<Field>``
159
+ reference string (e.g. ``"cell://Parameters.Slope.Value"``) too long for
160
+ ``decode_inline_text``'s fixed 8-byte value area.
161
+
162
+ This is a genuinely **different, wider** fixed-per-column record, not a
163
+ variant of the 10-byte double record: ``<u16 mask=0x0001><NUL-terminated
164
+ ASCII/latin-1 string><zero padding>``. The *width* is constant within one
165
+ column (Origin reserves it uniformly, sized to that column's longest
166
+ cell) but **varies column to column** — unlike a double column's constant
167
+ 10-byte stride. The ``0x0001`` mask (vs. plain data's/inline-text's
168
+ ``0x0000``) is the tell that discriminates this shape outright; the width
169
+ itself is recovered from the block's own byte content (the spacing
170
+ between consecutive mask markers), then the whole block is re-validated
171
+ at that width before being accepted — a coincidental short match never
172
+ survives more than one row's validation.
173
+
174
+ Pinned against ``hc2convert.opj``'s 407 previously honest-dropped
175
+ report-sheet columns (Notes/Input/Parameters/RegStats/Summary/ANOVA
176
+ families, widths from 21 bytes up to 45+ depending on the longest
177
+ ``cell://`` string in that column): **407/407 decode cleanly, 0
178
+ validation failures, 0 collisions with the 10-byte double/inline-text
179
+ shapes** (both already ruled out a data block before this is tried).
180
+ Recovers the *reference string* naming which fit statistic a report cell
181
+ represents — not the fit's computed number itself (e.g. a Slope's actual
182
+ value); that is a separate, harder gap documented in
183
+ ``docs/origin_project_format.md`` "Non-double column values".
184
+ """
185
+ n = len(data)
186
+ if n < 3:
187
+ return None
188
+ idxs: list[int] = []
189
+ i = data.find(_REPORT_MASK)
190
+ while i >= 0:
191
+ idxs.append(i)
192
+ i = data.find(_REPORT_MASK, i + 1)
193
+ if not idxs or idxs[0] != 0:
194
+ return None # every record must open with the mask, starting at byte 0
195
+ width = n if len(idxs) == 1 else min(idxs[k + 1] - idxs[k] for k in range(len(idxs) - 1))
196
+ if width < 3 or n % width != 0:
197
+ return None
198
+ rows: list[str] = []
199
+ for k in range(n // width):
200
+ rec = data[width * k : width * k + width]
201
+ if rec[:2] != _REPORT_MASK:
202
+ return None
203
+ value = rec[2:]
204
+ nul = value.find(b"\x00")
205
+ if nul < 0:
206
+ return None
207
+ text, pad = value[:nul], value[nul + 1 :]
208
+ if any(pad) or not all(c in _INLINE_TEXT_PRINTABLE for c in text):
209
+ return None
210
+ rows.append(text.decode("latin1"))
211
+ return rows
212
+
213
+
214
+ def plausible_column(vals: NDArray[np.float64], *, allow_all_nan: bool = False) -> bool:
215
+ """Reject a decoded column whose values betray a non-double payload.
216
+
217
+ Text / integer / float32 columns (the 147-byte column header carries a
218
+ type field the readers don't decode yet — plan item 4) reinterpret as
219
+ float64 garbage: subnormals (|v| ≲ 1e-300) and absurd magnitudes
220
+ (|v| ≳ 1e290) that real instrument data never contains. Dropping the whole
221
+ column on the first such value is honest-absent; silent garbage is worse
222
+ than a gap. ``allow_all_nan`` keeps genuinely empty columns (the ``.opj``
223
+ reader pads ragged books with them).
224
+ """
225
+ finite = vals[np.isfinite(vals)]
226
+ if finite.size == 0:
227
+ return allow_all_nan
228
+ mag = np.abs(finite)
229
+ wrecked = ((mag < 1e-290) & (finite != 0.0)) | (mag > 1e290)
230
+ return not bool(np.any(wrecked))
231
+
232
+
233
+ # A real double column may carry a couple of stray junk cells (denormals in the
234
+ # missing-sentinel magnitude band) without being a non-double payload — XRD.opj
235
+ # Book6_A (1543 2-theta values + 4 denormals) is the measured case. True garbage
236
+ # (text/int reinterpretations) runs >=5% wrecked across the corpus; the gap
237
+ # between <=0.26% (real) and >=0.83% (the report-sheet family, which the
238
+ # text/report decoders claim FIRST) motivates the tight bound below.
239
+ _SALVAGE_MAX_FRAC = 0.005
240
+ _SALVAGE_MAX_CELLS = 4
241
+
242
+
243
+ def salvage_column(vals: NDArray[np.float64]) -> NDArray[np.float64] | None:
244
+ """``vals`` with stray wrecked cells masked to NaN, or None if not salvageable.
245
+
246
+ Last-resort classification (after the text and report decoders have
247
+ passed): accepts only columns whose wrecked cells are both rare
248
+ (<= 0.5% of finite values) and few (<= 4 absolute) — measured to separate
249
+ real columns with junk cells from every true-garbage column in the corpus.
250
+ """
251
+ finite_mask = np.isfinite(vals)
252
+ finite = vals[finite_mask]
253
+ if finite.size == 0:
254
+ return None
255
+ mag = np.abs(vals)
256
+ wrecked = np.isfinite(vals) & (((mag < 1e-290) & (vals != 0.0)) | (mag > 1e290))
257
+ n = int(wrecked.sum())
258
+ if n == 0 or n > _SALVAGE_MAX_CELLS or n / finite.size > _SALVAGE_MAX_FRAC:
259
+ return None
260
+ out = vals.copy()
261
+ out[wrecked] = np.nan
262
+ return out