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
@@ -0,0 +1,158 @@
1
+ """Bruker/Siemens Diffrac-AT ``.raw`` binary XRD parser, version "RAW1.01".
2
+
3
+ Also known in ``xylib`` as "Bruker RAW ver. 3" (``bruker_raw.cpp``,
4
+ ``load_version1_01``). Fixed-layout little-endian binary produced by Siemens/
5
+ Bruker D-series diffractometers (Diffrac-AT / DIFFRAC^plus).
6
+
7
+ Byte layout (offsets 0-indexed, little-endian). Cross-checked against xylib's
8
+ ASCII ``.UXD`` export of the same raw file::
9
+
10
+ File header (712 bytes):
11
+ 0 8 bytes magic b'RAW1.01\\x00'
12
+ 12 4 <I range_cnt (number of scan ranges)
13
+ 608 4 ascii anode_material
14
+ 616 8 <d alpha_average (Ka average wavelength, A)
15
+ 624 8 <d alpha1
16
+ 632 8 <d alpha2
17
+
18
+ Per-range header (nominally 304 bytes, but read header_len):
19
+ 0 4 <I header_len (== 304 for RAW1.01)
20
+ 4 4 <I steps (number of data points)
21
+ 16 8 <d start_2theta (deg)
22
+ 176 8 <d step_size (deg)
23
+ 192 4 <f time_per_step (s)
24
+ 256 4 <I supplementary_headers_size
25
+
26
+ Intensity data (per range):
27
+ starts at range_start + header_len + supplementary_headers_size
28
+ <steps> float32 counts (stored as float even when integer)
29
+ next range (if any) starts immediately after: data_start + steps*4
30
+
31
+ The supplementary-header block is variable (0 in some files, 40 in others), so
32
+ ``data_start`` must be computed from the file's own header_len + supp size, not
33
+ a hardcoded 304 — a one-file test would miss this.
34
+
35
+ Reference: xylib (github.com/wojdyr/xylib), ``bruker_raw.cpp``. Sample files
36
+ ``xylib_BT86.raw`` / ``xylib_Cu3Au.raw`` (LGPL-2.1, attribution) seed the
37
+ parity tests.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import struct
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ import numpy as np
47
+
48
+ from quantized.datastruct import DataStruct
49
+
50
+ __all__ = ["import_bruker_raw", "is_bruker_raw"]
51
+
52
+ _FILE_HEADER_LEN = 712
53
+ _RANGE_HEADER_LEN = 304
54
+ _MAGIC = b"RAW1.01\x00"
55
+
56
+
57
+ def is_bruker_raw(path: Path) -> bool:
58
+ """Sniff a ``.raw`` as Bruker RAW1.01 via its magic bytes.
59
+
60
+ Rigaku ``.raw`` uses magic ``FI``, so there is no collision. Other Bruker
61
+ versions (``RAW2``/``RAW4.00``) start with ``RAW`` too but are not the
62
+ RAW1.01 layout; this sniffer accepts only RAW1.01 so the registry does not
63
+ mis-route them.
64
+ """
65
+ with Path(path).open("rb") as fh:
66
+ return fh.read(8) == _MAGIC
67
+
68
+
69
+ def import_bruker_raw(
70
+ filepath: str | Path,
71
+ *,
72
+ use_counts_per_sec: bool = False,
73
+ allow_partial: bool = False,
74
+ ) -> DataStruct:
75
+ """Import a Bruker RAW1.01 ``.raw`` (2theta vs intensity).
76
+
77
+ Parameters
78
+ ----------
79
+ use_counts_per_sec
80
+ Divide counts by ``time_per_step`` and label the channel ``counts/s``.
81
+ allow_partial
82
+ Multi-range files import range 0 only. Without this flag a multi-range
83
+ file raises (so the truncation is never silent); with it, range 0 is
84
+ returned and the extra ranges are recorded in metadata.
85
+
86
+ Returns
87
+ -------
88
+ DataStruct
89
+ ``time`` = 2theta (deg), one ``Intensity`` channel.
90
+ """
91
+ path = Path(filepath)
92
+ raw = path.read_bytes()
93
+ n_bytes = len(raw)
94
+ if n_bytes < _FILE_HEADER_LEN + _RANGE_HEADER_LEN:
95
+ raise ValueError(f"file too small to be a Bruker RAW1.01 ({n_bytes} bytes): {path.name}")
96
+ if raw[0:8] != _MAGIC:
97
+ raise ValueError(f"bad magic {raw[0:8]!r} (expected {_MAGIC!r}): {path.name}")
98
+
99
+ range_cnt = int(struct.unpack_from("<I", raw, 12)[0])
100
+ if range_cnt < 1:
101
+ raise ValueError(f"no scan ranges in file: {path.name}")
102
+
103
+ range_start = _FILE_HEADER_LEN
104
+ header_len = int(struct.unpack_from("<I", raw, range_start + 0)[0])
105
+ steps = int(struct.unpack_from("<I", raw, range_start + 4)[0])
106
+ start_2theta = float(struct.unpack_from("<d", raw, range_start + 16)[0])
107
+ step_size = float(struct.unpack_from("<d", raw, range_start + 176)[0])
108
+ time_per_step = float(struct.unpack_from("<f", raw, range_start + 192)[0])
109
+ supp_len = int(struct.unpack_from("<I", raw, range_start + 256)[0])
110
+
111
+ if header_len < _RANGE_HEADER_LEN:
112
+ raise ValueError(f"implausible range header length ({header_len}): {path.name}")
113
+ if steps < 1:
114
+ raise ValueError(f"range has no data points: {path.name}")
115
+ if step_size <= 0 or step_size > 10:
116
+ raise ValueError(f"implausible step size ({step_size:.6g} deg): {path.name}")
117
+
118
+ data_start = range_start + header_len + supp_len
119
+ if data_start + steps * 4 > n_bytes:
120
+ raise ValueError(
121
+ f"range claims {steps} points but only {(n_bytes - data_start) // 4} fit: {path.name}"
122
+ )
123
+
124
+ if range_cnt > 1 and not allow_partial:
125
+ raise ValueError(
126
+ f"multi-range .raw detected ({range_cnt} ranges); pass allow_partial=True "
127
+ f"to import the first range only: {path.name}"
128
+ )
129
+
130
+ intensities = np.frombuffer(raw, dtype="<f4", count=steps, offset=data_start).astype(float)
131
+ two_theta = start_2theta + np.arange(steps) * step_size
132
+
133
+ if use_counts_per_sec and time_per_step > 0:
134
+ values = intensities / time_per_step
135
+ unit = "counts/s"
136
+ else:
137
+ values = intensities
138
+ unit = "counts"
139
+
140
+ anode = raw[608:612].split(b"\x00", 1)[0].decode("ascii", "replace").strip()
141
+ metadata: dict[str, Any] = {
142
+ "source": str(path),
143
+ "parser_name": "import_bruker_raw",
144
+ "format_version": "RAW1.01",
145
+ "x_column_name": "2-Theta",
146
+ "x_column_unit": "deg",
147
+ "num_points": steps,
148
+ "start_angle": start_2theta,
149
+ "end_angle": float(two_theta[-1]),
150
+ "step_size": step_size,
151
+ "time_per_step": time_per_step,
152
+ "range_count": range_cnt,
153
+ "anode_material": anode,
154
+ "alpha_average": float(struct.unpack_from("<d", raw, 616)[0]),
155
+ }
156
+ return DataStruct.create(
157
+ two_theta, values, labels=["Intensity"], units=[unit], metadata=metadata
158
+ )
quantized/io/cif.py ADDED
@@ -0,0 +1,266 @@
1
+ """CIF (Crystallographic Information File) parser. Port of calc.importCIF.
2
+
3
+ Returns a crystal-structure dict (not a DataStruct — a crystal structure is not a
4
+ time/value series), so this parser is standalone and NOT registered in the
5
+ DataStruct ``registry``. Pure io layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ __all__ = ["import_cif"]
15
+
16
+
17
+ def _comment_pos(line: str) -> int:
18
+ """Index of the first '#' not inside a quoted string, or -1."""
19
+ in_single = in_double = False
20
+ for k, ch in enumerate(line):
21
+ if ch == "'" and not in_double:
22
+ in_single = not in_single
23
+ elif ch == '"' and not in_single:
24
+ in_double = not in_double
25
+ elif ch == "#" and not in_single and not in_double:
26
+ return k
27
+ return -1
28
+
29
+
30
+ def _strip_uncertainty(s: str) -> float:
31
+ """'5.4309(2)' -> 5.4309, '3.905' -> 3.905, '?'/'.' -> NaN."""
32
+ s = s.strip()
33
+ if s in ("?", "."):
34
+ return float("nan")
35
+ s = re.sub(r"\([^)]*\)", "", s)
36
+ try:
37
+ return float(s)
38
+ except ValueError:
39
+ return float("nan")
40
+
41
+
42
+ def _extract_single_value(token: str) -> str:
43
+ """Strip surrounding quotes; for unquoted, take the first whitespace token."""
44
+ token = token.strip()
45
+ if not token:
46
+ return ""
47
+ if token[0] == "'":
48
+ idx = token.rfind("'")
49
+ return token[1:idx] if idx > 0 else token[1:]
50
+ if token[0] == '"':
51
+ idx = token.rfind('"')
52
+ return token[1:idx] if idx > 0 else token[1:]
53
+ return token.split()[0]
54
+
55
+
56
+ def _read_semicolon_block(lines: list[str], i: int, n: int) -> tuple[str, int]:
57
+ """Read a ';'-delimited multi-line text field. Returns (text, last_content_idx)."""
58
+ i += 1
59
+ parts: list[str] = []
60
+ while i < n:
61
+ if lines[i][:1] == ";":
62
+ break
63
+ parts.append(lines[i])
64
+ i += 1
65
+ return " ".join(parts).strip(), i - 1
66
+
67
+
68
+ def _parse_tag_value(lines: list[str], i: int, n: int) -> tuple[str, str, int]:
69
+ """Parse a '_tag value' pair (value may be on the next line / a text block)."""
70
+ m = re.match(r"^(\S+)\s*(.*)", lines[i].strip())
71
+ if not m:
72
+ return lines[i].strip(), "", i
73
+ tag = m.group(1).strip().lower()
74
+ value = m.group(2).strip()
75
+ if not value:
76
+ if i + 1 < n:
77
+ next_line = lines[i + 1].strip()
78
+ if next_line and next_line[0] == ";":
79
+ value, i = _read_semicolon_block(lines, i + 1, n)
80
+ else:
81
+ i += 1
82
+ line2 = lines[i].strip()
83
+ cp = _comment_pos(line2)
84
+ if cp >= 0:
85
+ line2 = line2[:cp].strip()
86
+ value = _extract_single_value(line2)
87
+ elif value[0] == ";":
88
+ value, i = _read_semicolon_block(lines, i, n)
89
+ else:
90
+ value = _extract_single_value(value)
91
+ return tag, value, i
92
+
93
+
94
+ def _tokenise_cif_line(line: str) -> list[str]:
95
+ """Split a CIF data line into tokens, respecting single/double quotes."""
96
+ tokens: list[str] = []
97
+ n = len(line)
98
+ k = 0
99
+ while k < n:
100
+ ch = line[k]
101
+ if ch in (" ", "\t"):
102
+ k += 1
103
+ continue
104
+ if ch in ("'", '"'):
105
+ quote = ch
106
+ k += 1
107
+ start = k
108
+ while k < n and line[k] != quote:
109
+ k += 1
110
+ tokens.append(line[start:k])
111
+ k += 1
112
+ else:
113
+ start = k
114
+ while k < n and line[k] not in (" ", "\t"):
115
+ k += 1
116
+ tokens.append(line[start:k])
117
+ return tokens
118
+
119
+
120
+ def _parse_loop(lines: list[str], i: int, n: int) -> tuple[dict[str, Any], int]:
121
+ """Parse a loop_ block: collect tag names then data tokens into rows."""
122
+ loop_tags: list[str] = []
123
+ while i < n:
124
+ line = lines[i].strip()
125
+ cp = _comment_pos(line)
126
+ if cp >= 0:
127
+ line = line[:cp].strip()
128
+ if not line:
129
+ i += 1
130
+ continue
131
+ if line[0] == "_":
132
+ loop_tags.append(line.strip().lower())
133
+ i += 1
134
+ else:
135
+ break
136
+
137
+ n_cols = len(loop_tags)
138
+ if n_cols == 0:
139
+ return {"tags": [], "data": []}, i
140
+
141
+ tokens: list[str] = []
142
+ while i < n:
143
+ line = lines[i]
144
+ cp = _comment_pos(line)
145
+ if cp >= 0:
146
+ line = line[:cp]
147
+ trimmed = line.strip()
148
+ if not trimmed:
149
+ i += 1
150
+ continue
151
+ first = trimmed.split()[0]
152
+ if first.lower() in ("loop_", "save_") or first[:5].lower() == "data_":
153
+ break
154
+ if first[0] == "_":
155
+ break
156
+ if trimmed[0] == ";":
157
+ block, i = _read_semicolon_block(lines, i, n)
158
+ tokens.append(block)
159
+ i += 1
160
+ continue
161
+ tokens.extend(_tokenise_cif_line(trimmed))
162
+ i += 1
163
+
164
+ n_rows = len(tokens) // n_cols
165
+ data = [[tokens[r * n_cols + c] for c in range(n_cols)] for r in range(n_rows)]
166
+ return {"tags": loop_tags, "data": data}, i
167
+
168
+
169
+ def _find_col(tags: list[str], name: str) -> int:
170
+ return tags.index(name) if name in tags else -1
171
+
172
+
173
+ def _get_cell(row: list[str], col: int) -> str:
174
+ return row[col] if 0 <= col < len(row) else ""
175
+
176
+
177
+ def _extract_atom_sites(loops: list[dict[str, Any]]) -> list[dict[str, Any]]:
178
+ """Build the atom-site list from the first _atom_site_* loop."""
179
+ for lp in loops:
180
+ tags = lp["tags"]
181
+ if not tags or not any(t.startswith("_atom_site_") for t in tags):
182
+ continue
183
+ col = {
184
+ "label": _find_col(tags, "_atom_site_label"),
185
+ "symbol": _find_col(tags, "_atom_site_type_symbol"),
186
+ "x": _find_col(tags, "_atom_site_fract_x"),
187
+ "y": _find_col(tags, "_atom_site_fract_y"),
188
+ "z": _find_col(tags, "_atom_site_fract_z"),
189
+ "occupancy": _find_col(tags, "_atom_site_occupancy"),
190
+ }
191
+ sites = []
192
+ for row in lp["data"]:
193
+ sites.append({
194
+ "label": _get_cell(row, col["label"]),
195
+ "symbol": _get_cell(row, col["symbol"]),
196
+ "x": _strip_uncertainty(_get_cell(row, col["x"])),
197
+ "y": _strip_uncertainty(_get_cell(row, col["y"])),
198
+ "z": _strip_uncertainty(_get_cell(row, col["z"])),
199
+ "occupancy": _strip_uncertainty(_get_cell(row, col["occupancy"])),
200
+ })
201
+ return sites
202
+ return []
203
+
204
+
205
+ def import_cif(file_path: str | Path) -> dict[str, Any]:
206
+ """Parse a CIF file into a crystal-structure dict. Port of calc.importCIF.
207
+
208
+ Returns ``blockName``, ``tags`` (lowercased tag -> value string), ``loops``,
209
+ ``cellParams`` (a/b/c/alpha/beta/gamma, NaN if absent), ``spaceGroup``,
210
+ ``formula``, and ``atomSites`` (label/symbol/x/y/z/occupancy).
211
+ """
212
+ lines = Path(file_path).read_text(encoding="utf-8").splitlines()
213
+ n = len(lines)
214
+ result: dict[str, Any] = {
215
+ "blockName": "",
216
+ "tags": {},
217
+ "loops": [],
218
+ "cellParams": {k: float("nan") for k in ("a", "b", "c", "alpha", "beta", "gamma")},
219
+ "spaceGroup": "",
220
+ "formula": "",
221
+ "atomSites": [],
222
+ }
223
+ tags: dict[str, str] = result["tags"]
224
+
225
+ i = 0
226
+ while i < n:
227
+ raw = lines[i]
228
+ cp = _comment_pos(raw)
229
+ if cp >= 0:
230
+ raw = raw[:cp]
231
+ line = raw.strip()
232
+ if not line or line[0] == "#":
233
+ i += 1
234
+ continue
235
+ if line[:5].lower() == "data_":
236
+ result["blockName"] = line[5:].strip()
237
+ i += 1
238
+ continue
239
+ if line.lower() == "loop_":
240
+ loop_struct, i = _parse_loop(lines, i + 1, n)
241
+ result["loops"].append(loop_struct)
242
+ continue
243
+ if line[0] == "_":
244
+ tag, value, i = _parse_tag_value(lines, i, n)
245
+ tags[tag] = value
246
+ i += 1
247
+ continue
248
+ i += 1
249
+
250
+ cell_map = {
251
+ "_cell_length_a": "a", "_cell_length_b": "b", "_cell_length_c": "c",
252
+ "_cell_angle_alpha": "alpha", "_cell_angle_beta": "beta", "_cell_angle_gamma": "gamma",
253
+ }
254
+ for tag, field in cell_map.items():
255
+ if tag in tags:
256
+ result["cellParams"][field] = _strip_uncertainty(tags[tag])
257
+
258
+ for sg in ("_symmetry_space_group_name_h-m", "_space_group_name_h-m_alt"):
259
+ if sg in tags:
260
+ result["spaceGroup"] = tags[sg].strip()
261
+ break
262
+ if "_chemical_formula_sum" in tags:
263
+ result["formula"] = tags["_chemical_formula_sum"].strip()
264
+
265
+ result["atomSites"] = _extract_atom_sites(result["loops"])
266
+ return result
@@ -0,0 +1,122 @@
1
+ """Consolidated CSV export: multiple datasets side-by-side in one file. Port of
2
+ the per-dataset-block path of MATLAB ``+bosonPlotter/saveConsolidatedNeutronCSV.m``
3
+ (also the writer ``+bosonPlotter/exportCombinedCSV.m`` uses).
4
+
5
+ Each dataset contributes its own ``Q`` (X) column followed by its value columns,
6
+ each tagged with an Origin designation by role (X / Y / yEr / xEr). Columns may
7
+ differ in length; shorter ones leave trailing cells blank (not ``NaN``) so the
8
+ file imports cleanly into Origin / Excel.
9
+
10
+ Two header styles:
11
+ * ``standard`` — one row of ``<name> (<unit>)``.
12
+ * ``origin`` — four rows: Long Name / Units / File Name / Designation.
13
+
14
+ NOTE: the genuinely-polarized path (shared-Q interpolation + spin asymmetry for
15
+ >=2 distinct ++/-- cross-sections) is not ported here — that narrow neutron case
16
+ needs ++/-- polarization metadata. This covers the general per-dataset block.
17
+
18
+ Pure layer: ``consolidate_csv(datasets, fmt) -> str``. No disk I/O.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+ from quantized.datastruct import DataStruct
28
+
29
+ __all__ = ["consolidate_csv"]
30
+
31
+
32
+ class _Col:
33
+ __slots__ = ("name", "unit", "file", "desig", "data")
34
+
35
+ def __init__(
36
+ self, name: str, unit: str, file: str, desig: str, data: np.ndarray
37
+ ) -> None:
38
+ self.name = name
39
+ self.unit = unit
40
+ self.file = file
41
+ self.desig = desig
42
+ self.data = data
43
+
44
+
45
+ def _meta_get(meta: dict[str, Any], *keys: str, default: Any = None) -> Any:
46
+ sources: list[dict[str, Any]] = [meta]
47
+ for nested in ("parser_specific", "parserSpecific"):
48
+ sub = meta.get(nested)
49
+ if isinstance(sub, dict):
50
+ sources.append(sub)
51
+ for src in sources:
52
+ for key in keys:
53
+ val = src.get(key)
54
+ if val not in (None, ""):
55
+ return val
56
+ return default
57
+
58
+
59
+ def _resolve_x_unit(ds: DataStruct) -> str:
60
+ return str(_meta_get(dict(ds.metadata), "xUnit", "x_column_unit", "xColumnUnit", default=""))
61
+
62
+
63
+ def _dataset_filename(ds: DataStruct, name: str) -> str:
64
+ source = _meta_get(dict(ds.metadata), "source", "filepath", "filename", default="")
65
+ base = str(source).replace("\\", "/").rsplit("/", 1)[-1]
66
+ return base or name or "dataset"
67
+
68
+
69
+ def _column_role(label: str) -> str:
70
+ low = label.lower()
71
+ if low in ("dr", "di") or any(k in low for k in ("uncert", "err", "std", "sigma")):
72
+ return "yEr"
73
+ if "resolution" in low or "dq" in low:
74
+ return "xEr"
75
+ return "Y"
76
+
77
+
78
+ def _csv_field(text: str) -> str:
79
+ """Quote a header cell if it holds a comma, quote, or newline."""
80
+ if any(ch in text for ch in ',"\n\r'):
81
+ return '"' + text.replace('"', '""') + '"'
82
+ return text
83
+
84
+
85
+ def _columns(datasets: list[tuple[DataStruct, str]]) -> list[_Col]:
86
+ cols: list[_Col] = []
87
+ for ds, name in datasets:
88
+ file = _dataset_filename(ds, name)
89
+ time = np.asarray(ds.time, dtype=float)
90
+ values = np.asarray(ds.values, dtype=float)
91
+ cols.append(_Col("Q", _resolve_x_unit(ds), file, "X", time))
92
+ for i, label in enumerate(ds.labels):
93
+ unit = ds.units[i] if i < len(ds.units) else ""
94
+ cols.append(_Col(label, unit, file, _column_role(label), values[:, i]))
95
+ return cols
96
+
97
+
98
+ def consolidate_csv(datasets: list[tuple[DataStruct, str]], *, fmt: str = "standard") -> str:
99
+ """Combine ``datasets`` (each ``(DataStruct, name)``) into one CSV string."""
100
+ if fmt not in ("standard", "origin"):
101
+ raise ValueError("fmt must be 'standard' or 'origin'")
102
+ if not datasets:
103
+ raise ValueError("no datasets to consolidate")
104
+
105
+ cols = _columns(datasets)
106
+ lines: list[str] = []
107
+
108
+ if fmt == "origin":
109
+ lines.append(",".join(_csv_field(c.name) for c in cols))
110
+ lines.append(",".join(_csv_field(c.unit) for c in cols))
111
+ lines.append(",".join(_csv_field(c.file) for c in cols))
112
+ lines.append(",".join(_csv_field(c.desig) for c in cols))
113
+ else:
114
+ hdr = [f"{c.name} ({c.unit})" if c.unit else c.name for c in cols]
115
+ lines.append(",".join(_csv_field(h) for h in hdr))
116
+
117
+ max_rows = max((c.data.size for c in cols), default=0)
118
+ for r in range(max_rows):
119
+ cells = [f"{c.data[r]:.10g}" if r < c.data.size else "" for c in cols]
120
+ lines.append(",".join(cells))
121
+
122
+ return "\n".join(lines) + "\n"