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
quantized/io/opus.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Bruker OPUS FTIR/NIR/Raman binary spectrum parser (``.opus``).
|
|
2
|
+
|
|
3
|
+
**NOT a MATLAB port.** ``quantized_matlab`` never implemented
|
|
4
|
+
``importOpus.m`` — the roadmap parks it "Paused (awaiting example files)"
|
|
5
|
+
(``quantized_matlab/plans/archive/parser-roadmap.md`` item 3;
|
|
6
|
+
``PORT_CHECKLIST.md`` line 46) and no source or test exists to freeze golden
|
|
7
|
+
values from. This is an independent implementation against Bruker's
|
|
8
|
+
block-directory OPUS layout. The block-type/channel-type dispatch table
|
|
9
|
+
below is the format's own fixed vocabulary (facts about the binary
|
|
10
|
+
protocol, not one implementation's expression of it) — cross-checked
|
|
11
|
+
against the open-source readers ``qedsoftware/brukeropusreader``
|
|
12
|
+
(LGPL-3.0) and ``Bjorn-G-S/UiO-IR-Reader`` for consistency; no code from
|
|
13
|
+
either was copied (both are GPL-family and incompatible with this
|
|
14
|
+
project's Apache-2.0 runtime regardless).
|
|
15
|
+
|
|
16
|
+
No real ``.opus`` sample file was available to validate against (unlike
|
|
17
|
+
``spc.py``, which was checked against four real instrument files) — this
|
|
18
|
+
parser is exercised with synthetic fixtures built directly from the layout
|
|
19
|
+
below. That is an honest gap, not a fabricated golden result.
|
|
20
|
+
|
|
21
|
+
Binary layout
|
|
22
|
+
-------------
|
|
23
|
+
Bytes 0-23 file header (magic/version bytes; not decoded here)
|
|
24
|
+
Bytes 24-503 up to 40 directory entries, 12 bytes each:
|
|
25
|
+
byte 0 data_type (block category)
|
|
26
|
+
byte 1 channel_type (sub-category: which channel/plane)
|
|
27
|
+
byte 2 text_type (further sub-category, text blocks only)
|
|
28
|
+
byte 3 reserved
|
|
29
|
+
bytes 4-7 chunk_size (uint32 LE, in 4-byte WORDS)
|
|
30
|
+
bytes 8-11 offset (uint32 LE, absolute byte offset of the chunk)
|
|
31
|
+
A directory entry with ``offset <= 0`` terminates the directory early.
|
|
32
|
+
|
|
33
|
+
Each chunk is one of three kinds:
|
|
34
|
+
- **text** raw Latin-1 text (history, sample form, signature, ...)
|
|
35
|
+
- **series** ``chunk_size`` little-endian float32 values (a spectrum)
|
|
36
|
+
- **param** a sequence of ``{3-byte name}{1 reserved}{type:u16}{size:u16}
|
|
37
|
+
{value: 2*size bytes}`` records terminated by name ``"END"``; value
|
|
38
|
+
type 0=int32, 1=float64, 2-4=null-terminated Latin-1 string.
|
|
39
|
+
|
|
40
|
+
The primary spectrum is chosen by priority: ``AB`` (final absorbance /
|
|
41
|
+
transmittance result) > ``ScSm`` (sample single-channel spectrum) > ``IgSm``
|
|
42
|
+
(sample interferogram) > ``PhSm`` > ``PwSm`` > any other series block found.
|
|
43
|
+
Its companion ``"<Name> Data Parameter"`` block supplies ``FXV``/``LXV``/
|
|
44
|
+
``NPT`` (the x-axis range) and ``DXU`` (the x-axis unit code).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from struct import calcsize, unpack_from
|
|
51
|
+
from struct import error as struct_error
|
|
52
|
+
from typing import Any
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
from numpy.typing import NDArray
|
|
56
|
+
|
|
57
|
+
from quantized.datastruct import DataStruct
|
|
58
|
+
|
|
59
|
+
__all__ = ["import_opus", "is_opus"]
|
|
60
|
+
|
|
61
|
+
_HEADER_LEN = 504
|
|
62
|
+
_DIR_START = 24
|
|
63
|
+
_DIR_ENTRY_SIZE = 12
|
|
64
|
+
|
|
65
|
+
# (data_type, channel_type) -> block name. channel_type=None matches any
|
|
66
|
+
# (param/text blocks that don't distinguish by channel).
|
|
67
|
+
_SERIES_BLOCKS: dict[tuple[int, int], str] = {
|
|
68
|
+
(7, 4): "ScSm", (7, 8): "IgSm", (7, 12): "PhSm", (7, 56): "PwSm",
|
|
69
|
+
(11, 4): "ScRf", (11, 8): "IgRf", (11, 12): "PhRf", (11, 56): "PwRf",
|
|
70
|
+
}
|
|
71
|
+
_PARAM_CHANNEL_BLOCKS: dict[tuple[int, int], str] = {
|
|
72
|
+
(23, 4): "ScSm Data Parameter", (23, 8): "IgSm Data Parameter",
|
|
73
|
+
(23, 12): "PhSm Data Parameter", (23, 56): "PwSm Data Parameter",
|
|
74
|
+
(27, 4): "ScRf Data Parameter", (27, 8): "IgRf Data Parameter",
|
|
75
|
+
(27, 12): "PhRf Data Parameter", (27, 56): "PwRf Data Parameter",
|
|
76
|
+
}
|
|
77
|
+
_PARAM_BLOCKS: dict[int, str] = {
|
|
78
|
+
31: "AB Data Parameter", 32: "Instrument", 40: "Instrument (Rf)",
|
|
79
|
+
48: "Acquisition", 56: "Acquisition (Rf)", 64: "Fourier Transformation",
|
|
80
|
+
72: "Fourier Transformation (Rf)", 96: "Optik", 104: "Optik (Rf)",
|
|
81
|
+
160: "Sample",
|
|
82
|
+
}
|
|
83
|
+
_TEXT_BLOCKS: dict[int, str] = {
|
|
84
|
+
8: "Info Block", 104: "History", 152: "Curve Fit", 168: "Signature",
|
|
85
|
+
240: "Integration Method",
|
|
86
|
+
}
|
|
87
|
+
_AB_SERIES_TYPE = 15 # data_type for the final absorbance/transmittance block
|
|
88
|
+
|
|
89
|
+
_PRIMARY_PRIORITY = ("AB", "ScSm", "IgSm", "PhSm", "PwSm", "ScRf", "IgRf", "PhRf", "PwRf")
|
|
90
|
+
|
|
91
|
+
_DXU_UNITS = {
|
|
92
|
+
"WN": "Wavenumber (cm-1)", "MI": "Minutes", "PNT": "Data Points", "WL": "Wavelength (nm)",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _block_name(data_type: int, channel_type: int, text_type: int) -> tuple[str, str] | None:
|
|
97
|
+
"""Returns (name, kind) where kind is 'text'/'series'/'param', or None to skip."""
|
|
98
|
+
if data_type == 0:
|
|
99
|
+
return _TEXT_BLOCKS.get(text_type, "Text Information"), "text"
|
|
100
|
+
if data_type == _AB_SERIES_TYPE:
|
|
101
|
+
return "AB", "series"
|
|
102
|
+
name = _SERIES_BLOCKS.get((data_type, channel_type))
|
|
103
|
+
if name is not None:
|
|
104
|
+
return name, "series"
|
|
105
|
+
name = _PARAM_CHANNEL_BLOCKS.get((data_type, channel_type))
|
|
106
|
+
if name is not None:
|
|
107
|
+
return name, "param"
|
|
108
|
+
name = _PARAM_BLOCKS.get(data_type)
|
|
109
|
+
if name is not None:
|
|
110
|
+
return name, "param"
|
|
111
|
+
return None # unknown block type: skip (matches every known OPUS reader's behaviour)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_directory(header: bytes, file_size: int) -> list[tuple[int, int, int, int, int]]:
|
|
115
|
+
entries: list[tuple[int, int, int, int, int]] = []
|
|
116
|
+
cursor = _DIR_START
|
|
117
|
+
while cursor + _DIR_ENTRY_SIZE <= _HEADER_LEN:
|
|
118
|
+
data_type, channel_type, text_type = header[cursor], header[cursor + 1], header[cursor + 2]
|
|
119
|
+
chunk_size, offset = unpack_from("<II", header, cursor + 4)
|
|
120
|
+
if offset <= 0:
|
|
121
|
+
break
|
|
122
|
+
entries.append((data_type, channel_type, text_type, chunk_size, offset))
|
|
123
|
+
if offset + 4 * chunk_size >= file_size:
|
|
124
|
+
break
|
|
125
|
+
cursor += _DIR_ENTRY_SIZE
|
|
126
|
+
return entries
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_params(chunk: bytes) -> dict[str, Any]:
|
|
130
|
+
params: dict[str, Any] = {}
|
|
131
|
+
cursor = 0
|
|
132
|
+
while cursor + 8 <= len(chunk):
|
|
133
|
+
name = chunk[cursor : cursor + 3].decode("ascii", errors="replace")
|
|
134
|
+
if name == "END":
|
|
135
|
+
break
|
|
136
|
+
type_index = unpack_from("<H", chunk, cursor + 4)[0]
|
|
137
|
+
size_words = unpack_from("<H", chunk, cursor + 6)[0]
|
|
138
|
+
value = chunk[cursor + 8 : cursor + 8 + 2 * size_words]
|
|
139
|
+
try:
|
|
140
|
+
if type_index == 0 and len(value) >= calcsize("<i"):
|
|
141
|
+
params[name] = unpack_from("<i", value)[0]
|
|
142
|
+
elif type_index == 1 and len(value) >= calcsize("<d"):
|
|
143
|
+
params[name] = unpack_from("<d", value)[0]
|
|
144
|
+
else:
|
|
145
|
+
end = value.find(b"\x00")
|
|
146
|
+
params[name] = value[: end if end >= 0 else len(value)].decode(
|
|
147
|
+
"latin-1", errors="replace"
|
|
148
|
+
)
|
|
149
|
+
except struct_error:
|
|
150
|
+
break
|
|
151
|
+
cursor += 8 + 2 * size_words
|
|
152
|
+
return params
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_series(chunk: bytes, chunk_size: int) -> NDArray[np.float64]:
|
|
156
|
+
n = min(chunk_size, len(chunk) // 4)
|
|
157
|
+
return np.frombuffer(chunk, dtype="<f4", count=n).astype(float)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def is_opus(path: str | Path) -> bool:
|
|
161
|
+
"""Sniff a file as OPUS: a well-formed block directory with >= 1 entry."""
|
|
162
|
+
try:
|
|
163
|
+
raw = Path(path).read_bytes()
|
|
164
|
+
except OSError:
|
|
165
|
+
return False
|
|
166
|
+
if len(raw) < _HEADER_LEN:
|
|
167
|
+
return False
|
|
168
|
+
return len(_parse_directory(raw[:_HEADER_LEN], len(raw))) > 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def import_opus(filepath: str | Path) -> DataStruct:
|
|
172
|
+
"""Import a Bruker OPUS binary spectrum into a DataStruct."""
|
|
173
|
+
path = Path(filepath)
|
|
174
|
+
raw = path.read_bytes()
|
|
175
|
+
if len(raw) < _HEADER_LEN:
|
|
176
|
+
raise ValueError(f"file too small to be an OPUS file: {path.name}")
|
|
177
|
+
|
|
178
|
+
entries = _parse_directory(raw[:_HEADER_LEN], len(raw))
|
|
179
|
+
if not entries:
|
|
180
|
+
raise ValueError(f"no OPUS block directory found (not an OPUS file?): {path.name}")
|
|
181
|
+
|
|
182
|
+
blocks: dict[str, Any] = {}
|
|
183
|
+
for data_type, channel_type, text_type, chunk_size, offset in entries:
|
|
184
|
+
resolved = _block_name(data_type, channel_type, text_type)
|
|
185
|
+
if resolved is None:
|
|
186
|
+
continue
|
|
187
|
+
name, kind = resolved
|
|
188
|
+
chunk = raw[offset : offset + 4 * chunk_size]
|
|
189
|
+
if kind == "text":
|
|
190
|
+
blocks[name] = chunk.decode("latin-1", errors="replace").strip("\x00").strip()
|
|
191
|
+
elif kind == "series":
|
|
192
|
+
blocks[name] = _parse_series(chunk, chunk_size)
|
|
193
|
+
else:
|
|
194
|
+
blocks[name] = _parse_params(chunk)
|
|
195
|
+
|
|
196
|
+
primary = next(
|
|
197
|
+
(n for n in _PRIMARY_PRIORITY if isinstance(blocks.get(n), np.ndarray)), None
|
|
198
|
+
)
|
|
199
|
+
if primary is None:
|
|
200
|
+
primary = next((n for n, v in blocks.items() if isinstance(v, np.ndarray)), None)
|
|
201
|
+
if primary is None:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"no spectral data block (AB/ScSm/IgSm/...) found in OPUS file: {path.name}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
y = blocks[primary]
|
|
207
|
+
params = blocks.get(f"{primary} Data Parameter", {})
|
|
208
|
+
npt = len(y)
|
|
209
|
+
fxv = float(params.get("FXV", 0.0))
|
|
210
|
+
lxv = float(params.get("LXV", float(npt - 1)))
|
|
211
|
+
x = np.linspace(fxv, lxv, npt) if npt > 1 else np.array([fxv])
|
|
212
|
+
dxu = str(params.get("DXU", ""))
|
|
213
|
+
|
|
214
|
+
y_label = "Absorbance" if primary == "AB" else "Intensity"
|
|
215
|
+
param_blocks = {
|
|
216
|
+
name: value for name, value in blocks.items() if isinstance(value, dict)
|
|
217
|
+
}
|
|
218
|
+
text_blocks = {
|
|
219
|
+
name: value for name, value in blocks.items() if isinstance(value, str)
|
|
220
|
+
}
|
|
221
|
+
metadata: dict[str, Any] = {
|
|
222
|
+
"source": str(path),
|
|
223
|
+
"parser_name": "import_opus",
|
|
224
|
+
"primary_block": primary,
|
|
225
|
+
"x_column_name": _DXU_UNITS.get(dxu, dxu or "Wavenumber (cm-1)"),
|
|
226
|
+
"x_column_unit": dxu,
|
|
227
|
+
"blocks_found": sorted(blocks.keys()),
|
|
228
|
+
"parameters": param_blocks,
|
|
229
|
+
"text": text_blocks,
|
|
230
|
+
}
|
|
231
|
+
return DataStruct.create(x, y, labels=[y_label], units=[""], metadata=metadata)
|
quantized/io/origin.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Origin export: a paired CSV + LabTalk ``.ogs`` script. Port of MATLAB
|
|
2
|
+
``+utilities/exportOriginScript.m``.
|
|
3
|
+
|
|
4
|
+
The ``.ogs`` script, when run in OriginPro, imports the accompanying CSV, sets
|
|
5
|
+
column designations (X / Y / yErr by label), long names + units, and optionally
|
|
6
|
+
builds a graph — a maintainable alternative to writing binary ``.opju`` files.
|
|
7
|
+
|
|
8
|
+
Pure layer: ``format_origin_script`` returns ``(csv_text, ogs_text)`` (no disk
|
|
9
|
+
I/O). The ``created`` timestamp is injected by the caller so the function stays
|
|
10
|
+
deterministic (the route passes the wall-clock time; tests pass a fixed value).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from quantized.datastruct import DataStruct
|
|
23
|
+
|
|
24
|
+
__all__ = ["GraphSpec", "format_origin_project_script", "format_origin_script"]
|
|
25
|
+
|
|
26
|
+
_ERR_KEYWORDS = ("err", "dr", "std", "sigma")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class GraphSpec:
|
|
31
|
+
"""Current plot-state snapshot for the ``.ogs`` GRAPH block (item 26) —
|
|
32
|
+
a LabTalk mirror of ``calc.plotting.PlotState`` plus axis limits.
|
|
33
|
+
|
|
34
|
+
``y_keys``/``x_key``/``y2_keys`` are 0-based value-channel indices (the
|
|
35
|
+
same indexing as ``DataStruct.labels``/``.values`` columns — worksheet
|
|
36
|
+
column ``idx + 2``, since column 1 is X). ``y_keys=None`` means "all
|
|
37
|
+
channels" (mirrors ``PlotState``/``build_series``'s default). Channels
|
|
38
|
+
listed in ``y2_keys`` are drawn on a secondary (right) Y axis; they
|
|
39
|
+
should be a subset of the effective y-key set.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
y_keys: tuple[int, ...] | None = None
|
|
43
|
+
x_key: int | None = None
|
|
44
|
+
x_log: bool = False
|
|
45
|
+
y_log: bool = False
|
|
46
|
+
x_lim: tuple[float, float] | None = None
|
|
47
|
+
y_lim: tuple[float, float] | None = None
|
|
48
|
+
y2_keys: tuple[int, ...] = ()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _meta_get(meta: dict[str, Any], *keys: str, default: Any = None) -> Any:
|
|
52
|
+
"""First present, non-empty metadata value among ``keys`` (snake/camel)."""
|
|
53
|
+
for key in keys:
|
|
54
|
+
val = meta.get(key)
|
|
55
|
+
if val not in (None, ""):
|
|
56
|
+
return val
|
|
57
|
+
return default
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _escape_lt(text: str) -> str:
|
|
61
|
+
"""Escape double-quotes for a LabTalk string literal."""
|
|
62
|
+
return text.replace('"', '\\"')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sanitize(name: str) -> str:
|
|
66
|
+
"""LabTalk-safe identifier: non-word chars -> underscore (MATLAB \\W)."""
|
|
67
|
+
return re.sub(r"\W", "_", name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_err_label(label: str) -> bool:
|
|
71
|
+
low = label.lower()
|
|
72
|
+
return any(kw in low for kw in _ERR_KEYWORDS)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _col(idx: int) -> int:
|
|
76
|
+
"""Worksheet column (1-based) for value-channel index ``idx`` (0-based).
|
|
77
|
+
Column 1 is X; channels start at column 2 (mirrors the designations loop
|
|
78
|
+
in ``format_origin_script``)."""
|
|
79
|
+
return idx + 2
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _axis_title(label: str, unit: str) -> str:
|
|
83
|
+
return label + (f" ({unit})" if unit else "")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _plot_state_graph(
|
|
87
|
+
graph: GraphSpec,
|
|
88
|
+
*,
|
|
89
|
+
labels: list[str],
|
|
90
|
+
units: list[str],
|
|
91
|
+
x_name: str,
|
|
92
|
+
x_unit: str,
|
|
93
|
+
sheet: str,
|
|
94
|
+
) -> list[str]:
|
|
95
|
+
"""LabTalk lines recreating the CURRENT PLOT STATE (item 26): selected
|
|
96
|
+
channels, x source, log flags, axis limits, and an optional secondary
|
|
97
|
+
(right) Y axis for ``y2_keys``.
|
|
98
|
+
|
|
99
|
+
One ``plotxy`` call plots the whole primary y-set via grouped range
|
|
100
|
+
syntax (``(x,y1):(x,y2):…`` — pairs need not be contiguous columns). The
|
|
101
|
+
secondary axis uses ``layer -nr`` (new right-Y layer linked to the active
|
|
102
|
+
graph's X axis) followed by a ``plotxy`` into layer 2. Both were VERIFIED
|
|
103
|
+
live against OriginPro via COM: the secondary ``plotxy`` must reference the
|
|
104
|
+
worksheet explicitly (``[%(qzbk$)]<sheet>!``) because the graph — not the
|
|
105
|
+
book — is the active window by then, and impASC renamed the book, so
|
|
106
|
+
``qzbk$`` (captured in the import block) holds its real short name.
|
|
107
|
+
"""
|
|
108
|
+
y_indices = list(range(len(labels))) if graph.y_keys is None else list(graph.y_keys)
|
|
109
|
+
if not y_indices:
|
|
110
|
+
return ["", "// Create graph (current plot state): no channels selected -- skipped"]
|
|
111
|
+
|
|
112
|
+
# Validate channel indices up front: an out-of-range or negative index would
|
|
113
|
+
# otherwise emit a `plotxy` referencing a worksheet column that was never
|
|
114
|
+
# declared (or column 0 / a wrapped-around label), silently producing a
|
|
115
|
+
# broken .ogs. The route converts this ValueError to a 422.
|
|
116
|
+
n_ch = len(labels)
|
|
117
|
+
checked = [*y_indices, *graph.y2_keys, *([] if graph.x_key is None else [graph.x_key])]
|
|
118
|
+
out_of_range = sorted({k for k in checked if not 0 <= k < n_ch})
|
|
119
|
+
if out_of_range:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"graph channel index out of range for {n_ch} channel(s): {out_of_range}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
y2_set = set(graph.y2_keys)
|
|
125
|
+
primary = [i for i in y_indices if i not in y2_set]
|
|
126
|
+
secondary = [i for i in y_indices if i in y2_set]
|
|
127
|
+
if not primary:
|
|
128
|
+
# Nothing to anchor a left/right split against -- render the would-be
|
|
129
|
+
# y2 set on the single default axis instead.
|
|
130
|
+
primary, secondary = y_indices, []
|
|
131
|
+
|
|
132
|
+
x_col = 1 if graph.x_key is None else _col(graph.x_key)
|
|
133
|
+
if graph.x_key is None:
|
|
134
|
+
x_label, x_lbl_unit = x_name, x_unit
|
|
135
|
+
else:
|
|
136
|
+
xi = graph.x_key
|
|
137
|
+
x_label = labels[xi] if xi < len(labels) else x_name
|
|
138
|
+
x_lbl_unit = units[xi] if xi < len(units) else ""
|
|
139
|
+
|
|
140
|
+
o: list[str] = ["", "// Create graph (current plot state)"]
|
|
141
|
+
pairs = ":".join(f"({x_col},{_col(i)})" for i in primary)
|
|
142
|
+
o.append(f"plotxy iy:={pairs} plot:=201 ogl:=[<new>];")
|
|
143
|
+
if graph.x_log:
|
|
144
|
+
o.append("layer.x.type = 1; // Log X")
|
|
145
|
+
if graph.y_log:
|
|
146
|
+
o.append("layer.y.type = 1; // Log Y")
|
|
147
|
+
# Only emit finite limits -- NaN/Inf would format as invalid LabTalk
|
|
148
|
+
# literals (`layer.x.from = nan;`). A non-finite bound just omits the line,
|
|
149
|
+
# letting Origin auto-scale that axis.
|
|
150
|
+
if graph.x_lim is not None and all(math.isfinite(v) for v in graph.x_lim):
|
|
151
|
+
lo, hi = graph.x_lim
|
|
152
|
+
o.append(f"layer.x.from = {lo:.10g};")
|
|
153
|
+
o.append(f"layer.x.to = {hi:.10g};")
|
|
154
|
+
if graph.y_lim is not None and all(math.isfinite(v) for v in graph.y_lim):
|
|
155
|
+
lo, hi = graph.y_lim
|
|
156
|
+
o.append(f"layer.y.from = {lo:.10g};")
|
|
157
|
+
o.append(f"layer.y.to = {hi:.10g};")
|
|
158
|
+
o.append(f'xb.text$ = "{_escape_lt(_axis_title(x_label, x_lbl_unit))}";')
|
|
159
|
+
if len(primary) == 1:
|
|
160
|
+
yi = primary[0]
|
|
161
|
+
y_unit = units[yi] if yi < len(units) else ""
|
|
162
|
+
o.append(f'yl.text$ = "{_escape_lt(_axis_title(labels[yi], y_unit))}";')
|
|
163
|
+
|
|
164
|
+
if secondary:
|
|
165
|
+
# Explicit worksheet ref: the graph is the active window here, so a bare
|
|
166
|
+
# (x,y) range would not resolve. qzbk$ (import block) holds the book's
|
|
167
|
+
# post-impASC short name; <sheet> was restored right after import.
|
|
168
|
+
pairs2 = ":".join(f"[%(qzbk$)]{sheet}!({x_col},{_col(i)})" for i in secondary)
|
|
169
|
+
o += [
|
|
170
|
+
"",
|
|
171
|
+
"// Secondary (right) Y axis for y2-assigned channels (item 26).",
|
|
172
|
+
"// New right-Y layer linked to the active graph's X, then plot the",
|
|
173
|
+
"// y2 channels into layer 2 (verified live in OriginPro via COM).",
|
|
174
|
+
"layer -nr; // new right-Y layer, linked X",
|
|
175
|
+
f"plotxy iy:={pairs2} plot:=201 ogl:=2!;",
|
|
176
|
+
"page.active = 2; // operate on the new right-Y layer below",
|
|
177
|
+
]
|
|
178
|
+
if graph.y_log:
|
|
179
|
+
o.append("layer.y.type = 1; // Log Y (right)")
|
|
180
|
+
if len(secondary) == 1:
|
|
181
|
+
yi = secondary[0]
|
|
182
|
+
y_unit = units[yi] if yi < len(units) else ""
|
|
183
|
+
# A "layer -nr" layer has no pre-made axis-title object, so use the
|
|
184
|
+
# `label -yr` command (yr.text$ / yl.text$ fail here) to title its
|
|
185
|
+
# right Y axis. Verified live in OriginPro via COM.
|
|
186
|
+
o.append(f'label -yr "{_escape_lt(_axis_title(labels[yi], y_unit))}";')
|
|
187
|
+
return o
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def format_origin_script(
|
|
191
|
+
data: DataStruct,
|
|
192
|
+
*,
|
|
193
|
+
csv_name: str = "data.csv",
|
|
194
|
+
book_name: str = "",
|
|
195
|
+
sheet_name: str = "",
|
|
196
|
+
log_x: bool = False,
|
|
197
|
+
log_y: bool = False,
|
|
198
|
+
make_graph: bool = True,
|
|
199
|
+
created: str = "",
|
|
200
|
+
book_long_name: str = "",
|
|
201
|
+
graph: GraphSpec | None = None,
|
|
202
|
+
) -> tuple[str, str]:
|
|
203
|
+
"""Return ``(csv_text, ogs_text)`` for ``data``. Port of exportOriginScript.
|
|
204
|
+
|
|
205
|
+
``csv_name`` is the CSV filename the script references (``impASC``).
|
|
206
|
+
``book_name``/``sheet_name`` default to the source filename in metadata,
|
|
207
|
+
then ``"data"``; both are sanitized to LabTalk identifiers.
|
|
208
|
+
|
|
209
|
+
``graph`` (item 26), when given, replaces the default single-column graph
|
|
210
|
+
with a full plot-state export: selected channels, x source, log axes,
|
|
211
|
+
limits, and a secondary Y axis. Ignored when ``make_graph`` is False.
|
|
212
|
+
"""
|
|
213
|
+
meta = dict(data.metadata)
|
|
214
|
+
source = _meta_get(meta, "source", "filepath", "filename", default="")
|
|
215
|
+
base = str(source).replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
|
216
|
+
book = _sanitize(book_name or base or "data")
|
|
217
|
+
sheet = _sanitize(sheet_name or book)
|
|
218
|
+
|
|
219
|
+
x_name = str(_meta_get(meta, "x_column_name", "xColumnName", default="X"))
|
|
220
|
+
x_unit = str(_meta_get(meta, "x_column_unit", "xColumnUnit", default=""))
|
|
221
|
+
|
|
222
|
+
labels = list(data.labels)
|
|
223
|
+
units = list(data.units)
|
|
224
|
+
time = np.asarray(data.time, dtype=float)
|
|
225
|
+
values = np.asarray(data.values, dtype=float)
|
|
226
|
+
|
|
227
|
+
# ── CSV: header, units, then %.10g data rows ──
|
|
228
|
+
csv_lines = [
|
|
229
|
+
",".join([x_name, *labels]),
|
|
230
|
+
",".join([x_unit, *units]),
|
|
231
|
+
]
|
|
232
|
+
for r in range(values.shape[0]):
|
|
233
|
+
cells = [f"{time[r]:.10g}"]
|
|
234
|
+
cells.extend(f"{values[r, c]:.10g}" for c in range(values.shape[1]))
|
|
235
|
+
csv_lines.append(",".join(cells))
|
|
236
|
+
csv_text = "\n".join(csv_lines) + "\n"
|
|
237
|
+
|
|
238
|
+
# ── LabTalk (.ogs) script ──
|
|
239
|
+
o: list[str] = [
|
|
240
|
+
"// LabTalk script generated by quantized_matlab",
|
|
241
|
+
f"// Date: {created}",
|
|
242
|
+
"",
|
|
243
|
+
"// Import data",
|
|
244
|
+
f'newbook name:="{book}" sheet:=1;',
|
|
245
|
+
# impASC auto-detects the 2-row (name/unit) header AND renames the book +
|
|
246
|
+
# sheet to the source file -- so restore the intended names AFTER import.
|
|
247
|
+
# (The historical `options.SkipRows.Count:=2` sub-option is INVALID LabTalk
|
|
248
|
+
# in current Origin and aborts the whole import; bare impASC reads our
|
|
249
|
+
# 2-header CSV correctly and the explicit designations below re-assert
|
|
250
|
+
# names/units. See docs/origin_project_format.md.)
|
|
251
|
+
f'impASC fname:="{csv_name}";',
|
|
252
|
+
*([f'page.longname$ = "{_escape_lt(book_long_name)}";'] if book_long_name else []),
|
|
253
|
+
f'wks.name$ = "{sheet}";',
|
|
254
|
+
# A successful impASC renamed the book; capture its (post-import) short
|
|
255
|
+
# name so the item-26 secondary-axis plotxy can reference the worksheet
|
|
256
|
+
# columns while the GRAPH window -- not the book -- is active.
|
|
257
|
+
*(["string qzbk$ = page.name$;"] if (make_graph and graph is not None) else []),
|
|
258
|
+
"",
|
|
259
|
+
"// Column designations and labels",
|
|
260
|
+
"wks.col1.type = 4; // X",
|
|
261
|
+
f'wks.col1.lname$ = "{_escape_lt(x_name)}";',
|
|
262
|
+
f'wks.col1.unit$ = "{_escape_lt(x_unit)}";',
|
|
263
|
+
]
|
|
264
|
+
for k, label in enumerate(labels):
|
|
265
|
+
cn = k + 2
|
|
266
|
+
unit = units[k] if k < len(units) else ""
|
|
267
|
+
if _is_err_label(label):
|
|
268
|
+
o.append(f"wks.col{cn}.type = 3; // yErr")
|
|
269
|
+
else:
|
|
270
|
+
o.append(f"wks.col{cn}.type = 1; // Y")
|
|
271
|
+
o.append(f'wks.col{cn}.lname$ = "{_escape_lt(label)}";')
|
|
272
|
+
o.append(f'wks.col{cn}.unit$ = "{_escape_lt(unit)}";')
|
|
273
|
+
|
|
274
|
+
if make_graph:
|
|
275
|
+
if graph is not None:
|
|
276
|
+
o += _plot_state_graph(
|
|
277
|
+
graph, labels=labels, units=units, x_name=x_name, x_unit=x_unit, sheet=sheet
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
o += ["", "// Create graph", "plotxy iy:=(1,2) plot:=201 ogl:=[<new>];"]
|
|
281
|
+
if log_x:
|
|
282
|
+
o.append("layer.x.type = 1; // Log X")
|
|
283
|
+
if log_y:
|
|
284
|
+
o.append("layer.y.type = 1; // Log Y")
|
|
285
|
+
x_title = x_name + (f" ({x_unit})" if x_unit else "")
|
|
286
|
+
o.append(f'xb.text$ = "{_escape_lt(x_title)}";')
|
|
287
|
+
if len(labels) == 1:
|
|
288
|
+
y_unit = units[0] if units else ""
|
|
289
|
+
y_title = labels[0] + (f" ({y_unit})" if y_unit else "")
|
|
290
|
+
o.append(f'yl.text$ = "{_escape_lt(y_title)}";')
|
|
291
|
+
|
|
292
|
+
o += ["", "// Done"]
|
|
293
|
+
ogs_text = "\n".join(o) + "\n"
|
|
294
|
+
return csv_text, ogs_text
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def format_origin_project_script(
|
|
298
|
+
items: list[tuple[DataStruct, str]],
|
|
299
|
+
*,
|
|
300
|
+
created: str = "",
|
|
301
|
+
make_graph: bool = False,
|
|
302
|
+
) -> tuple[list[tuple[str, str]], str]:
|
|
303
|
+
"""Multi-book export: one LabTalk ``.ogs`` importing N CSVs into N books.
|
|
304
|
+
|
|
305
|
+
``items`` is ``[(dataset, book_name), …]`` (an empty name falls back to the
|
|
306
|
+
dataset's ``origin_book`` metadata, then ``dataN``). Returns
|
|
307
|
+
``([(csv_name, csv_text), …], ogs_text)`` — the script recreates every
|
|
308
|
+
workbook with designations, long names, units, and the book display title
|
|
309
|
+
(``page.longname$``) when the dataset carries one.
|
|
310
|
+
"""
|
|
311
|
+
if not items:
|
|
312
|
+
raise ValueError("format_origin_project_script needs at least one dataset")
|
|
313
|
+
csvs: list[tuple[str, str]] = []
|
|
314
|
+
sections: list[str] = [
|
|
315
|
+
"// LabTalk project script generated by quantized",
|
|
316
|
+
f"// Date: {created}",
|
|
317
|
+
f"// Books: {len(items)}",
|
|
318
|
+
]
|
|
319
|
+
used: set[str] = set()
|
|
320
|
+
for i, (ds, name) in enumerate(items):
|
|
321
|
+
meta = dict(ds.metadata)
|
|
322
|
+
book = _sanitize(str(name or meta.get("origin_book", "") or f"data{i + 1}"))
|
|
323
|
+
base = book
|
|
324
|
+
n = 1
|
|
325
|
+
while book in used:
|
|
326
|
+
n += 1
|
|
327
|
+
book = f"{base}{n}"
|
|
328
|
+
used.add(book)
|
|
329
|
+
long_name = str(meta.get("origin_book_long", "") or "")
|
|
330
|
+
csv_name = f"{book}_data.csv"
|
|
331
|
+
csv_text, ogs_text = format_origin_script(
|
|
332
|
+
ds,
|
|
333
|
+
csv_name=csv_name,
|
|
334
|
+
book_name=book,
|
|
335
|
+
sheet_name=book,
|
|
336
|
+
make_graph=make_graph,
|
|
337
|
+
created=created,
|
|
338
|
+
book_long_name=long_name if long_name != book else "",
|
|
339
|
+
)
|
|
340
|
+
csvs.append((csv_name, csv_text))
|
|
341
|
+
body = ogs_text.splitlines()
|
|
342
|
+
# drop the per-script header comment lines (kept once at the top)
|
|
343
|
+
while body and body[0].startswith("//"):
|
|
344
|
+
body.pop(0)
|
|
345
|
+
sections += ["", f"// ---- Book {i + 1}: {book} ----", *body]
|
|
346
|
+
return csvs, "\n".join(sections) + "\n"
|