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,163 @@
|
|
|
1
|
+
"""Lake Shore VSM ``.csv``/``.dat`` parser. Port of MATLAB parser.importLakeShore.
|
|
2
|
+
|
|
3
|
+
Auto-detects the column-header row (first comma line with >50% non-numeric
|
|
4
|
+
fields), then defaults to Temperature (x) vs Moment (y).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from quantized.datastruct import DataStruct
|
|
16
|
+
from quantized.io.base import NO_COLUMN, parse_col_header, resolve_column
|
|
17
|
+
|
|
18
|
+
__all__ = ["import_lake_shore", "is_lakeshore_file"]
|
|
19
|
+
|
|
20
|
+
_LS_SHORTHAND: dict[str, str] = {
|
|
21
|
+
"temp": "Temperature",
|
|
22
|
+
"temperature": "Temperature",
|
|
23
|
+
"field": "Magnetic Field",
|
|
24
|
+
"appliedfield": "Magnetic Field",
|
|
25
|
+
"moment": "Moment",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_nan_token(token: str) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
float(token)
|
|
32
|
+
return False
|
|
33
|
+
except ValueError:
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _to_float(token: str) -> float:
|
|
38
|
+
token = token.strip()
|
|
39
|
+
if not token:
|
|
40
|
+
return float("nan")
|
|
41
|
+
try:
|
|
42
|
+
return float(token)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return float("nan")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _detect_header_row(lines: Sequence[str]) -> int:
|
|
48
|
+
"""First line (of the first 100) with a comma and >50% non-numeric fields."""
|
|
49
|
+
for i, raw in enumerate(lines[:100]):
|
|
50
|
+
line = raw.strip()
|
|
51
|
+
if not line or "," not in line:
|
|
52
|
+
continue
|
|
53
|
+
parts = [p.strip() for p in line.split(",")]
|
|
54
|
+
if len(parts) < 2:
|
|
55
|
+
continue
|
|
56
|
+
text_count = sum(1 for p in parts if _is_nan_token(p))
|
|
57
|
+
if text_count / len(parts) > 0.5:
|
|
58
|
+
return i
|
|
59
|
+
return -1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def import_lake_shore(
|
|
63
|
+
filepath: str | Path,
|
|
64
|
+
*,
|
|
65
|
+
x_axis: str | int = "temp",
|
|
66
|
+
y_axis: str | int | Sequence[str | int] = "moment",
|
|
67
|
+
) -> DataStruct:
|
|
68
|
+
"""Import a Lake Shore VSM file (Temperature vs Moment by default)."""
|
|
69
|
+
path = Path(filepath)
|
|
70
|
+
lines = path.read_text(encoding="latin-1").splitlines()
|
|
71
|
+
header_idx = _detect_header_row(lines)
|
|
72
|
+
if header_idx < 0:
|
|
73
|
+
raise ValueError(f"could not detect column-header row in {path.name}")
|
|
74
|
+
|
|
75
|
+
raw_cols = [c.strip() for c in lines[header_idx].split(",")]
|
|
76
|
+
n_cols = len(raw_cols)
|
|
77
|
+
col_names: list[str] = []
|
|
78
|
+
col_units: list[str] = []
|
|
79
|
+
for cell in raw_cols:
|
|
80
|
+
name, unit = parse_col_header(cell)
|
|
81
|
+
col_names.append(name)
|
|
82
|
+
col_units.append(unit)
|
|
83
|
+
|
|
84
|
+
rows: list[list[float]] = []
|
|
85
|
+
for raw in lines[header_idx + 1 :]:
|
|
86
|
+
if not raw.strip():
|
|
87
|
+
continue # drop blank rows only (MATLAB parity)
|
|
88
|
+
parts = raw.split(",")
|
|
89
|
+
row = [float("nan")] * n_cols
|
|
90
|
+
for c in range(min(len(parts), n_cols)):
|
|
91
|
+
row[c] = _to_float(parts[c])
|
|
92
|
+
rows.append(row)
|
|
93
|
+
if not rows:
|
|
94
|
+
raise ValueError(f"no data rows in {path.name}")
|
|
95
|
+
matrix = np.asarray(rows, dtype=float)
|
|
96
|
+
|
|
97
|
+
x_idx = resolve_column(x_axis, col_names, _LS_SHORTHAND, "x-axis")
|
|
98
|
+
if x_idx == NO_COLUMN:
|
|
99
|
+
raise ValueError("x-axis column could not be resolved")
|
|
100
|
+
if isinstance(y_axis, str) and y_axis.lower() == "all":
|
|
101
|
+
y_idx = [c for c in range(n_cols) if c != x_idx]
|
|
102
|
+
else:
|
|
103
|
+
specs: list[str | int] = [y_axis] if isinstance(y_axis, (str, int)) else list(y_axis)
|
|
104
|
+
# resolve_column raises KeyError for an unresolvable name and returns
|
|
105
|
+
# NO_COLUMN (-1) for empty specs - the latter would silently index the
|
|
106
|
+
# LAST column as the signal (latent while the parser was unregistered;
|
|
107
|
+
# reachable via import_auto since MAIN #7). Normalize both to a clean
|
|
108
|
+
# ValueError like every other unresolved-column path.
|
|
109
|
+
y_idx = []
|
|
110
|
+
unresolved: list[str] = []
|
|
111
|
+
for spec in specs:
|
|
112
|
+
try:
|
|
113
|
+
idx = resolve_column(spec, col_names, _LS_SHORTHAND, "y-axis")
|
|
114
|
+
except KeyError:
|
|
115
|
+
unresolved.append(str(spec))
|
|
116
|
+
continue
|
|
117
|
+
if idx == NO_COLUMN:
|
|
118
|
+
unresolved.append(str(spec))
|
|
119
|
+
else:
|
|
120
|
+
y_idx.append(idx)
|
|
121
|
+
if unresolved:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"y-axis column(s) could not be resolved: " + ", ".join(unresolved)
|
|
124
|
+
)
|
|
125
|
+
if not y_idx:
|
|
126
|
+
raise ValueError("no y-axis columns resolved")
|
|
127
|
+
|
|
128
|
+
metadata: dict[str, Any] = {
|
|
129
|
+
"source": str(path),
|
|
130
|
+
"parser_name": "import_lake_shore",
|
|
131
|
+
"x_column_name": col_names[x_idx],
|
|
132
|
+
"x_column_unit": col_units[x_idx],
|
|
133
|
+
"all_column_names": col_names,
|
|
134
|
+
"all_column_units": col_units,
|
|
135
|
+
}
|
|
136
|
+
return DataStruct.create(
|
|
137
|
+
matrix[:, x_idx],
|
|
138
|
+
matrix[:, y_idx],
|
|
139
|
+
labels=[col_names[i] for i in y_idx],
|
|
140
|
+
units=[col_units[i] for i in y_idx],
|
|
141
|
+
metadata=metadata,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_lakeshore_file(path: Path) -> bool:
|
|
146
|
+
"""Content sniffer for ambiguous ``.csv``/``.dat``: True for a Lake Shore
|
|
147
|
+
VSM export — the instrument writes a "Lake Shore" preamble line above the
|
|
148
|
+
column-header row (see the module docstring). Tight on purpose: a generic
|
|
149
|
+
CSV only matches if it self-identifies as Lake Shore in its first 2 KB.
|
|
150
|
+
A sniffer must never raise; unreadable -> not Lake Shore."""
|
|
151
|
+
try:
|
|
152
|
+
text = Path(path).read_text(encoding="latin-1", errors="replace")[:2048]
|
|
153
|
+
except Exception: # noqa: BLE001
|
|
154
|
+
return False
|
|
155
|
+
lower = text.lower()
|
|
156
|
+
# Vendor marker must sit in the PREAMBLE (the instrument titles the file),
|
|
157
|
+
# not merely appear anywhere - a generic CSV mentioning "Lake Shore" in a
|
|
158
|
+
# comment or data column must NOT reroute away from import_csv. And the
|
|
159
|
+
# header region must actually carry a resolvable instrument column.
|
|
160
|
+
head_lines = [ln.strip().lower() for ln in lower.splitlines()[:3]]
|
|
161
|
+
if not any("lake shore" in ln for ln in head_lines):
|
|
162
|
+
return False
|
|
163
|
+
return any(col in lower for col in ("temperature", "moment", "magnetic field"))
|
quantized/io/ncnr.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""NCNR reductus reflectometry parser (.refl). Port of parser.importNCNRRefl.
|
|
2
|
+
|
|
3
|
+
Format: ``#``-prefixed JSON-ish header (name / polarization / wavelength /
|
|
4
|
+
columns / units) then whitespace-delimited numeric rows. time = Qz (column 0),
|
|
5
|
+
values = the remaining columns (Intensity, uncertainty, resolution). Rows with
|
|
6
|
+
any non-numeric token are dropped (matches MATLAB's any-NaN filter).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from quantized.datastruct import DataStruct
|
|
19
|
+
|
|
20
|
+
__all__ = ["import_ncnr_dat", "import_ncnr_pnr", "import_ncnr_refl", "is_ncnr_refl"]
|
|
21
|
+
|
|
22
|
+
_HEADER_RE = re.compile(r'#\s*"([^"]+)":\s*(.+)$')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _loads(text: str) -> Any:
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(text)
|
|
28
|
+
except (json.JSONDecodeError, ValueError):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_ncnr_refl(path: str | Path) -> bool:
|
|
33
|
+
"""Sniff a ``.refl`` as NCNR reductus output: its ``#``-comment header carries
|
|
34
|
+
a JSON ``"columns": [...]`` line. A refl1d-exported ``.refl`` instead uses a
|
|
35
|
+
plain ``Q (1/A) R dR`` column line, so it won't match — letting the registry
|
|
36
|
+
route it to the refl1d parser. Scans the header line-by-line (the reductus
|
|
37
|
+
``"template_data"`` line alone can exceed several KB, so a fixed byte slice
|
|
38
|
+
would miss the later ``"columns"`` key) and stops at the first data row."""
|
|
39
|
+
with Path(path).open(encoding="latin-1", errors="replace") as fh:
|
|
40
|
+
for raw in fh:
|
|
41
|
+
line = raw.rstrip("\n")
|
|
42
|
+
if not line.startswith("#"):
|
|
43
|
+
break # header ended without a "columns" key
|
|
44
|
+
match = _HEADER_RE.match(line)
|
|
45
|
+
if match is not None and match.group(1) == "columns":
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def import_ncnr_refl(filepath: str | Path) -> DataStruct:
|
|
51
|
+
"""Import an NCNR reductus ``.refl`` file (PBR or CANDOR)."""
|
|
52
|
+
path = Path(filepath)
|
|
53
|
+
lines = path.read_text(encoding="latin-1").splitlines()
|
|
54
|
+
|
|
55
|
+
name = ""
|
|
56
|
+
polarization = ""
|
|
57
|
+
wavelength: list[float] = []
|
|
58
|
+
columns: list[str] = []
|
|
59
|
+
units: list[str] = []
|
|
60
|
+
data_start = len(lines)
|
|
61
|
+
|
|
62
|
+
for i, line in enumerate(lines):
|
|
63
|
+
if not line.startswith("#"):
|
|
64
|
+
data_start = i
|
|
65
|
+
break
|
|
66
|
+
match = _HEADER_RE.match(line)
|
|
67
|
+
if match is None:
|
|
68
|
+
continue
|
|
69
|
+
key, valstr = match.group(1), match.group(2).strip()
|
|
70
|
+
if key == "name":
|
|
71
|
+
parsed = _loads(valstr)
|
|
72
|
+
name = parsed if isinstance(parsed, str) else valstr.strip('"')
|
|
73
|
+
elif key == "polarization":
|
|
74
|
+
parsed = _loads(valstr)
|
|
75
|
+
polarization = parsed if isinstance(parsed, str) else valstr.strip('"')
|
|
76
|
+
elif key == "wavelength":
|
|
77
|
+
parsed = _loads(valstr)
|
|
78
|
+
if isinstance(parsed, list):
|
|
79
|
+
wavelength = [float(x) for x in parsed]
|
|
80
|
+
elif isinstance(parsed, (int, float)):
|
|
81
|
+
wavelength = [float(parsed)]
|
|
82
|
+
elif key == "columns":
|
|
83
|
+
parsed = _loads(valstr)
|
|
84
|
+
if isinstance(parsed, list):
|
|
85
|
+
columns = [str(x) for x in parsed]
|
|
86
|
+
elif key == "units":
|
|
87
|
+
parsed = _loads(valstr)
|
|
88
|
+
if isinstance(parsed, list):
|
|
89
|
+
units = [str(x) for x in parsed]
|
|
90
|
+
|
|
91
|
+
if not columns:
|
|
92
|
+
raise ValueError(f"no 'columns' header found in {path.name}")
|
|
93
|
+
|
|
94
|
+
rows: list[list[float]] = []
|
|
95
|
+
for line in lines[data_start:]:
|
|
96
|
+
text = line.strip()
|
|
97
|
+
if not text:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
rows.append([float(t) for t in text.split()])
|
|
101
|
+
except ValueError:
|
|
102
|
+
continue # any non-numeric token -> drop the row (MATLAB parity)
|
|
103
|
+
if not rows:
|
|
104
|
+
raise ValueError(f"no numeric data rows in {path.name}")
|
|
105
|
+
matrix = np.asarray(rows, dtype=float)
|
|
106
|
+
|
|
107
|
+
n_cols = len(columns)
|
|
108
|
+
qz = matrix[:, 0]
|
|
109
|
+
values = matrix[:, 1:n_cols]
|
|
110
|
+
labels = columns[1:n_cols]
|
|
111
|
+
out_units = [units[j] if j < len(units) else "" for j in range(1, n_cols)]
|
|
112
|
+
|
|
113
|
+
instrument_type = "PBR (monochromatic)" if len(wavelength) == 1 else "CANDOR (polychromatic)"
|
|
114
|
+
metadata: dict[str, Any] = {
|
|
115
|
+
"source": str(path),
|
|
116
|
+
"parser_name": "import_ncnr_refl",
|
|
117
|
+
"x_column_name": "Qz",
|
|
118
|
+
"x_column_unit": units[0] if units else "1/Ang",
|
|
119
|
+
"name": name,
|
|
120
|
+
"polarization": polarization,
|
|
121
|
+
"instrument_type": instrument_type,
|
|
122
|
+
"wavelengths": wavelength,
|
|
123
|
+
}
|
|
124
|
+
return DataStruct.create(qz, values, labels=labels, units=out_units, metadata=metadata)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Polarized .pnr (tab-delimited, 2 header rows) ────────────────────────────
|
|
128
|
+
_POL_REPLACEMENTS = (
|
|
129
|
+
("++", "pp"), ("+-", "pm"), ("-+", "mp"), ("--", "mm"), ("+/-", "pm"), ("-/+", "mp"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _clean_polarization(label: str) -> str:
|
|
134
|
+
for old, new in _POL_REPLACEMENTS:
|
|
135
|
+
label = label.replace(old, new)
|
|
136
|
+
return label
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def import_ncnr_pnr(filepath: str | Path) -> DataStruct:
|
|
140
|
+
"""Import an NCNR polarized neutron reflectometry ``.pnr`` (tab-delimited)."""
|
|
141
|
+
path = Path(filepath)
|
|
142
|
+
lines = path.read_text(encoding="latin-1").splitlines()
|
|
143
|
+
if len(lines) < 3:
|
|
144
|
+
raise ValueError(f"{path.name}: too few lines for a .pnr file")
|
|
145
|
+
col_names = lines[0].strip().split("\t")
|
|
146
|
+
units = lines[1].strip().split("\t")
|
|
147
|
+
n_cols = len(col_names)
|
|
148
|
+
|
|
149
|
+
rows: list[list[float]] = []
|
|
150
|
+
for raw in lines[2:]:
|
|
151
|
+
stripped = raw.strip()
|
|
152
|
+
if not stripped:
|
|
153
|
+
continue
|
|
154
|
+
tokens = stripped.split("\t")
|
|
155
|
+
if len(tokens) < n_cols:
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
rows.append([float(t) for t in tokens[:n_cols]])
|
|
159
|
+
except ValueError:
|
|
160
|
+
continue
|
|
161
|
+
if not rows:
|
|
162
|
+
raise ValueError(f"no numeric data in {path.name}")
|
|
163
|
+
matrix = np.asarray(rows, dtype=float)
|
|
164
|
+
|
|
165
|
+
labels = [_clean_polarization(c) for c in col_names[1:]]
|
|
166
|
+
out_units = [units[j] if j < len(units) else "" for j in range(1, n_cols)]
|
|
167
|
+
|
|
168
|
+
lowered = " ".join(col_names).lower()
|
|
169
|
+
is_nsf = "r++" in lowered or "r--" in lowered
|
|
170
|
+
is_sf = "r+-" in lowered or "r-+" in lowered or "r+/-" in lowered
|
|
171
|
+
if is_nsf and is_sf:
|
|
172
|
+
variant = "combined"
|
|
173
|
+
elif is_nsf:
|
|
174
|
+
variant = "NSF"
|
|
175
|
+
elif is_sf:
|
|
176
|
+
variant = "SF"
|
|
177
|
+
else:
|
|
178
|
+
variant = "unknown"
|
|
179
|
+
|
|
180
|
+
metadata: dict[str, Any] = {
|
|
181
|
+
"source": str(path),
|
|
182
|
+
"parser_name": "import_ncnr_pnr",
|
|
183
|
+
"x_column_name": "Q",
|
|
184
|
+
"x_column_unit": units[0] if units else "1/Ang",
|
|
185
|
+
"variant": variant,
|
|
186
|
+
}
|
|
187
|
+
return DataStruct.create(
|
|
188
|
+
matrix[:, 0], matrix[:, 1:], labels=labels, units=out_units, metadata=metadata
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── refl1d-fit cross sections (.datA/.datB/.datC/.datD) ──────────────────────
|
|
193
|
+
_NCNR_POL_BY_EXT = {".datA": "++", ".datB": "+-", ".datC": "-+", ".datD": "--"}
|
|
194
|
+
_NCNR_DAT_LABELS = ["dQ", "R", "dR", "theory", "fresnel"]
|
|
195
|
+
_NCNR_DAT_UNITS = ["1/A", "", "", "", ""]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _safe_float(text: str) -> float:
|
|
199
|
+
try:
|
|
200
|
+
return float(text.strip())
|
|
201
|
+
except ValueError:
|
|
202
|
+
return float("nan")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def import_ncnr_dat(filepath: str | Path) -> DataStruct:
|
|
206
|
+
"""Import an NCNR refl1d-fit cross section (.datA/.datB/.datC/.datD)."""
|
|
207
|
+
path = Path(filepath)
|
|
208
|
+
pol = next(
|
|
209
|
+
(v for k, v in _NCNR_POL_BY_EXT.items() if k.lower() == path.suffix.lower()), None
|
|
210
|
+
)
|
|
211
|
+
if pol is None:
|
|
212
|
+
raise ValueError(f"{path.name}: expected extension .datA/.datB/.datC/.datD")
|
|
213
|
+
lines = path.read_text(encoding="latin-1").splitlines()
|
|
214
|
+
|
|
215
|
+
intensity = float("nan")
|
|
216
|
+
background = float("nan")
|
|
217
|
+
data_start = 0
|
|
218
|
+
# Scan the whole comment header (not just the first 5 lines) so intensity /
|
|
219
|
+
# background metadata is captured wherever it sits; the loop still breaks at
|
|
220
|
+
# the column header or first data row.
|
|
221
|
+
for i, line in enumerate(lines):
|
|
222
|
+
if line.startswith("# intensity:"):
|
|
223
|
+
intensity = _safe_float(line.split(":", 1)[1])
|
|
224
|
+
elif line.startswith("# background:"):
|
|
225
|
+
background = _safe_float(line.split(":", 1)[1])
|
|
226
|
+
elif line.startswith("#") and "Q (1/A)" in line:
|
|
227
|
+
data_start = i + 1
|
|
228
|
+
break
|
|
229
|
+
elif not line.startswith("#"):
|
|
230
|
+
data_start = i
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
rows: list[list[float]] = []
|
|
234
|
+
for line in lines[data_start:]:
|
|
235
|
+
stripped = line.strip()
|
|
236
|
+
if not stripped or stripped.startswith("#"):
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
rows.append([float(t) for t in stripped.split()])
|
|
240
|
+
except ValueError:
|
|
241
|
+
continue
|
|
242
|
+
if not rows:
|
|
243
|
+
raise ValueError(f"no numeric data in {path.name}")
|
|
244
|
+
width = len(rows[0])
|
|
245
|
+
matrix = np.asarray([r for r in rows if len(r) == width], dtype=float)
|
|
246
|
+
|
|
247
|
+
n_val = matrix.shape[1] - 1
|
|
248
|
+
labels: list[str] = []
|
|
249
|
+
units: list[str] = []
|
|
250
|
+
for j in range(n_val):
|
|
251
|
+
labels.append(_NCNR_DAT_LABELS[j] if j < len(_NCNR_DAT_LABELS) else f"col{j + 1}")
|
|
252
|
+
units.append(_NCNR_DAT_UNITS[j] if j < len(_NCNR_DAT_UNITS) else "")
|
|
253
|
+
|
|
254
|
+
metadata: dict[str, Any] = {
|
|
255
|
+
"source": str(path),
|
|
256
|
+
"parser_name": "import_ncnr_dat",
|
|
257
|
+
"x_column_name": "Q",
|
|
258
|
+
"x_column_unit": "1/Ang",
|
|
259
|
+
"polarization": pol,
|
|
260
|
+
}
|
|
261
|
+
if not np.isnan(intensity):
|
|
262
|
+
metadata["intensity"] = intensity
|
|
263
|
+
if not np.isnan(background):
|
|
264
|
+
metadata["background"] = background
|
|
265
|
+
# Plot hints (honoured by the frontend defaults): show the measured
|
|
266
|
+
# reflectivity R and the fit line 'theory' by default, with dR as R's error
|
|
267
|
+
# bars; the resolution 'dQ' and normalisation 'fresnel' columns stay off the
|
|
268
|
+
# plot by default (still toggleable in the Channels card). Keyed by value-
|
|
269
|
+
# column index (0-based, x/Q excluded).
|
|
270
|
+
label_idx = {lab: j for j, lab in enumerate(labels)}
|
|
271
|
+
default_channels = [label_idx[name] for name in ("R", "theory") if name in label_idx]
|
|
272
|
+
if default_channels:
|
|
273
|
+
metadata["default_value_channels"] = default_channels
|
|
274
|
+
if "R" in label_idx and "dR" in label_idx:
|
|
275
|
+
metadata["error_channels"] = {label_idx["R"]: label_idx["dR"]}
|
|
276
|
+
return DataStruct.create(
|
|
277
|
+
matrix[:, 0], matrix[:, 1:], labels=labels, units=units, metadata=metadata
|
|
278
|
+
)
|
quantized/io/netcdf.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""NetCDF import (``.nc`` / ``.cdf``) — chromatography (ANDI/AIA) + generic.
|
|
2
|
+
|
|
3
|
+
NetCDF is a self-describing container, so this parser has two layers:
|
|
4
|
+
|
|
5
|
+
* a **reader** that normalizes both on-disk formats to a common view —
|
|
6
|
+
NetCDF-3 "classic"/64-bit (magic ``CDF\\x01``/``\\x02``/``\\x05``) via
|
|
7
|
+
``scipy.io.netcdf_file``, and NetCDF-4 (HDF5-backed, magic ``\\x89HDF``) via
|
|
8
|
+
``h5py``. Both are existing quantized deps (no new dependency);
|
|
9
|
+
* an **interpreter** that recognizes the ANDI/AIA Chromatography convention
|
|
10
|
+
(``total_intensity`` vs ``scan_acquisition_time`` = a TIC; or
|
|
11
|
+
``ordinate_values`` + sampling interval = a single-channel trace) and
|
|
12
|
+
otherwise falls back to a generic "pick the monotonic coordinate as x, the
|
|
13
|
+
rest as channels" heuristic.
|
|
14
|
+
|
|
15
|
+
Pure layer: ndarray/DataStruct in -> out. No fastapi/pydantic imports.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from quantized.datastruct import DataStruct
|
|
27
|
+
|
|
28
|
+
__all__ = ["import_netcdf", "is_netcdf"]
|
|
29
|
+
|
|
30
|
+
_CDF_MAGICS = (b"CDF\x01", b"CDF\x02", b"CDF\x05")
|
|
31
|
+
_HDF5_MAGIC = b"\x89HDF"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _Var:
|
|
36
|
+
data: np.ndarray
|
|
37
|
+
units: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class _NcData:
|
|
42
|
+
variables: dict[str, _Var]
|
|
43
|
+
attrs: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_netcdf(path: Path) -> bool:
|
|
47
|
+
"""Sniff a file as NetCDF-3 (``CDF``) or NetCDF-4/HDF5 (``\\x89HDF``)."""
|
|
48
|
+
with Path(path).open("rb") as fh:
|
|
49
|
+
head = fh.read(4)
|
|
50
|
+
return head in _CDF_MAGICS or head == _HDF5_MAGIC
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_str(value: Any) -> str:
|
|
54
|
+
if isinstance(value, bytes):
|
|
55
|
+
return value.decode("latin-1", "replace").strip()
|
|
56
|
+
return str(value).strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_netcdf3(path: Path) -> _NcData:
|
|
60
|
+
from scipy.io import netcdf_file # noqa: PLC0415
|
|
61
|
+
|
|
62
|
+
f = netcdf_file(str(path), "r", mmap=False)
|
|
63
|
+
try:
|
|
64
|
+
variables = {
|
|
65
|
+
name: _Var(np.asarray(var[:], dtype=float) if var.data.dtype.kind in "fiu"
|
|
66
|
+
else np.asarray(var[:]),
|
|
67
|
+
_as_str(getattr(var, "units", b"")))
|
|
68
|
+
for name, var in f.variables.items()
|
|
69
|
+
}
|
|
70
|
+
attrs = {k: _as_str(v) if isinstance(v, bytes) else v
|
|
71
|
+
for k, v in f._attributes.items()} # noqa: SLF001 (scipy's public-in-practice map)
|
|
72
|
+
finally:
|
|
73
|
+
f.close()
|
|
74
|
+
return _NcData(variables, attrs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_netcdf4(path: Path) -> _NcData:
|
|
78
|
+
import h5py # noqa: PLC0415
|
|
79
|
+
|
|
80
|
+
variables: dict[str, _Var] = {}
|
|
81
|
+
with h5py.File(path, "r") as h:
|
|
82
|
+
def _collect(name: str, obj: Any) -> None:
|
|
83
|
+
if isinstance(obj, h5py.Dataset) and obj.ndim <= 1:
|
|
84
|
+
key = name.rsplit("/", 1)[-1]
|
|
85
|
+
variables[key] = _Var(np.asarray(obj[()]), _as_str(obj.attrs.get("units", "")))
|
|
86
|
+
|
|
87
|
+
h.visititems(_collect)
|
|
88
|
+
attrs = {k: _as_str(v) if isinstance(v, bytes) else v for k, v in h.attrs.items()}
|
|
89
|
+
return _NcData(variables, attrs)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _numeric_1d(nc: _NcData) -> dict[str, _Var]:
|
|
93
|
+
return {
|
|
94
|
+
n: v for n, v in nc.variables.items()
|
|
95
|
+
if v.data.ndim == 1 and v.data.size > 1 and v.data.dtype.kind in "fiu"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_monotonic(a: np.ndarray) -> bool:
|
|
100
|
+
d = np.diff(a)
|
|
101
|
+
return bool(np.all(d > 0) or np.all(d < 0))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _andi_axes(
|
|
105
|
+
nc: _NcData,
|
|
106
|
+
) -> tuple[np.ndarray, np.ndarray, str, str, str, str] | None:
|
|
107
|
+
"""Return (x, y, x_name, x_unit, y_label, y_unit) for ANDI/AIA, else None."""
|
|
108
|
+
v = nc.variables
|
|
109
|
+
if "total_intensity" in v and "scan_acquisition_time" in v:
|
|
110
|
+
t, tic = v["scan_acquisition_time"], v["total_intensity"]
|
|
111
|
+
return (np.asarray(t.data, float), np.asarray(tic.data, float),
|
|
112
|
+
"Retention Time", t.units or "seconds", "Total Intensity",
|
|
113
|
+
tic.units or "counts")
|
|
114
|
+
if "ordinate_values" in v:
|
|
115
|
+
y = np.asarray(v["ordinate_values"].data, float)
|
|
116
|
+
interval = _attr_float(nc, "actual_sampling_interval")
|
|
117
|
+
delay = _attr_float(nc, "actual_delay_time", 0.0) or 0.0
|
|
118
|
+
x = delay + np.arange(y.size) * interval if interval is not None \
|
|
119
|
+
else np.arange(y.size, dtype=float)
|
|
120
|
+
return x, y, "Retention Time", "seconds", "Signal", v["ordinate_values"].units or ""
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _attr_float(nc: _NcData, key: str, default: float | None = None) -> float | None:
|
|
125
|
+
if key in nc.attrs:
|
|
126
|
+
try:
|
|
127
|
+
return float(nc.attrs[key])
|
|
128
|
+
except (TypeError, ValueError):
|
|
129
|
+
return default
|
|
130
|
+
if key in nc.variables and nc.variables[key].data.size == 1:
|
|
131
|
+
return float(np.asarray(nc.variables[key].data).ravel()[0])
|
|
132
|
+
return default
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def import_netcdf(filepath: str | Path) -> DataStruct:
|
|
136
|
+
"""Import a NetCDF ``.nc``/``.cdf`` into a DataStruct.
|
|
137
|
+
|
|
138
|
+
ANDI/AIA chromatography files yield the total-ion-current (or single
|
|
139
|
+
detector) trace vs retention time; generic files use the monotonic
|
|
140
|
+
coordinate variable as x and the remaining 1-D numeric variables as
|
|
141
|
+
channels.
|
|
142
|
+
"""
|
|
143
|
+
path = Path(filepath)
|
|
144
|
+
with path.open("rb") as fh:
|
|
145
|
+
magic = fh.read(4)
|
|
146
|
+
if magic in _CDF_MAGICS:
|
|
147
|
+
nc = _read_netcdf3(path)
|
|
148
|
+
elif magic == _HDF5_MAGIC:
|
|
149
|
+
nc = _read_netcdf4(path)
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError(f"not a NetCDF/HDF5 file (magic {magic!r}): {path.name}")
|
|
152
|
+
|
|
153
|
+
andi = _andi_axes(nc)
|
|
154
|
+
if andi is not None:
|
|
155
|
+
x, y, x_name, x_unit, y_label, y_unit = andi
|
|
156
|
+
return _build(path, x, y[:, None], [y_label], [y_unit], x_name, x_unit, nc, "ANDI")
|
|
157
|
+
|
|
158
|
+
numeric = _numeric_1d(nc)
|
|
159
|
+
if not numeric:
|
|
160
|
+
raise ValueError(f"no 1-D numeric variables to import: {path.name}")
|
|
161
|
+
|
|
162
|
+
names = list(numeric)
|
|
163
|
+
x_key = next((n for n in names if _is_monotonic(numeric[n].data)), names[0])
|
|
164
|
+
x = np.asarray(numeric[x_key].data, float)
|
|
165
|
+
channels = [n for n in names if n != x_key and numeric[n].data.size == x.size]
|
|
166
|
+
if not channels: # a single variable: index it
|
|
167
|
+
return _build(path, np.arange(x.size, dtype=float), x[:, None],
|
|
168
|
+
[_titlecase(x_key)], [numeric[x_key].units], "Index", "", nc, "generic")
|
|
169
|
+
|
|
170
|
+
values = np.column_stack([numeric[n].data for n in channels])
|
|
171
|
+
return _build(path, x, values, [_titlecase(n) for n in channels],
|
|
172
|
+
[numeric[n].units for n in channels], _titlecase(x_key),
|
|
173
|
+
numeric[x_key].units, nc, "generic")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _titlecase(name: str) -> str:
|
|
177
|
+
return name.replace("_", " ").title()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build(
|
|
181
|
+
path: Path, x: np.ndarray, values: np.ndarray, labels: list[str], units: list[str],
|
|
182
|
+
x_name: str, x_unit: str, nc: _NcData, kind: str,
|
|
183
|
+
) -> DataStruct:
|
|
184
|
+
metadata: dict[str, Any] = {
|
|
185
|
+
"source": str(path),
|
|
186
|
+
"parser_name": "import_netcdf",
|
|
187
|
+
"netcdf_kind": kind,
|
|
188
|
+
"x_column_name": x_name,
|
|
189
|
+
"x_column_unit": x_unit,
|
|
190
|
+
"num_points": int(x.size),
|
|
191
|
+
}
|
|
192
|
+
for k in ("aia_template_revision", "netcdf_revision", "experiment_type", "title"):
|
|
193
|
+
if k in nc.attrs:
|
|
194
|
+
metadata[k] = nc.attrs[k]
|
|
195
|
+
return DataStruct.create(x, values, labels=labels, units=units, metadata=metadata)
|