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/spc.py ADDED
@@ -0,0 +1,311 @@
1
+ """GRAMS/Thermo Scientific ``.spc`` spectral binary parser.
2
+
3
+ **NOT a MATLAB port.** ``quantized_matlab`` never implemented
4
+ ``importSPC.m`` — the roadmap explicitly parks it "Paused (awaiting example
5
+ files)" (``quantized_matlab/plans/archive/parser-roadmap.md`` item 4;
6
+ ``PORT_CHECKLIST.md`` line 46) and no source or test exists to freeze golden
7
+ values from. This is an independent implementation against the published
8
+ Galactic Industries "SPC" binary layout (the field names below — ``ftflgs``,
9
+ ``fnpts``, ``fexp``, ... — are the format's own defined struct fields,
10
+ reproduced from the widely-republished ``SPC.H`` header spec, not copied
11
+ from any single implementation's source code). Cross-validated against four
12
+ real-world instrument files (Horiba Raman, Perkin-Elmer FTIR, a Kr
13
+ calibration-lamp spectrum, and a Nicolet FTIR/Raman scan) during development
14
+ — see the parser test file for the offset-by-offset sanity checks that
15
+ matched every one of those independently-produced binary files.
16
+
17
+ Only the modern "new format, LSB-first" sub-version (``fversn == 0x4B``) is
18
+ implemented — the sub-version every real sample file used, and the one
19
+ every actively-maintained OPUS/SPC reader targets. The old pre-1996 format
20
+ (``0x4D``), the rare new-format MSB variant (``0x4C``), and the
21
+ Shimadzu-specific variant (``0xCF``) are recognized but rejected with a
22
+ clear error rather than guessed at (no example file / spec text was
23
+ available to validate them against).
24
+
25
+ Binary layout (new format, 512-byte main header)
26
+ --------------------------------------------------
27
+ Bytes 0 ftflgs (flags, see ``_FLAG_BITS``)
28
+ Bytes 1 fversn (0x4B for this parser)
29
+ Bytes 2 fexper (experiment-type code, see ``_EXPERIMENT_TYPES``)
30
+ Bytes 3 fexp (signed; y-scaling exponent, or -128 = IEEE float32 y)
31
+ Bytes 4-7 fnpts (points per subfile, uint32)
32
+ Bytes 8-15 ffirst (first x value, float64)
33
+ Bytes 16-23 flast (last x value, float64)
34
+ Bytes 24-27 fnsub (number of subfiles, uint32)
35
+ Bytes 28-31 fxtype, fytype, fztype, fpost (axis unit codes)
36
+ Bytes 32-35 fdate (packed year<<20|month<<16|day<<11|hour<<6|minute)
37
+ Bytes 36-44 fres (resolution description, 9 bytes text)
38
+ Bytes 45-53 fsource (source instrument, 9 bytes text)
39
+ Bytes 54-247 fpeakpt, fspare, fcmnt, fcatxt (comment / custom axis labels)
40
+ Bytes 248-251 flogoff (byte offset of the trailer log block; 0 = none)
41
+ Bytes 252-511 fmods, fprocs, flevel, fsampin, ffactor, fmethod, fzinc,
42
+ fwplanes, fwinc, fwtype, freserv (processing/4-D metadata)
43
+
44
+ Each subfile is a 32-byte subheader followed by its y data (and, when the
45
+ ``txyxys`` flag is set, its own x data first). See ``_SUBHEAD_FMT``.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import struct
51
+ from pathlib import Path
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_spc", "is_spc"]
60
+
61
+ # ── Binary layout constants ──────────────────────────────────────────────
62
+ _HEAD_SIZE = 512
63
+ _SUBHEAD_SIZE = 32
64
+ _LOG_HEAD_SIZE = 64
65
+ _FLOAT_EXP_SENTINEL = -128 # fexp/subexp value meaning "y is IEEE float32"
66
+
67
+ _HEAD_FMT = "<BBBbIddIBBBBI9s9sh32s130s30sIIBBhf48sfIfB187s"
68
+ _HEAD_FIELDS = (
69
+ "ftflgs", "fversn", "fexper", "fexp", "fnpts", "ffirst", "flast", "fnsub",
70
+ "fxtype", "fytype", "fztype", "fpost", "fdate", "fres", "fsource",
71
+ "fpeakpt", "fspare", "fcmnt", "fcatxt", "flogoff", "fmods", "fprocs",
72
+ "flevel", "fsampin", "ffactor", "fmethod", "fzinc", "fwplanes", "fwinc",
73
+ "fwtype", "freserv",
74
+ )
75
+ assert struct.calcsize(_HEAD_FMT) == _HEAD_SIZE
76
+
77
+ _SUBHEAD_FMT = "<BbhfffIIf4s"
78
+ _SUBHEAD_FIELDS = (
79
+ "subflgs", "subexp", "subindx", "subtime", "subnext", "subnois",
80
+ "subnpts", "subscan", "subwlevel", "subresv",
81
+ )
82
+ assert struct.calcsize(_SUBHEAD_FMT) == _SUBHEAD_SIZE
83
+
84
+ _LOG_FMT = "<IIIII44s"
85
+ assert struct.calcsize(_LOG_FMT) == _LOG_HEAD_SIZE
86
+
87
+ # ftflgs bit -> name (LSB first; matches the SPC spec's TSPREC..TXVALS bits)
88
+ _FLAG_BITS = ("tsprec", "tcgram", "tmulti", "trandm", "tordrd", "talabs", "txyxys", "txvals")
89
+
90
+ _UNSUPPORTED_FVERSN = {
91
+ 0x4C: "new-format MSB-first",
92
+ 0x4D: "old format (pre-1996)",
93
+ 0xCF: "Shimadzu-specific variant",
94
+ }
95
+
96
+ # X/Z axis unit codes (fxtype/fztype) — the SPC spec's defined enumeration.
97
+ _XZ_UNITS = (
98
+ "Arbitrary", "Wavenumber (cm-1)", "Micrometers (um)", "Nanometers (nm)",
99
+ "Seconds", "Minutes", "Hertz (Hz)", "Kilohertz (KHz)", "Megahertz (MHz)",
100
+ "Mass (M/z)", "Parts per million (PPM)", "Days", "Years",
101
+ "Raman Shift (cm-1)", "eV", "XYZ text labels in fcatxt", "Diode Number",
102
+ "Channel", "Degrees", "Temperature (F)", "Temperature (C)",
103
+ "Temperature (K)", "Data Points", "Milliseconds (mSec)",
104
+ "Microseconds (uSec)", "Nanoseconds (nSec)", "Gigahertz (GHz)",
105
+ "Centimeters (cm)", "Meters (m)", "Millimeters (mm)", "Hours",
106
+ )
107
+ # Y axis unit codes (fytype): 0-26 direct table, 128-131 a second table.
108
+ _Y_UNITS = (
109
+ "Arbitrary Intensity", "Interferogram", "Absorbance", "Kubelka-Munk",
110
+ "Counts", "Volts", "Degrees", "Milliamps", "Millimeters", "Millivolts",
111
+ "Log(1/R)", "Percent", "Intensity", "Relative Intensity", "Energy", "",
112
+ "Decibel", "", "", "Temperature (F)", "Temperature (C)",
113
+ "Temperature (K)", "Index of Refraction [N]", "Extinction Coeff. [K]",
114
+ "Real", "Imaginary", "Complex",
115
+ )
116
+ _Y_UNITS_ALT = ("Transmission", "Reflectance", "Arbitrary or Single Beam", "Emission")
117
+
118
+ _EXPERIMENT_TYPES = (
119
+ "General SPC", "Gas Chromatogram", "General Chromatogram",
120
+ "HPLC Chromatogram", "FT-IR, FT-NIR, FT-Raman Spectrum or Igram",
121
+ "NIR Spectrum", "UV-VIS Spectrum", "X-ray Diffraction Spectrum",
122
+ "Mass Spectrum", "NMR Spectrum or FID", "Raman Spectrum",
123
+ "Fluorescence Spectrum", "Atomic Spectrum", "Chromatography Diode Array Spectra",
124
+ )
125
+
126
+
127
+ def _axis_label(code: int) -> str:
128
+ return _XZ_UNITS[code] if 0 <= code < len(_XZ_UNITS) else "Unknown"
129
+
130
+
131
+ def _y_label(code: int) -> str:
132
+ if 0 <= code < len(_Y_UNITS):
133
+ return _Y_UNITS[code] or "Arbitrary Intensity"
134
+ if 128 <= code < 128 + len(_Y_UNITS_ALT):
135
+ return _Y_UNITS_ALT[code - 128]
136
+ return "Unknown"
137
+
138
+
139
+ def is_spc(path: str | Path) -> bool:
140
+ """Sniff a file as SPC via the ``fversn`` byte (0x4B/0x4C/0x4D/0xCF)."""
141
+ try:
142
+ with Path(path).open("rb") as fh:
143
+ head = fh.read(2)
144
+ except OSError:
145
+ return False
146
+ return len(head) == 2 and head[1] in (0x4B, 0x4C, 0x4D, 0xCF)
147
+
148
+
149
+ def _decode_flags(ftflgs: int) -> dict[str, bool]:
150
+ return {name: bool(ftflgs & (1 << bit)) for bit, name in enumerate(_FLAG_BITS)}
151
+
152
+
153
+ def _decode_date(fdate: int) -> dict[str, int] | None:
154
+ if fdate == 0:
155
+ return None
156
+ return {
157
+ "year": fdate >> 20,
158
+ "month": (fdate >> 16) % 16,
159
+ "day": (fdate >> 11) % 32,
160
+ "hour": (fdate >> 6) % 32,
161
+ "minute": fdate % 64,
162
+ }
163
+
164
+
165
+ def _null_str(raw: bytes) -> str:
166
+ return raw.split(b"\x00", 1)[0].decode("latin-1", errors="replace").strip()
167
+
168
+
169
+ def _parse_log_block(raw: bytes, flogoff: int) -> dict[str, Any] | None:
170
+ """Trailer log block: a small binary header, then '\\n'-joined text lines,
171
+ each either a ``KEY=value`` pair or free text."""
172
+ if flogoff <= 0 or flogoff + _LOG_HEAD_SIZE > len(raw):
173
+ return None
174
+ logsizd, _logsizm, logtxto, _logbins, _logdsks, _logspar = struct.unpack_from(
175
+ _LOG_FMT, raw, flogoff
176
+ )
177
+ log_pos = flogoff + logtxto
178
+ log_end = log_pos + logsizd
179
+ if log_pos < 0 or log_end > len(raw) or log_end < log_pos:
180
+ return None
181
+ lines = raw[log_pos:log_end].replace(b"\r", b"").split(b"\n")
182
+ pairs: dict[str, str] = {}
183
+ other: list[str] = []
184
+ for raw_line in lines:
185
+ text = raw_line.decode("latin-1", errors="replace")
186
+ if "=" in text:
187
+ key, _, value = text.partition("=")
188
+ pairs[key.strip()] = value.strip()
189
+ elif text.strip():
190
+ other.append(text.strip())
191
+ return {"fields": pairs, "text": other}
192
+
193
+
194
+ def _y_from_ints(raw_ints: NDArray[np.integer[Any]], exp: int, bits: int) -> NDArray[np.float64]:
195
+ return np.asarray(raw_ints, dtype=float) * (2.0 ** (exp - bits))
196
+
197
+
198
+ def _read_subfile(
199
+ raw: bytes, pos: int, *, fnpts: int, fexp: int, tmulti: bool, tsprec: bool, txyxys: bool
200
+ ) -> tuple[NDArray[np.float64] | None, NDArray[np.float64], int, dict[str, Any]]:
201
+ """Returns (own_x or None, y, bytes_consumed, subheader_info)."""
202
+ sub = dict(zip(_SUBHEAD_FIELDS, struct.unpack_from(_SUBHEAD_FMT, raw, pos), strict=True))
203
+ exp = int(sub["subexp"]) if tmulti else fexp
204
+ pts = int(sub["subnpts"]) if (txyxys and sub["subnpts"] > 0) else fnpts
205
+ cursor = pos + _SUBHEAD_SIZE
206
+
207
+ own_x = None
208
+ if txyxys:
209
+ # Per-subfile x is fixed-point scaled int32 (same exponent formula as y).
210
+ x_ints = np.frombuffer(raw, dtype="<i4", count=pts, offset=cursor)
211
+ own_x = _y_from_ints(x_ints, exp, 32)
212
+ cursor += 4 * pts
213
+
214
+ if exp == _FLOAT_EXP_SENTINEL:
215
+ y = np.frombuffer(raw, dtype="<f4", count=pts, offset=cursor).astype(float)
216
+ cursor += 4 * pts
217
+ elif tsprec:
218
+ y_ints = np.frombuffer(raw, dtype="<i2", count=pts, offset=cursor)
219
+ y = _y_from_ints(y_ints, exp, 16)
220
+ cursor += 2 * pts
221
+ else:
222
+ y_ints = np.frombuffer(raw, dtype="<i4", count=pts, offset=cursor)
223
+ y = _y_from_ints(y_ints, exp, 32)
224
+ cursor += 4 * pts
225
+
226
+ return own_x, y, cursor - pos, sub
227
+
228
+
229
+ def import_spc(filepath: str | Path) -> DataStruct:
230
+ """Import a GRAMS/Thermo ``.spc`` spectral file into a DataStruct."""
231
+ path = Path(filepath)
232
+ raw = path.read_bytes()
233
+ if len(raw) < 2:
234
+ raise ValueError(f"file too small to be an SPC file: {path.name}")
235
+ fversn = raw[1]
236
+ if fversn in _UNSUPPORTED_FVERSN:
237
+ raise ValueError(
238
+ f"SPC sub-format '{_UNSUPPORTED_FVERSN[fversn]}' (fversn=0x{fversn:02x}) is "
239
+ f"recognized but not implemented (no example file to validate against): "
240
+ f"{path.name}. Only the modern format (fversn=0x4B) is supported."
241
+ )
242
+ if fversn != 0x4B or len(raw) < _HEAD_SIZE:
243
+ raise ValueError(f"not a recognized SPC file (fversn byte 0x{fversn:02x}): {path.name}")
244
+
245
+ head = dict(zip(_HEAD_FIELDS, struct.unpack_from(_HEAD_FMT, raw, 0), strict=True))
246
+ flags = _decode_flags(head["ftflgs"])
247
+ fnpts, fnsub, fexp = int(head["fnpts"]), int(head["fnsub"]), int(head["fexp"])
248
+ if fnpts <= 0 or fnsub <= 0:
249
+ raise ValueError(f"empty SPC file (fnpts={fnpts}, fnsub={fnsub}): {path.name}")
250
+
251
+ pos = _HEAD_SIZE
252
+ global_x: NDArray[np.float64] | None = None
253
+ if flags["txvals"] and not flags["txyxys"]:
254
+ global_x = np.frombuffer(raw, dtype="<f4", count=fnpts, offset=pos).astype(float)
255
+ pos += 4 * fnpts
256
+ elif not flags["txyxys"]:
257
+ global_x = np.linspace(head["ffirst"], head["flast"], fnpts)
258
+
259
+ subfiles: list[tuple[NDArray[np.float64] | None, NDArray[np.float64], dict[str, Any]]] = []
260
+ for _ in range(fnsub):
261
+ own_x, y, consumed, sub_info = _read_subfile(
262
+ raw, pos, fnpts=fnpts, fexp=fexp, tmulti=flags["tmulti"],
263
+ tsprec=flags["tsprec"], txyxys=flags["txyxys"],
264
+ )
265
+ subfiles.append((own_x, y, sub_info))
266
+ pos += consumed
267
+
268
+ x_label = _axis_label(head["fxtype"])
269
+ y_label = _y_label(head["fytype"])
270
+ if flags["talabs"]:
271
+ parts = head["fcatxt"].split(b"\x00")
272
+ if len(parts) >= 2:
273
+ xl, yl = _null_str(parts[0]), _null_str(parts[1])
274
+ x_label = xl or x_label
275
+ y_label = yl or y_label
276
+
277
+ multi_x = flags["txyxys"] and fnsub > 1
278
+ if multi_x:
279
+ # Per-subfile x-axes can't share one DataStruct time column (jcamp.py
280
+ # precedent: use the first block, note the rest in metadata).
281
+ x, y_cols, y_labels = subfiles[0][0], subfiles[0][1][:, None], [y_label]
282
+ else:
283
+ x = global_x if global_x is not None else subfiles[0][0]
284
+ y_cols = np.column_stack([y for _own_x, y, _info in subfiles])
285
+ y_labels = [y_label] if fnsub == 1 else [f"{y_label} {i + 1}" for i in range(fnsub)]
286
+ assert x is not None
287
+
288
+ metadata: dict[str, Any] = {
289
+ "source": str(path),
290
+ "parser_name": "import_spc",
291
+ "x_column_name": x_label,
292
+ "x_column_unit": "",
293
+ "experiment_type": (
294
+ _EXPERIMENT_TYPES[head["fexper"]]
295
+ if head["fexper"] < len(_EXPERIMENT_TYPES)
296
+ else "Unknown"
297
+ ),
298
+ "comment": _null_str(head["fcmnt"]),
299
+ "source_instrument": _null_str(head["fres"]) or _null_str(head["fsource"]),
300
+ "date": _decode_date(head["fdate"]),
301
+ "n_subfiles": fnsub,
302
+ "flags": flags,
303
+ "log": _parse_log_block(raw, int(head["flogoff"])),
304
+ }
305
+ if multi_x:
306
+ metadata["multi_x_subfiles"] = True
307
+ metadata["n_subfiles_with_own_x"] = fnsub
308
+
309
+ return DataStruct.create(
310
+ x, y_cols, labels=y_labels, units=[y_label] * len(y_labels), metadata=metadata
311
+ )
@@ -0,0 +1,308 @@
1
+ """XRD data CSV / Origin-ASCII exporter. Port of MATLAB ``+utilities/writeXRDcsv.m``.
2
+
3
+ This is the first *writer* in ``io/`` (readers parse files into a
4
+ ``DataStruct``; this goes the other way: ``DataStruct`` -> CSV text/file).
5
+
6
+ Two output formats mirror the MATLAB original exactly:
7
+
8
+ * ``"standard"`` — comma-delimited; one header row; optional ``# ``-prefixed
9
+ metadata block.
10
+ * ``"origin"`` — tab-delimited Origin ASCII; three header rows (long name,
11
+ units, X/Y designation); optional ``#\\t``-prefixed metadata block.
12
+
13
+ Number formatting matches MATLAB ``fprintf``: x-axis values use ``%.6f``,
14
+ intensity values use ``%.6g``.
15
+
16
+ Pure layer: ``DataStruct`` in -> string/file out. No fastapi/pydantic imports.
17
+
18
+ The string-producing :func:`format_xrd_csv` is the testable core;
19
+ :func:`write_xrd_csv` is a thin disk wrapper around it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import warnings
25
+ from collections.abc import Mapping
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+ from numpy.typing import NDArray
32
+
33
+ from quantized.datastruct import DataStruct
34
+
35
+ __all__ = ["format_xrd_csv", "write_xrd_csv"]
36
+
37
+ _FORMATS = ("standard", "origin")
38
+ _INTENSITIES = ("both", "cps", "counts")
39
+
40
+
41
+ def write_xrd_csv(
42
+ data: DataStruct,
43
+ output_path: str | Path,
44
+ *,
45
+ fmt: str = "standard",
46
+ intensity: str = "both",
47
+ include_metadata: bool = True,
48
+ ) -> None:
49
+ """Write XRD ``data`` to ``output_path`` as CSV or Origin ASCII.
50
+
51
+ Parameters
52
+ ----------
53
+ data:
54
+ ``DataStruct`` from an XRD parser (first value column is intensity).
55
+ output_path:
56
+ Destination ``.csv`` file. Its parent directory must exist (mirrors
57
+ the MATLAB ``isfolder`` check).
58
+ fmt:
59
+ ``"standard"`` (comma) or ``"origin"`` (tab, 3-row header). Case
60
+ insensitive (mirrors MATLAB ``validatestring``).
61
+ intensity:
62
+ ``"both"`` | ``"cps"`` | ``"counts"`` — which intensity column(s) to
63
+ emit. Case insensitive.
64
+ include_metadata:
65
+ Emit a comment-prefixed metadata header block.
66
+ """
67
+ out = Path(output_path)
68
+ parent = out.parent
69
+ # Mirror MATLAB: only validate the directory when one is specified.
70
+ if str(parent) not in ("", ".") and not parent.is_dir():
71
+ raise FileNotFoundError(f"Output directory does not exist: {parent}")
72
+
73
+ text = format_xrd_csv(
74
+ data, fmt=fmt, intensity=intensity, include_metadata=include_metadata
75
+ )
76
+ # newline="" so the explicit "\n" in the text is written verbatim (no
77
+ # platform translation), matching MATLAB's byte-for-byte fprintf output.
78
+ out.write_text(text, encoding="utf-8", newline="")
79
+
80
+
81
+ def format_xrd_csv(
82
+ data: DataStruct,
83
+ *,
84
+ fmt: str = "standard",
85
+ intensity: str = "both",
86
+ include_metadata: bool = True,
87
+ ) -> str:
88
+ """Return the CSV / Origin-ASCII text for ``data`` (no disk I/O).
89
+
90
+ See :func:`write_xrd_csv` for parameter meanings. Lines are joined with
91
+ ``"\\n"`` and the text ends with a trailing newline, matching MATLAB.
92
+ """
93
+ fmt_norm = _validate_choice(fmt, _FORMATS, "Format")
94
+ intensity_norm = _validate_choice(intensity, _INTENSITIES, "Intensity")
95
+
96
+ int_vals, int_labels, int_units = _resolve_intensity_columns(data, intensity_norm)
97
+
98
+ x_label = _x_axis_label(data)
99
+ x_unit = _x_axis_unit(data)
100
+
101
+ is_origin = fmt_norm == "origin"
102
+ sep = "\t" if is_origin else ","
103
+ prefix = "#\t" if is_origin else "# "
104
+
105
+ lines: list[str] = []
106
+ if include_metadata:
107
+ lines.extend(_metadata_block(data, prefix))
108
+
109
+ if is_origin:
110
+ names = [x_label, *int_labels]
111
+ units = [x_unit, *int_units]
112
+ designations = ["X", *(["Y"] * len(int_labels))]
113
+ lines.append(sep.join(names))
114
+ lines.append(sep.join(units))
115
+ lines.append(sep.join(designations))
116
+ else:
117
+ lines.append(sep.join([x_label, *int_labels]))
118
+
119
+ time = np.asarray(data.time, dtype=float)
120
+ for row in range(int_vals.shape[0]):
121
+ cells = [f"{time[row]:.6f}"]
122
+ cells.extend(f"{int_vals[row, col]:.6g}" for col in range(int_vals.shape[1]))
123
+ lines.append(sep.join(cells))
124
+
125
+ # Trailing newline after the final row (MATLAB writes "\n" after each row).
126
+ return "\n".join(lines) + "\n"
127
+
128
+
129
+ # ── Helpers ────────────────────────────────────────────────────────────────
130
+
131
+
132
+ def _validate_choice(value: str, allowed: tuple[str, ...], label: str) -> str:
133
+ """Case-insensitive choice validation (mirrors MATLAB ``validatestring``)."""
134
+ norm = str(value).strip().lower()
135
+ if norm not in allowed:
136
+ opts = ", ".join(f'"{a}"' for a in allowed)
137
+ raise ValueError(f"{label} must be one of [{opts}]; got {value!r}")
138
+ return norm
139
+
140
+
141
+ def _meta_get(meta: Mapping[str, Any], *keys: str, default: Any = None) -> Any:
142
+ """First present, non-empty metadata value among ``keys`` (or ``default``).
143
+
144
+ Accepts both quantized's flat snake_case keys and the MATLAB-style camelCase
145
+ keys (and a nested ``parser_specific`` / ``parserSpecific`` mapping), so the
146
+ writer is robust to either DataStruct provenance.
147
+ """
148
+ sources: list[Mapping[str, Any]] = [meta]
149
+ for nested_key in ("parser_specific", "parserSpecific"):
150
+ nested = meta.get(nested_key)
151
+ if isinstance(nested, Mapping):
152
+ sources.append(nested)
153
+ for src in sources:
154
+ for key in keys:
155
+ if key in src:
156
+ val = src[key]
157
+ if val is None:
158
+ continue
159
+ if isinstance(val, str) and val == "":
160
+ continue
161
+ return val
162
+ return default
163
+
164
+
165
+ def _counting_time(data: DataStruct) -> float:
166
+ """Counting time (s/point) or NaN. Mirrors MATLAB's parserSpecific lookup."""
167
+ val = _meta_get(data.metadata, "counting_time", "countingTime", default=None)
168
+ if val is None:
169
+ return float("nan")
170
+ try:
171
+ return float(val)
172
+ except (TypeError, ValueError):
173
+ return float("nan")
174
+
175
+
176
+ def _resolve_intensity_columns(
177
+ data: DataStruct, intensity: str
178
+ ) -> tuple[NDArray[np.float64], list[str], list[str]]:
179
+ """Select / convert intensity columns. Port of ``resolveIntensityColumns``.
180
+
181
+ Returns ``(values[N, k], labels[k], units[k])``.
182
+ """
183
+ original_unit = data.units[0] if data.units else ""
184
+ original = np.asarray(data.values[:, 0], dtype=float)
185
+ counting_time = _counting_time(data)
186
+ has_ct = not np.isnan(counting_time)
187
+ is_cps = "cps" in original_unit.lower()
188
+
189
+ if intensity == "both":
190
+ if not has_ct:
191
+ # Can't convert; write only the original column.
192
+ if is_cps:
193
+ return original.reshape(-1, 1), ["Intensity (cps)"], ["cps"]
194
+ return original.reshape(-1, 1), ["Intensity (counts)"], ["counts"]
195
+ if is_cps:
196
+ counts = original * counting_time
197
+ stacked = np.column_stack([original, counts])
198
+ else:
199
+ cps = original / counting_time
200
+ stacked = np.column_stack([cps, original])
201
+ return (
202
+ stacked,
203
+ ["Intensity (cps)", "Intensity (counts)"],
204
+ ["cps", "counts"],
205
+ )
206
+
207
+ if intensity == "cps":
208
+ if is_cps:
209
+ vals = original
210
+ elif not has_ct:
211
+ warnings.warn(
212
+ "Cannot convert counts to cps (countingTime not available). "
213
+ "Writing counts.",
214
+ stacklevel=2,
215
+ )
216
+ vals = original
217
+ else:
218
+ vals = original / counting_time
219
+ return vals.reshape(-1, 1), ["Intensity (cps)"], ["cps"]
220
+
221
+ # intensity == "counts"
222
+ is_counts = "counts" in original_unit.lower()
223
+ if is_counts:
224
+ vals = original
225
+ elif not has_ct:
226
+ warnings.warn(
227
+ "Cannot convert cps to counts (countingTime not available). "
228
+ "Writing cps.",
229
+ stacklevel=2,
230
+ )
231
+ vals = original
232
+ else:
233
+ vals = original * counting_time
234
+ return vals.reshape(-1, 1), ["Intensity (counts)"], ["counts"]
235
+
236
+
237
+ def _x_axis_label(data: DataStruct) -> str:
238
+ """Build the x-axis column label. Port of ``getXAxisLabel``."""
239
+ name = _meta_get(data.metadata, "x_column_name", "xColumnName", default=None)
240
+ if name is None:
241
+ return "X Axis"
242
+ unit = _x_axis_unit(data)
243
+ return f"{name} ({unit})" if unit else str(name)
244
+
245
+
246
+ def _x_axis_unit(data: DataStruct) -> str:
247
+ """X-axis unit string ('' when unknown). Port of ``getXAxisUnit``."""
248
+ unit = _meta_get(data.metadata, "x_column_unit", "xColumnUnit", default="")
249
+ return "" if unit is None else str(unit)
250
+
251
+
252
+ def _metadata_block(data: DataStruct, prefix: str) -> list[str]:
253
+ """Comment-prefixed metadata lines. Port of ``writeMetadataBlock``.
254
+
255
+ Field presence/order matches the MATLAB original. The "Export date" line
256
+ is non-deterministic (current time) by design — golden parity tests freeze
257
+ with ``include_metadata=False``.
258
+ """
259
+ meta = data.metadata
260
+ lines: list[str] = [f"{prefix}XRD Batch Export"]
261
+
262
+ source = _meta_get(meta, "source", "sourceFile", default=None)
263
+ if source is not None:
264
+ lines.append(f"{prefix}Source: {source}")
265
+
266
+ parser = _meta_get(meta, "parser_name", "parser", default=None)
267
+ if parser is not None:
268
+ lines.append(f"{prefix}Parser: {parser}")
269
+
270
+ sample = _meta_get(
271
+ meta, "sample_name", "sampleName", "sampleID", default=None
272
+ )
273
+ if sample is not None:
274
+ lines.append(f"{prefix}Sample: {sample}")
275
+
276
+ anode = _meta_get(meta, "anode_material", "anodeMaterial", default=None)
277
+ if anode is not None:
278
+ kv = _meta_get(meta, "tension_kV", "tension_kv", default=None)
279
+ ma = _meta_get(meta, "current_mA", "current_ma", default=None)
280
+ if kv is not None and ma is not None:
281
+ lines.append(f"{prefix}Anode: {anode} ({kv:.1f} kV / {ma:.1f} mA)")
282
+ else:
283
+ lines.append(f"{prefix}Anode: {anode}")
284
+
285
+ ka1 = _meta_get(meta, "k_alpha1", "kAlpha1", default=None)
286
+ if ka1 is not None:
287
+ lines.append(f"{prefix}Wavelength: Ka1 = {float(ka1):.5g} A")
288
+
289
+ start = _meta_get(meta, "start_angle", "startAngle", default=None)
290
+ end = _meta_get(meta, "end_angle", "endAngle", default=None)
291
+ if start is not None and end is not None:
292
+ lines.append(
293
+ f"{prefix}2-theta range: {float(start):.4f} - {float(end):.4f} deg"
294
+ )
295
+
296
+ step = _meta_get(meta, "step_size", "stepSize", default=None)
297
+ npts = _meta_get(meta, "n_points", "nPoints", "num_points", default=None)
298
+ if step is not None and npts is not None:
299
+ lines.append(f"{prefix}Step size: {float(step):.6f} deg ({int(npts)} points)")
300
+
301
+ ct = _counting_time(data)
302
+ if not np.isnan(ct):
303
+ lines.append(f"{prefix}Counting time: {ct:.3f} s/point")
304
+
305
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
306
+ lines.append(f"{prefix}Export date: {now}")
307
+ lines.append(prefix)
308
+ return lines