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,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))
|