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,178 @@
1
+ """Saved import filters: persist an :class:`~quantized.io.import_preview.ImportSettings`
2
+ under a name + glob pattern so a messy instrument file only needs the import
3
+ wizard's manual setup once (ORIGIN_GAP_PLAN #40).
4
+
5
+ Persistence lives in the SERVER user config directory (via ``platformdirs``,
6
+ MIT-licensed), never in the browser — the registry (a headless, server-side
7
+ chokepoint; see :mod:`quantized.io.registry`) must be able to see saved
8
+ filters, which rules out frontend-only storage. The directory is overridable
9
+ via the ``QZ_CONFIG_DIR`` environment variable so tests never touch the real
10
+ user config (and so a packaged app can be pointed at a portable location).
11
+
12
+ Pure ``io`` layer — no fastapi/pydantic imports; :mod:`quantized.routes.import_wizard`
13
+ is the thin adapter that exposes this as CRUD + "import with filter" endpoints.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import fnmatch
19
+ import json
20
+ import os
21
+ from dataclasses import dataclass, replace
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import platformdirs
27
+
28
+ from quantized.io.import_preview import ImportSettings
29
+
30
+ __all__ = [
31
+ "ImportFilter",
32
+ "config_dir",
33
+ "delete_filter",
34
+ "load_filters",
35
+ "match_filter",
36
+ "save_filter",
37
+ ]
38
+
39
+ _ENV_OVERRIDE = "QZ_CONFIG_DIR"
40
+ _APP_NAME = "quantized"
41
+ _FILTERS_FILENAME = "import_filters.json"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ImportFilter:
46
+ """A saved, named :class:`ImportSettings` bound to a filename glob.
47
+
48
+ ``updated`` is an ISO-8601 UTC timestamp, stamped by :func:`save_filter`;
49
+ it is the tie-break key in :func:`match_filter` (see there).
50
+ """
51
+
52
+ name: str
53
+ glob: str
54
+ settings: ImportSettings
55
+ updated: str = ""
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return {
59
+ "name": self.name,
60
+ "glob": self.glob,
61
+ "settings": self.settings.to_dict(),
62
+ "updated": self.updated,
63
+ }
64
+
65
+ @classmethod
66
+ def from_dict(cls, payload: dict[str, Any]) -> ImportFilter:
67
+ return cls(
68
+ name=str(payload.get("name", "")),
69
+ glob=str(payload.get("glob", "*")),
70
+ settings=ImportSettings.from_dict(payload.get("settings") or {}),
71
+ updated=str(payload.get("updated", "")),
72
+ )
73
+
74
+
75
+ def config_dir() -> Path:
76
+ """The user config directory quantized persists filters (and later,
77
+ plugin state) under. ``QZ_CONFIG_DIR`` overrides it (tests set this to a
78
+ ``tmp_path`` so they never touch the real user config directory).
79
+ """
80
+ override = os.environ.get(_ENV_OVERRIDE)
81
+ base = Path(override) if override else Path(
82
+ platformdirs.user_config_dir(_APP_NAME, appauthor=False)
83
+ )
84
+ base.mkdir(parents=True, exist_ok=True)
85
+ return base
86
+
87
+
88
+ def _filters_path() -> Path:
89
+ return config_dir() / _FILTERS_FILENAME
90
+
91
+
92
+ def load_filters() -> list[ImportFilter]:
93
+ """All saved filters. Missing or corrupt files -> ``[]`` (never raises);
94
+ a malformed individual entry is skipped, not fatal to the rest."""
95
+ path = _filters_path()
96
+ if not path.is_file():
97
+ return []
98
+ try:
99
+ raw = json.loads(path.read_text(encoding="utf-8"))
100
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
101
+ return []
102
+ if not isinstance(raw, list):
103
+ return []
104
+ filters: list[ImportFilter] = []
105
+ for item in raw:
106
+ if not isinstance(item, dict):
107
+ continue
108
+ try:
109
+ filters.append(ImportFilter.from_dict(item))
110
+ except (TypeError, ValueError):
111
+ continue
112
+ return filters
113
+
114
+
115
+ def _write_filters(filters: list[ImportFilter]) -> None:
116
+ payload = [f.to_dict() for f in filters]
117
+ _filters_path().write_text(json.dumps(payload, indent=2), encoding="utf-8")
118
+
119
+
120
+ def save_filter(filt: ImportFilter) -> ImportFilter:
121
+ """Upsert ``filt`` by name (case-sensitive) and persist it.
122
+
123
+ Stamps ``updated`` to now (UTC), overriding whatever was passed in, so the
124
+ tie-break in :func:`match_filter` always reflects the true save order.
125
+ """
126
+ if not filt.name.strip():
127
+ raise ValueError("filter name must not be empty")
128
+ stamped = replace(filt, updated=datetime.now(UTC).isoformat())
129
+ remaining = [f for f in load_filters() if f.name != stamped.name]
130
+ remaining.append(stamped)
131
+ _write_filters(remaining)
132
+ return stamped
133
+
134
+
135
+ def delete_filter(name: str) -> bool:
136
+ """Remove the filter named ``name``. Returns whether one was removed."""
137
+ filters = load_filters()
138
+ remaining = [f for f in filters if f.name != name]
139
+ if len(remaining) == len(filters):
140
+ return False
141
+ _write_filters(remaining)
142
+ return True
143
+
144
+
145
+ def _specificity(pattern: str) -> int:
146
+ """Rough glob specificity: count of literal (non-wildcard) characters.
147
+
148
+ ``"XYZ9000_*.dat"`` (11 literal chars) outranks ``"*.dat"`` (4) so a
149
+ narrowly-targeted saved filter wins over a broad one.
150
+ """
151
+ return sum(1 for c in pattern if c not in "*?")
152
+
153
+
154
+ def match_filter(
155
+ path: str | Path, filters: list[ImportFilter] | None = None
156
+ ) -> ImportFilter | None:
157
+ """The best saved filter for ``path``'s filename, or ``None``.
158
+
159
+ Matching is case-insensitive glob (:mod:`fnmatch`) against the filename
160
+ only (not the full path). When more than one saved filter matches:
161
+
162
+ - the most SPECIFIC glob wins (highest literal-character count, per
163
+ :func:`_specificity`) — a filter aimed at one instrument's naming
164
+ convention should beat a catch-all;
165
+ - ties in specificity are broken by the most RECENTLY saved filter
166
+ (``updated``, ISO-8601 so it sorts lexicographically) — the user's
167
+ latest edit reflects their current intent.
168
+
169
+ ``filters`` lets callers pass an already-loaded list (e.g. the registry,
170
+ to avoid re-reading the JSON file per candidate extension); defaults to
171
+ :func:`load_filters`.
172
+ """
173
+ name = Path(path).name.lower()
174
+ candidates = load_filters() if filters is None else filters
175
+ matches = [f for f in candidates if fnmatch.fnmatch(name, f.glob.lower())]
176
+ if not matches:
177
+ return None
178
+ return max(matches, key=lambda f: (_specificity(f.glob), f.updated))
@@ -0,0 +1,262 @@
1
+ """Interactive import engine: guess -> preview -> parse under explicit settings.
2
+
3
+ ORIGIN_GAP_PLAN #40 (the wizard's backend). ``import_csv`` auto-detects and
4
+ imports in one shot; the wizard instead needs to *show* the user what a file
5
+ looks like under adjustable settings and re-preview on every tweak, then parse
6
+ with the confirmed settings. This module provides that:
7
+
8
+ - :class:`ImportSettings` — a serializable description of how to read a file
9
+ (delimiter, which absolute lines are the header / units / first data row,
10
+ column-name overrides, and a per-column role: ``x`` / ``y`` / ``error`` /
11
+ ``label`` / ``ignore``). This is also the persistable "import filter" shape;
12
+ binding a saved filter to a glob and consulting it from the registry is the
13
+ remaining (design) half of #40.
14
+ - :func:`guess_settings` — a starting guess from the raw text (reusing the
15
+ ``delimited`` detectors).
16
+ - :func:`preview_import` — parse the first rows under given settings and return
17
+ a table + resolved columns for the wizard to render.
18
+ - :func:`parse_import` — parse the full text under settings into a
19
+ ``DataStruct``.
20
+
21
+ Absolute line indices (over ``text.splitlines()``, comments/blanks included)
22
+ so the wizard can number every line and let the user point at the header.
23
+ Pure ``io`` layer — no fastapi/pydantic imports.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import asdict, dataclass
30
+ from typing import Any
31
+
32
+ import numpy as np
33
+
34
+ from quantized.datastruct import DataStruct
35
+ from quantized.io.delimited import (
36
+ _detect_delimiter,
37
+ _extract_units,
38
+ _looks_like_units_row,
39
+ _numeric_score,
40
+ _to_float,
41
+ )
42
+
43
+ __all__ = [
44
+ "DATA_ROLES",
45
+ "ImportSettings",
46
+ "guess_settings",
47
+ "parse_import",
48
+ "preview_import",
49
+ ]
50
+
51
+ DATA_ROLES = ("x", "y", "error", "label", "ignore")
52
+ _CHANNEL_ROLES = ("y", "error") # numeric roles that become DataStruct channels
53
+ # friendly delimiter aliases -> how to split
54
+ _NAMED_DELIMS = {"auto": "auto", "comma": ",", "tab": "\t", "\\t": "\t",
55
+ "semicolon": ";", "pipe": "|", "space": " ", "whitespace": " "}
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ImportSettings:
60
+ """How to read a delimited file (also the persistable import-filter shape)."""
61
+
62
+ delimiter: str = "auto"
63
+ header_line: int | None = None
64
+ units_line: int | None = None
65
+ data_start_line: int = 0
66
+ column_names: list[str] | None = None
67
+ roles: list[str] | None = None
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return asdict(self)
71
+
72
+ @classmethod
73
+ def from_dict(cls, payload: dict[str, Any]) -> ImportSettings:
74
+ allowed = {f for f in cls.__dataclass_fields__}
75
+ return cls(**{k: v for k, v in payload.items() if k in allowed})
76
+
77
+
78
+ @dataclass
79
+ class _Parsed:
80
+ lines: list[str]
81
+ delim: str
82
+ names: list[str]
83
+ units: list[str]
84
+ roles: list[str]
85
+ matrix: np.ndarray # (n_rows, n_cols) float
86
+ data_start: int
87
+
88
+
89
+ def _split(line: str, delim: str) -> list[str]:
90
+ if delim in (" ", "whitespace"):
91
+ return re.split(r"\s+", line.strip())
92
+ return line.split(delim)
93
+
94
+
95
+ def _effective_ncols(rows: list[list[str]]) -> int:
96
+ """Column count ignoring trailing empty tokens (a trailing-delimiter row
97
+ like ``"1,2,"`` is 2 columns, not 3), while preserving empty *interior*
98
+ cells (``"1,,3"`` stays 3). Mirrors ``import_csv``'s trailing-column guard.
99
+ """
100
+ best = 0
101
+ for row in rows:
102
+ last = 0
103
+ for k, cell in enumerate(row):
104
+ if cell.strip():
105
+ last = k + 1
106
+ best = max(best, last)
107
+ return best
108
+
109
+
110
+ def _resolve_delim(lines: list[str], setting: str) -> str:
111
+ d = _NAMED_DELIMS.get(setting.lower(), setting)
112
+ if d != "auto":
113
+ return d
114
+ non_empty = [ln for ln in lines if ln.strip()]
115
+ return _detect_delimiter(non_empty) if non_empty else ","
116
+
117
+
118
+ def guess_settings(text: str) -> ImportSettings:
119
+ """Best-effort starting settings for ``text`` (the wizard's initial state)."""
120
+ lines = text.splitlines()
121
+ delim = _resolve_delim(lines, "auto")
122
+ tokens = [_split(ln, delim) for ln in lines]
123
+ scores = [_numeric_score(t) if ln.strip() else 0.0 for t, ln in zip(tokens, lines, strict=True)]
124
+ data_start = next((i for i, s in enumerate(scores) if s > 0.5), 0)
125
+
126
+ header_line: int | None = None
127
+ units_line: int | None = None
128
+ data_rows = [t for t in tokens[data_start:] if any(c.strip() for c in t)]
129
+ n_cols = _effective_ncols(data_rows)
130
+ # a units row just above the data, and a header above that
131
+ if data_start >= 2 and scores[data_start - 1] < 0.5 and _looks_like_units_row(
132
+ tokens[data_start - 1], n_cols
133
+ ):
134
+ units_line = data_start - 1
135
+ header_line = data_start - 2 if scores[data_start - 2] < 0.5 else None
136
+ elif data_start >= 1 and scores[data_start - 1] < 0.5:
137
+ header_line = data_start - 1
138
+
139
+ names = _resolve_names(tokens, header_line, n_cols)
140
+ roles = ["x"] + ["y"] * (n_cols - 1) if n_cols else []
141
+ return ImportSettings(
142
+ delimiter="auto", header_line=header_line, units_line=units_line,
143
+ data_start_line=data_start, column_names=names, roles=roles,
144
+ )
145
+
146
+
147
+ def _resolve_names(tokens: list[list[str]], header_line: int | None, n_cols: int) -> list[str]:
148
+ if header_line is not None and 0 <= header_line < len(tokens):
149
+ raw = [c.strip() for c in tokens[header_line]]
150
+ else:
151
+ raw = []
152
+ names = [raw[k] if k < len(raw) and raw[k] else f"Col{k + 1}" for k in range(n_cols)]
153
+ return names
154
+
155
+
156
+ def _parse_core(text: str, settings: ImportSettings) -> _Parsed:
157
+ lines = text.splitlines()
158
+ delim = _resolve_delim(lines, settings.delimiter)
159
+ tokens = [_split(ln, delim) for ln in lines]
160
+ ds = max(0, settings.data_start_line)
161
+ data_tokens = [t for t in tokens[ds:] if any(c.strip() for c in t)]
162
+ n_cols = _effective_ncols(data_tokens)
163
+ if settings.column_names:
164
+ names = [settings.column_names[k] if k < len(settings.column_names) else f"Col{k + 1}"
165
+ for k in range(n_cols)]
166
+ else:
167
+ names = _resolve_names(tokens, settings.header_line, n_cols)
168
+ # split any "Name (unit)" embedded units out of the header names
169
+ units = [""] * n_cols
170
+ for k in range(n_cols):
171
+ u, lbl = _extract_units(names[k])
172
+ names[k], units[k] = lbl, u
173
+ # an explicit units row overrides
174
+ if settings.units_line is not None and 0 <= settings.units_line < len(tokens):
175
+ urow = [c.strip().strip("()[]{}") for c in tokens[settings.units_line]]
176
+ for k in range(min(n_cols, len(urow))):
177
+ if urow[k]:
178
+ units[k] = urow[k]
179
+ roles = _resolve_roles(settings.roles, n_cols)
180
+
181
+ matrix = np.full((len(data_tokens), n_cols), np.nan, dtype=float)
182
+ for i, row in enumerate(data_tokens):
183
+ for k in range(min(len(row), n_cols)):
184
+ matrix[i, k] = _to_float(row[k])
185
+ return _Parsed(lines, delim, names, units, roles, matrix, ds)
186
+
187
+
188
+ def _resolve_roles(roles: list[str] | None, n_cols: int) -> list[str]:
189
+ if not roles:
190
+ return (["x"] + ["y"] * (n_cols - 1)) if n_cols else []
191
+ out = [roles[k] if k < len(roles) and roles[k] in DATA_ROLES else "y" for k in range(n_cols)]
192
+ return out
193
+
194
+
195
+ def preview_import(text: str, settings: ImportSettings, *, max_rows: int = 20,
196
+ max_lines: int = 60) -> dict[str, Any]:
197
+ """Parse the first ``max_rows`` under ``settings`` for the wizard to render.
198
+
199
+ Returns the raw lines (numbered, up to ``max_lines``), the resolved
200
+ delimiter, the header/units/data-start indices, one column descriptor per
201
+ column (name/unit/role/sample values), a preview row grid, and the total
202
+ data-row count.
203
+ """
204
+ p = _parse_core(text, settings)
205
+ n_rows, n_cols = p.matrix.shape
206
+ preview_rows = [
207
+ [None if np.isnan(v) else float(v) for v in p.matrix[i, :]]
208
+ for i in range(min(n_rows, max_rows))
209
+ ]
210
+ columns = [
211
+ {"index": k, "name": p.names[k], "unit": p.units[k], "role": p.roles[k]}
212
+ for k in range(n_cols)
213
+ ]
214
+ return {
215
+ "raw_lines": p.lines[:max_lines],
216
+ "n_lines": len(p.lines),
217
+ "delimiter": p.delim,
218
+ "header_line": settings.header_line,
219
+ "units_line": settings.units_line,
220
+ "data_start_line": p.data_start,
221
+ "columns": columns,
222
+ "rows": preview_rows,
223
+ "n_data_rows": int(n_rows),
224
+ "n_preview_rows": len(preview_rows),
225
+ }
226
+
227
+
228
+ def parse_import(text: str, settings: ImportSettings) -> DataStruct:
229
+ """Parse the full ``text`` under ``settings`` into a ``DataStruct``.
230
+
231
+ The ``x`` role column becomes the axis (a 1..N sample index if none is
232
+ marked); ``y`` / ``error`` columns become channels; ``label`` / ``ignore``
233
+ columns are dropped (``DataStruct`` is numeric-only).
234
+ """
235
+ p = _parse_core(text, settings)
236
+ n_rows, n_cols = p.matrix.shape
237
+ if n_cols == 0 or n_rows == 0:
238
+ raise ValueError("no data rows found under these settings")
239
+
240
+ x_cols = [k for k in range(n_cols) if p.roles[k] == "x"]
241
+ chan_cols = [k for k in range(n_cols) if p.roles[k] in _CHANNEL_ROLES]
242
+ if not chan_cols:
243
+ raise ValueError("no y/error columns selected to import")
244
+ if x_cols:
245
+ x = p.matrix[:, x_cols[0]]
246
+ x_name, x_unit = p.names[x_cols[0]], p.units[x_cols[0]]
247
+ else:
248
+ x = np.arange(1, n_rows + 1, dtype=float)
249
+ x_name, x_unit = "Sample Index", ""
250
+
251
+ labels = [p.names[k] for k in chan_cols]
252
+ units = [p.units[k] for k in chan_cols]
253
+ metadata: dict[str, Any] = {
254
+ "parser_name": "import_preview",
255
+ "x_column_name": x_name,
256
+ "x_column_unit": x_unit,
257
+ "delimiter": p.delim,
258
+ "all_column_names": p.names,
259
+ "import_settings": settings.to_dict(),
260
+ }
261
+ return DataStruct.create(x, p.matrix[:, chan_cols], labels=labels, units=units,
262
+ metadata=metadata)
quantized/io/jcamp.py ADDED
@@ -0,0 +1,179 @@
1
+ """JCAMP-DX spectroscopy parser (``.jdx`` / ``.dx``).
2
+
3
+ JCAMP-DX (Joint Committee on Atomic and Molecular Physical Data — Data
4
+ eXchange) is the manufacturer-independent text format for IR, Raman, UV-Vis,
5
+ NMR and mass spectra. A file is a list of ``##LABEL= value`` records; the
6
+ ordinate data follows an ``##XYDATA= (X++(Y..Y))`` (equally-spaced, ASDF-
7
+ compressed) or ``##XYPOINTS=``/``##PEAK TABLE= (XY..XY)`` (explicit pairs)
8
+ record.
9
+
10
+ This parser reads the first spectral block of a file (compound ``LINK`` files
11
+ note the extra blocks in metadata) and returns a single-channel DataStruct.
12
+ The ASDF ordinate decoding (SQZ/DIF/DUP) lives in :mod:`_jcamp_asdf`.
13
+
14
+ Correctness is self-checking: the decoded ordinate count must equal
15
+ ``##NPOINTS`` and the first ordinate must equal ``##FIRSTY`` (JCAMP's built-in
16
+ integrity checks), plus DIF lines carry per-line Y-value checks.
17
+
18
+ Sample corpus: ``nzhagen/jcamp`` (MIT) IR spectra + the R. J. Lancashire
19
+ compression-form test suite.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+
29
+ from quantized.datastruct import DataStruct
30
+ from quantized.io._jcamp_asdf import decode_xydata
31
+
32
+ __all__ = ["import_jcamp", "is_jcamp"]
33
+
34
+ _DATA_LABELS = {"XYDATA", "XYPOINTS", "PEAKTABLE"}
35
+
36
+
37
+ def _norm_label(label: str) -> str:
38
+ """JCAMP label equivalence: case- and separator-insensitive."""
39
+ return label.upper().replace(" ", "").replace("-", "").replace("_", "").replace("/", "")
40
+
41
+
42
+ def _strip_comment(line: str) -> str:
43
+ i = line.find("$$")
44
+ return line[:i] if i >= 0 else line
45
+
46
+
47
+ def is_jcamp(path: Path) -> bool:
48
+ """Sniff a file as JCAMP-DX: the first data record must be ``##TITLE=``."""
49
+ try:
50
+ with Path(path).open("r", encoding="latin-1") as fh:
51
+ for _ in range(20):
52
+ line = fh.readline()
53
+ if not line:
54
+ break
55
+ s = line.strip()
56
+ if s.startswith("##"):
57
+ return _norm_label(s[2:].split("=", 1)[0]) == "TITLE"
58
+ except OSError:
59
+ return False
60
+ return False
61
+
62
+
63
+ def _parse_records(text: str) -> tuple[dict[str, str], list[str], str, int]:
64
+ """Split into (header LDRs, data lines, data-kind, extra block count).
65
+
66
+ Reads the *first* data block; counts how many additional ``##TITLE`` blocks
67
+ follow (compound/LINK files).
68
+ """
69
+ header: dict[str, str] = {}
70
+ data_lines: list[str] = []
71
+ data_kind = ""
72
+ in_data = False
73
+ title_count = 0
74
+
75
+ for raw_line in text.splitlines():
76
+ line = _strip_comment(raw_line)
77
+ stripped = line.strip()
78
+ if stripped.startswith("##"):
79
+ in_data = False
80
+ label, _, value = stripped[2:].partition("=")
81
+ nlabel = _norm_label(label)
82
+ if nlabel == "TITLE":
83
+ title_count += 1
84
+ if title_count > 1:
85
+ continue # a later block; header already captured
86
+ if nlabel in _DATA_LABELS and not data_kind:
87
+ data_kind = nlabel
88
+ in_data = True
89
+ elif nlabel == "END":
90
+ in_data = False
91
+ else:
92
+ header.setdefault(nlabel, value.strip())
93
+ elif in_data and stripped:
94
+ data_lines.append(stripped)
95
+
96
+ return header, data_lines, data_kind, max(0, title_count - 1)
97
+
98
+
99
+ def _num(header: dict[str, str], key: str, default: float) -> float:
100
+ try:
101
+ return float(header[key])
102
+ except (KeyError, ValueError):
103
+ return default
104
+
105
+
106
+ def _decode_pairs(data_lines: list[str], xfactor: float, yfactor: float) -> tuple[
107
+ np.ndarray, np.ndarray
108
+ ]:
109
+ """Decode ``(XY..XY)`` explicit pairs (XYPOINTS / PEAK TABLE)."""
110
+ nums: list[float] = []
111
+ for line in data_lines:
112
+ for tok in line.replace(";", " ").replace(",", " ").split():
113
+ try:
114
+ nums.append(float(tok))
115
+ except ValueError:
116
+ continue
117
+ if len(nums) % 2:
118
+ nums = nums[:-1]
119
+ arr = np.asarray(nums, dtype=float).reshape(-1, 2)
120
+ return arr[:, 0] * xfactor, arr[:, 1] * yfactor
121
+
122
+
123
+ def import_jcamp(filepath: str | Path) -> DataStruct:
124
+ """Import a JCAMP-DX ``.jdx``/``.dx`` spectrum (one channel).
125
+
126
+ Returns
127
+ -------
128
+ DataStruct
129
+ ``time`` = abscissa (wavenumber / wavelength / m/z, per ``##XUNITS``),
130
+ one ordinate channel (``##YUNITS``).
131
+ """
132
+ path = Path(filepath)
133
+ text = path.read_text(encoding="latin-1")
134
+ header, data_lines, data_kind, extra_blocks = _parse_records(text)
135
+ if not data_kind or not data_lines:
136
+ raise ValueError(f"no XYDATA/XYPOINTS/PEAK TABLE block found: {path.name}")
137
+
138
+ xfactor = _num(header, "XFACTOR", 1.0)
139
+ yfactor = _num(header, "YFACTOR", 1.0)
140
+
141
+ if data_kind == "XYDATA":
142
+ raw_y = np.asarray(decode_xydata(data_lines), dtype=float)
143
+ y = raw_y * yfactor
144
+ npoints = int(_num(header, "NPOINTS", len(y)))
145
+ if len(y) != npoints:
146
+ raise ValueError(
147
+ f"decoded {len(y)} ordinates but ##NPOINTS={npoints}: {path.name}"
148
+ )
149
+ firstx = _num(header, "FIRSTX", 0.0)
150
+ lastx = _num(header, "LASTX", float(len(y) - 1))
151
+ x = np.linspace(firstx, lastx, len(y)) if len(y) > 1 else np.asarray([firstx])
152
+ firsty = _num(header, "FIRSTY", y[0] if len(y) else 0.0)
153
+ # FIRSTY is an optional integrity check; 0 is a common "not set"
154
+ # placeholder, so only enforce it when a real value is given.
155
+ if len(y) and firsty != 0.0 and abs(y[0] - firsty) > 1e-3 * (abs(firsty) + 1.0):
156
+ raise ValueError(
157
+ f"first ordinate {y[0]:.6g} != ##FIRSTY={firsty:.6g}: {path.name}"
158
+ )
159
+ else: # XYPOINTS / PEAK TABLE
160
+ x, y = _decode_pairs(data_lines, xfactor, yfactor)
161
+ if not len(x):
162
+ raise ValueError(f"no data points in {data_kind} block: {path.name}")
163
+
164
+ xunits = header.get("XUNITS", "")
165
+ yunits = header.get("YUNITS", "") or "Intensity"
166
+ metadata: dict[str, Any] = {
167
+ "source": str(path),
168
+ "parser_name": "import_jcamp",
169
+ "title": header.get("TITLE", path.stem),
170
+ "data_type": header.get("DATATYPE", ""),
171
+ "jcamp_version": header.get("JCAMPDX", ""),
172
+ "data_form": data_kind,
173
+ "x_column_name": header.get("DATATYPE", "") or "X",
174
+ "x_column_unit": xunits,
175
+ "num_points": int(len(y)),
176
+ }
177
+ if extra_blocks:
178
+ metadata["extra_blocks"] = extra_blocks # compound/LINK file
179
+ return DataStruct.create(x, y, labels=[yunits.title()], units=[""], metadata=metadata)