quantized-lab 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
|
@@ -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)
|