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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- 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
|