quantized-lab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
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"