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
quantized/io/registry.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Single parser registry: extension map + content sniffers for ambiguous types.
|
|
2
|
+
|
|
3
|
+
One place to register a parser (no MATLAB-style dual registration). Ambiguous
|
|
4
|
+
extensions (``.dat``) resolve by sniffing file content.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from quantized.datastruct import DataStruct
|
|
13
|
+
from quantized.io.bruker_brml import import_bruker_brml
|
|
14
|
+
from quantized.io.bruker_raw import import_bruker_raw, is_bruker_raw
|
|
15
|
+
from quantized.io.delimited import import_csv
|
|
16
|
+
from quantized.io.excel import import_excel
|
|
17
|
+
from quantized.io.import_filters import match_filter
|
|
18
|
+
from quantized.io.import_preview import parse_import
|
|
19
|
+
from quantized.io.jcamp import import_jcamp
|
|
20
|
+
from quantized.io.lakeshore import import_lake_shore, is_lakeshore_file
|
|
21
|
+
from quantized.io.ncnr import import_ncnr_dat, import_ncnr_pnr, import_ncnr_refl, is_ncnr_refl
|
|
22
|
+
from quantized.io.netcdf import import_netcdf
|
|
23
|
+
from quantized.io.opus import import_opus
|
|
24
|
+
from quantized.io.origin_project import read_origin_project
|
|
25
|
+
from quantized.io.qd import import_ppms, import_qd_vsm, is_ppms_dat, is_qd_file
|
|
26
|
+
from quantized.io.refl1d import import_refl1d_dat, is_refl1d_dat
|
|
27
|
+
from quantized.io.rigaku import import_rigaku_raw, is_rigaku_raw
|
|
28
|
+
from quantized.io.sims import import_sims, is_sims_file
|
|
29
|
+
from quantized.io.spc import import_spc
|
|
30
|
+
from quantized.io.xrdml import import_xrdml
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"import_auto",
|
|
34
|
+
"register_parser",
|
|
35
|
+
"resolve_parser",
|
|
36
|
+
"unregister_plugin_parsers",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
Parser = Callable[[Path], DataStruct]
|
|
40
|
+
Sniffer = Callable[[Path], bool]
|
|
41
|
+
|
|
42
|
+
# Unambiguous extensions map directly (grows as parsers land).
|
|
43
|
+
# NOTE: resolve_parser lowercases the suffix, so .datA -> '.data', etc.
|
|
44
|
+
_EXT_MAP: dict[str, Parser] = {
|
|
45
|
+
".xrdml": import_xrdml,
|
|
46
|
+
".brml": import_bruker_brml, # Bruker XRD (ZIP of XML); 1-D line scans
|
|
47
|
+
".jdx": import_jcamp, # JCAMP-DX spectroscopy (IR/Raman/UV-Vis/...)
|
|
48
|
+
".dx": import_jcamp,
|
|
49
|
+
".nc": import_netcdf, # NetCDF-3/4 (generic + ANDI/AIA chromatography)
|
|
50
|
+
".cdf": import_netcdf, # ANDI/AIA chromatography (NetCDF-3 classic)
|
|
51
|
+
".pnr": import_ncnr_pnr,
|
|
52
|
+
# Origin project files — clean-room reader (no GPL liborigin). Currently
|
|
53
|
+
# recognizes + guides; the binary decoders land against sample files.
|
|
54
|
+
".opj": read_origin_project, # Origin ≤2017 binary project
|
|
55
|
+
".opju": read_origin_project, # Origin 2018+ Unicode project
|
|
56
|
+
".data": import_ncnr_dat, # .datA
|
|
57
|
+
".datb": import_ncnr_dat, # .datB
|
|
58
|
+
".datc": import_ncnr_dat, # .datC
|
|
59
|
+
".datd": import_ncnr_dat, # .datD
|
|
60
|
+
# importSPC.m / importOxford.m / importOpus.m were never written in
|
|
61
|
+
# quantized_matlab (PORT_CHECKLIST.md line 46 — "paused, awaiting example
|
|
62
|
+
# files"); .spc and .opus below are independent implementations against
|
|
63
|
+
# the published formats, not MATLAB ports (see each module's docstring).
|
|
64
|
+
# importOxford stays unported: "format varies by software version" with
|
|
65
|
+
# no spec and no example file — nothing to implement against honestly.
|
|
66
|
+
".spc": import_spc, # GRAMS/Thermo spectral binary
|
|
67
|
+
".opus": import_opus, # Bruker OPUS FTIR/NIR/Raman binary
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _accept_any(_path: Path) -> bool:
|
|
72
|
+
"""Catch-all sniffer: routes to the generic fallback parser for an extension."""
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Ambiguous extensions resolve by content sniffing — first match wins.
|
|
77
|
+
_SNIFFERS: dict[str, list[tuple[Sniffer, Parser]]] = {
|
|
78
|
+
".dat": [
|
|
79
|
+
(is_qd_file, import_qd_vsm),
|
|
80
|
+
(is_refl1d_dat, import_refl1d_dat),
|
|
81
|
+
(is_ppms_dat, import_ppms),
|
|
82
|
+
(is_lakeshore_file, import_lake_shore),
|
|
83
|
+
],
|
|
84
|
+
# .refl is reductus (JSON "columns" header) for the whole corpus, but refl1d
|
|
85
|
+
# also exports .refl (a "Q (1/A) R dR" column header below # metadata): route
|
|
86
|
+
# those to the refl1d parser. Catch-all stays reductus (the prior behaviour).
|
|
87
|
+
".refl": [
|
|
88
|
+
(is_ncnr_refl, import_ncnr_refl),
|
|
89
|
+
(is_refl1d_dat, import_refl1d_dat),
|
|
90
|
+
(_accept_any, import_ncnr_refl),
|
|
91
|
+
],
|
|
92
|
+
# .raw is either Rigaku SmartLab (magic "FI") or Bruker Diffrac-AT RAW1.01
|
|
93
|
+
# (magic "RAW1.01"); the magic bytes disambiguate with no collision.
|
|
94
|
+
".raw": [(is_rigaku_raw, import_rigaku_raw), (is_bruker_raw, import_bruker_raw)],
|
|
95
|
+
# SIMS depth profiles share .csv/.tsv/.xlsx with generic tables: sniff for the
|
|
96
|
+
# SIMS layout first, else fall back to the generic delimited / Excel parser.
|
|
97
|
+
# Lake Shore VSM self-identifies in its preamble (MAIN_PLAN #7 — the
|
|
98
|
+
# parser existed unregistered; the #52 matrix surfaced it). SIMS keeps
|
|
99
|
+
# precedence (established chain order).
|
|
100
|
+
".csv": [
|
|
101
|
+
(is_sims_file, import_sims),
|
|
102
|
+
(is_lakeshore_file, import_lake_shore),
|
|
103
|
+
(_accept_any, import_csv),
|
|
104
|
+
],
|
|
105
|
+
".tsv": [(is_sims_file, import_sims), (_accept_any, import_csv)],
|
|
106
|
+
".xlsx": [(is_sims_file, import_sims), (_accept_any, import_excel)],
|
|
107
|
+
".xlsm": [(is_sims_file, import_sims), (_accept_any, import_excel)],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _import_via_saved_filter(path: Path) -> DataStruct:
|
|
112
|
+
"""Parse ``path`` under its best-matching saved import filter.
|
|
113
|
+
|
|
114
|
+
See :mod:`quantized.io.import_filters` (gap #40): a user-saved
|
|
115
|
+
``ImportSettings`` bound to a filename glob, consulted by
|
|
116
|
+
:func:`resolve_parser` before the content sniffers below.
|
|
117
|
+
"""
|
|
118
|
+
filt = match_filter(path)
|
|
119
|
+
if filt is None: # pragma: no cover - resolve_parser only routes here on a match
|
|
120
|
+
raise ValueError(f"no saved import filter matches '{path.name}'")
|
|
121
|
+
return parse_import(path.read_text(encoding="latin-1"), filt.settings)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Plugin registration (single-registration path; gap #8) ──────────────────
|
|
125
|
+
# Third-party plugins (see quantized.plugins) contribute parsers THROUGH this one
|
|
126
|
+
# function — the same ``_EXT_MAP`` / ``_SNIFFERS`` chokepoint the built-ins use
|
|
127
|
+
# above — so there is never a second dispatch path. Plugin registrations are
|
|
128
|
+
# tracked separately so an idempotent reload / test isolation can remove them
|
|
129
|
+
# WITHOUT ever touching a built-in entry.
|
|
130
|
+
_PLUGIN_EXTS: set[str] = set()
|
|
131
|
+
_PLUGIN_SNIFFERS: dict[str, list[tuple[Sniffer, Parser]]] = {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _normalize_ext(ext: str) -> str:
|
|
135
|
+
lowered = ext.lower()
|
|
136
|
+
return lowered if lowered.startswith(".") else f".{lowered}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def register_parser(
|
|
140
|
+
extensions: list[str], parser: Parser, *, sniff: Sniffer | None = None
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Register a plugin ``parser`` for one or more file ``extensions``.
|
|
143
|
+
|
|
144
|
+
Precedence discipline (identical to saved import filters): a plugin may claim
|
|
145
|
+
a NOVEL extension, but must never SHADOW a built-in one.
|
|
146
|
+
|
|
147
|
+
- ``sniff is None`` (unambiguous claim): the extension maps straight to
|
|
148
|
+
``parser``. Refused with ``ValueError`` when the extension is already known
|
|
149
|
+
— a built-in ``_EXT_MAP`` entry *or* an ambiguous ``_SNIFFERS`` extension.
|
|
150
|
+
This is the "a plugin cannot shadow ``.jdx``" rule.
|
|
151
|
+
- ``sniff`` given (content sniff): ``(sniff, parser)`` is APPENDED to the
|
|
152
|
+
extension's sniffer chain, so built-in sniffers keep precedence and a
|
|
153
|
+
plugin sniffer can only ever act as a fallback.
|
|
154
|
+
"""
|
|
155
|
+
for raw in extensions:
|
|
156
|
+
ext = _normalize_ext(raw)
|
|
157
|
+
if sniff is None:
|
|
158
|
+
if ext in _EXT_MAP or ext in _SNIFFERS:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"extension '{ext}' is already claimed by a built-in parser "
|
|
161
|
+
"(plugins may not shadow built-in extensions)"
|
|
162
|
+
)
|
|
163
|
+
_EXT_MAP[ext] = parser
|
|
164
|
+
_PLUGIN_EXTS.add(ext)
|
|
165
|
+
else:
|
|
166
|
+
_SNIFFERS.setdefault(ext, []).append((sniff, parser))
|
|
167
|
+
_PLUGIN_SNIFFERS.setdefault(ext, []).append((sniff, parser))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def unregister_plugin_parsers() -> None:
|
|
171
|
+
"""Remove every plugin-registered parser, restoring the built-in registry.
|
|
172
|
+
|
|
173
|
+
Used by :func:`quantized.plugins.load_plugins` for an idempotent reload and
|
|
174
|
+
by tests for isolation; built-in ``_EXT_MAP`` / ``_SNIFFERS`` entries are
|
|
175
|
+
never touched.
|
|
176
|
+
"""
|
|
177
|
+
for ext in _PLUGIN_EXTS:
|
|
178
|
+
_EXT_MAP.pop(ext, None)
|
|
179
|
+
_PLUGIN_EXTS.clear()
|
|
180
|
+
for ext, entries in _PLUGIN_SNIFFERS.items():
|
|
181
|
+
chain = _SNIFFERS.get(ext)
|
|
182
|
+
if chain is None:
|
|
183
|
+
continue
|
|
184
|
+
for entry in entries:
|
|
185
|
+
if entry in chain:
|
|
186
|
+
chain.remove(entry)
|
|
187
|
+
if not chain:
|
|
188
|
+
_SNIFFERS.pop(ext, None)
|
|
189
|
+
_PLUGIN_SNIFFERS.clear()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def resolve_parser(path: Path) -> Parser:
|
|
193
|
+
"""Pick the parser for ``path``: unambiguous extension, else a saved
|
|
194
|
+
import filter (gap #40 — a user-named glob -> ``ImportSettings``), else
|
|
195
|
+
content sniffing."""
|
|
196
|
+
ext = path.suffix.lower()
|
|
197
|
+
if ext in _EXT_MAP:
|
|
198
|
+
return _EXT_MAP[ext]
|
|
199
|
+
if match_filter(path) is not None:
|
|
200
|
+
return _import_via_saved_filter
|
|
201
|
+
for sniff, parser in _SNIFFERS.get(ext, []):
|
|
202
|
+
if sniff(path):
|
|
203
|
+
return parser
|
|
204
|
+
raise ValueError(f"no parser registered for '{path.name}' (extension '{ext}')")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def import_auto(path: str | Path) -> DataStruct:
|
|
208
|
+
"""Auto-detect format and import ``path`` into a DataStruct."""
|
|
209
|
+
resolved = Path(path)
|
|
210
|
+
return resolve_parser(resolved)(resolved)
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Render a :class:`~quantized.calc.report.ReportSheet` to office / markup files.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #37 (docx/pptx) + #38 (LaTeX) + #39 (HTML). Every renderer
|
|
4
|
+
walks the SAME report schema (title -> sections -> typed blocks) with no
|
|
5
|
+
per-block special cases beyond the four block kinds — that is the #36
|
|
6
|
+
acceptance criterion made real.
|
|
7
|
+
|
|
8
|
+
LaTeX and HTML are pure-Python and always available. Word (.docx) and
|
|
9
|
+
PowerPoint (.pptx) need the MIT libraries ``python-docx`` / ``python-pptx``;
|
|
10
|
+
those imports are guarded so the module (and CI) work without them — a missing
|
|
11
|
+
library raises a clear ``ReportExportError`` instead of an ImportError at
|
|
12
|
+
module load.
|
|
13
|
+
|
|
14
|
+
Pure ``io`` layer — no fastapi/pydantic imports (enforced by
|
|
15
|
+
test_repo_integrity). ``value ± error`` formatting rounds the value to the
|
|
16
|
+
precision implied by the uncertainty (2 significant figures on the error).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64 as _base64
|
|
22
|
+
import binascii as _binascii
|
|
23
|
+
import html as _html
|
|
24
|
+
import io as _io
|
|
25
|
+
import math
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
# Raster image MIME types Office can embed (SVG/other -> placeholder text).
|
|
30
|
+
_EMBEDDABLE_IMAGE_MIMES = ("image/png", "image/jpeg", "image/jpg", "image/gif", "image/bmp")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _decode_raster(image: Mapping[str, str] | None) -> bytes | None:
|
|
34
|
+
"""Return decoded image bytes iff it's an Office-embeddable raster type."""
|
|
35
|
+
if not image or image.get("mime") not in _EMBEDDABLE_IMAGE_MIMES:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return _base64.b64decode(image["data"], validate=True)
|
|
39
|
+
except (_binascii.Error, ValueError, KeyError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"FORMATS",
|
|
44
|
+
"ReportExportError",
|
|
45
|
+
"format_value_error",
|
|
46
|
+
"render_report",
|
|
47
|
+
"to_html",
|
|
48
|
+
"to_latex",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
FORMATS = ("latex", "html", "docx", "pptx")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ReportExportError(RuntimeError):
|
|
55
|
+
"""Raised for an unknown format or a missing optional export library."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── number formatting ─────────────────────────────────────────────────────
|
|
59
|
+
def _fmt_num(value: Any) -> str:
|
|
60
|
+
if value is None:
|
|
61
|
+
return ""
|
|
62
|
+
if isinstance(value, bool):
|
|
63
|
+
return str(value)
|
|
64
|
+
if isinstance(value, int):
|
|
65
|
+
return str(value)
|
|
66
|
+
if isinstance(value, float):
|
|
67
|
+
if not math.isfinite(value):
|
|
68
|
+
return ""
|
|
69
|
+
return f"{value:.6g}"
|
|
70
|
+
return str(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_value_error(value: Any, error: Any = None, *, sig: int = 2) -> str:
|
|
74
|
+
"""Format ``value ± error`` with the value rounded to the error's precision.
|
|
75
|
+
|
|
76
|
+
With no (finite, non-zero) error, falls back to a plain 6-significant-figure
|
|
77
|
+
number. ``sig`` is the number of significant figures kept on the error.
|
|
78
|
+
"""
|
|
79
|
+
if value is None:
|
|
80
|
+
return ""
|
|
81
|
+
v = float(value)
|
|
82
|
+
if error is None or not math.isfinite(float(error)) or float(error) == 0.0:
|
|
83
|
+
return _fmt_num(value)
|
|
84
|
+
e = abs(float(error)) # uncertainty is magnitude-only, sign is meaningless
|
|
85
|
+
exp = math.floor(math.log10(e))
|
|
86
|
+
ndp = sig - 1 - exp # decimal places (may be negative for large errors)
|
|
87
|
+
v_r, e_r = round(v, ndp), round(e, ndp)
|
|
88
|
+
dp = max(0, ndp)
|
|
89
|
+
return f"{v_r:.{dp}f} ± {e_r:.{dp}f}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── block-walking helpers (shared by every renderer) ──────────────────────
|
|
93
|
+
def _params_rows(block: Mapping[str, Any]) -> tuple[list[str], list[list[str]]]:
|
|
94
|
+
"""(header, rows) for a params block, with value ± error merged."""
|
|
95
|
+
has_unit = any(p.get("unit") for p in block["params"])
|
|
96
|
+
header = ["Parameter", "Value", *(["Unit"] if has_unit else [])]
|
|
97
|
+
rows = []
|
|
98
|
+
for p in block["params"]:
|
|
99
|
+
cells = [str(p["name"]), format_value_error(p.get("value"), p.get("error"))]
|
|
100
|
+
if has_unit:
|
|
101
|
+
cells.append(str(p.get("unit", "")))
|
|
102
|
+
rows.append(cells)
|
|
103
|
+
return header, rows
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _table_rows(block: Mapping[str, Any]) -> tuple[list[str], list[list[str]]]:
|
|
107
|
+
header = [str(c) for c in block["columns"]]
|
|
108
|
+
rows = [[_fmt_num(c) for c in row] for row in block["rows"]]
|
|
109
|
+
return header, rows
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── LaTeX (booktabs) ──────────────────────────────────────────────────────
|
|
113
|
+
# LaTeX special chars + the science glyphs the emitters emit (so the output
|
|
114
|
+
# compiles under plain pdfLaTeX, no inputenc/unicode-engine required).
|
|
115
|
+
_LATEX_REPL = {
|
|
116
|
+
"&": r"\&", "%": r"\%", "$": r"\$", "#": r"\#", "_": r"\_",
|
|
117
|
+
"{": r"\{", "}": r"\}", "~": r"\textasciitilde{}", "^": r"\textasciicircum{}",
|
|
118
|
+
"±": r"$\pm$", "×": r"$\times$", "·": r"$\cdot$", "²": r"$^2$", "³": r"$^3$",
|
|
119
|
+
"χ": r"$\chi$", "η": r"$\eta$", "α": r"$\alpha$", "β": r"$\beta$",
|
|
120
|
+
"γ": r"$\gamma$", "σ": r"$\sigma$", "λ": r"$\lambda$", "θ": r"$\theta$",
|
|
121
|
+
"ω": r"$\omega$", "μ": r"$\mu$", "π": r"$\pi$", "τ": r"$\tau$",
|
|
122
|
+
"Δ": r"$\Delta$", "Ω": r"$\Omega$", "Å": r"\AA{}", "°": r"$^\circ$",
|
|
123
|
+
"√": r"$\surd$", "∞": r"$\infty$",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _latex_escape(text: str) -> str:
|
|
128
|
+
return "".join(_LATEX_REPL.get(ch, ch) for ch in text)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _latex_table(header: list[str], rows: list[list[str]], caption: str | None) -> list[str]:
|
|
132
|
+
ncol = len(header)
|
|
133
|
+
align = "l" + "r" * (ncol - 1) if ncol > 1 else "l"
|
|
134
|
+
out = [r"\begin{table}[h]", r" \centering"]
|
|
135
|
+
if caption:
|
|
136
|
+
out.append(rf" \caption{{{_latex_escape(caption)}}}")
|
|
137
|
+
out.append(rf" \begin{{tabular}}{{{align}}}")
|
|
138
|
+
out.append(r" \toprule")
|
|
139
|
+
out.append(" " + " & ".join(_latex_escape(h) for h in header) + r" \\")
|
|
140
|
+
out.append(r" \midrule")
|
|
141
|
+
for row in rows:
|
|
142
|
+
out.append(" " + " & ".join(_latex_escape(str(c)) for c in row) + r" \\")
|
|
143
|
+
out.append(r" \bottomrule")
|
|
144
|
+
out.append(r" \end{tabular}")
|
|
145
|
+
out.append(r"\end{table}")
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def to_latex(report: Mapping[str, Any]) -> str:
|
|
150
|
+
"""Booktabs LaTeX for the report's tables (params + stats), text as prose."""
|
|
151
|
+
lines = [rf"% Report: {_latex_escape(str(report.get('title', '')))}",
|
|
152
|
+
r"% Requires \usepackage{booktabs}", ""]
|
|
153
|
+
for sec in report.get("sections", []):
|
|
154
|
+
lines.append(rf"\subsection*{{{_latex_escape(str(sec.get('title', '')))}}}")
|
|
155
|
+
for block in sec.get("blocks", []):
|
|
156
|
+
btype = block.get("type")
|
|
157
|
+
if btype == "text":
|
|
158
|
+
lines.append(_latex_escape(block["text"]) + "\n")
|
|
159
|
+
elif btype == "params":
|
|
160
|
+
header, rows = _params_rows(block)
|
|
161
|
+
lines += _latex_table(header, rows, block.get("caption"))
|
|
162
|
+
elif btype == "table":
|
|
163
|
+
header, rows = _table_rows(block)
|
|
164
|
+
lines += _latex_table(header, rows, block.get("caption"))
|
|
165
|
+
elif btype == "figure":
|
|
166
|
+
lines.append(rf"% [figure: {_latex_escape(str(block.get('name', '')))}]")
|
|
167
|
+
lines.append("")
|
|
168
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── HTML (self-contained) ─────────────────────────────────────────────────
|
|
172
|
+
_HTML_CSS = (
|
|
173
|
+
"body{font-family:system-ui,sans-serif;max-width:52rem;margin:2rem auto;"
|
|
174
|
+
"padding:0 1rem;color:#1a1a1a}h1{font-size:1.5rem}h2{font-size:1.15rem;"
|
|
175
|
+
"border-bottom:1px solid #ddd;padding-bottom:.2rem}table{border-collapse:"
|
|
176
|
+
"collapse;margin:.6rem 0}th,td{border:1px solid #ccc;padding:.25rem .6rem;"
|
|
177
|
+
"text-align:right}th:first-child,td:first-child{text-align:left}"
|
|
178
|
+
"caption{caption-side:top;font-style:italic;text-align:left;color:#555}"
|
|
179
|
+
"figure{color:#777;font-style:italic}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _html_table(header: list[str], rows: list[list[str]], caption: str | None) -> str:
|
|
184
|
+
parts = ["<table>"]
|
|
185
|
+
if caption:
|
|
186
|
+
parts.append(f"<caption>{_html.escape(caption)}</caption>")
|
|
187
|
+
parts.append("<thead><tr>" + "".join(f"<th>{_html.escape(h)}</th>" for h in header)
|
|
188
|
+
+ "</tr></thead><tbody>")
|
|
189
|
+
for row in rows:
|
|
190
|
+
parts.append("<tr>" + "".join(f"<td>{_html.escape(str(c))}</td>" for c in row) + "</tr>")
|
|
191
|
+
parts.append("</tbody></table>")
|
|
192
|
+
return "".join(parts)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def to_html(report: Mapping[str, Any]) -> str:
|
|
196
|
+
"""A self-contained HTML page for the report (#39)."""
|
|
197
|
+
title = _html.escape(str(report.get("title", "Report")))
|
|
198
|
+
body = [f"<h1>{title}</h1>"]
|
|
199
|
+
refs = report.get("source_refs", [])
|
|
200
|
+
if refs:
|
|
201
|
+
names = ", ".join(_html.escape(str(r.get("name") or r.get("id"))) for r in refs)
|
|
202
|
+
body.append(f"<p><small>Sources: {names}</small></p>")
|
|
203
|
+
for sec in report.get("sections", []):
|
|
204
|
+
body.append(f"<h2>{_html.escape(str(sec.get('title', '')))}</h2>")
|
|
205
|
+
for block in sec.get("blocks", []):
|
|
206
|
+
btype = block.get("type")
|
|
207
|
+
if btype == "text":
|
|
208
|
+
body.append(f"<p>{_html.escape(block['text'])}</p>")
|
|
209
|
+
elif btype == "params":
|
|
210
|
+
header, rows = _params_rows(block)
|
|
211
|
+
body.append(_html_table(header, rows, block.get("caption")))
|
|
212
|
+
elif btype == "table":
|
|
213
|
+
header, rows = _table_rows(block)
|
|
214
|
+
body.append(_html_table(header, rows, block.get("caption")))
|
|
215
|
+
elif btype == "figure":
|
|
216
|
+
cap = block.get("caption") or block.get("name", "")
|
|
217
|
+
img = block.get("image")
|
|
218
|
+
if img:
|
|
219
|
+
src = f"data:{img['mime']};base64,{img['data']}"
|
|
220
|
+
body.append(f'<figure><img src="{src}" alt="{_html.escape(str(cap))}"'
|
|
221
|
+
f' style="max-width:100%"><figcaption>'
|
|
222
|
+
f"{_html.escape(str(cap))}</figcaption></figure>")
|
|
223
|
+
else:
|
|
224
|
+
body.append(f"<figure>[figure: {_html.escape(str(cap))}]</figure>")
|
|
225
|
+
return (f"<!doctype html><html><head><meta charset='utf-8'><title>{title}</title>"
|
|
226
|
+
f"<style>{_HTML_CSS}</style></head><body>{''.join(body)}</body></html>")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Word / PowerPoint (guarded optional deps) ─────────────────────────────
|
|
230
|
+
def _to_docx(report: Mapping[str, Any]) -> bytes:
|
|
231
|
+
try:
|
|
232
|
+
from docx import Document # python-docx (MIT)
|
|
233
|
+
except ImportError as exc: # pragma: no cover - exercised only without the dep
|
|
234
|
+
raise ReportExportError(
|
|
235
|
+
"Word export needs 'python-docx' (pip install quantized[office])"
|
|
236
|
+
) from exc
|
|
237
|
+
|
|
238
|
+
doc = Document()
|
|
239
|
+
doc.add_heading(str(report.get("title", "Report")), level=0)
|
|
240
|
+
for sec in report.get("sections", []):
|
|
241
|
+
doc.add_heading(str(sec.get("title", "")), level=1)
|
|
242
|
+
for block in sec.get("blocks", []):
|
|
243
|
+
btype = block.get("type")
|
|
244
|
+
if btype == "text":
|
|
245
|
+
doc.add_paragraph(block["text"])
|
|
246
|
+
elif btype in ("params", "table"):
|
|
247
|
+
header, rows = (_params_rows if btype == "params" else _table_rows)(block)
|
|
248
|
+
cap = block.get("caption")
|
|
249
|
+
if cap:
|
|
250
|
+
doc.add_paragraph().add_run(str(cap)).italic = True
|
|
251
|
+
t = doc.add_table(rows=1, cols=len(header))
|
|
252
|
+
t.style = "Light Grid Accent 1"
|
|
253
|
+
for j, h in enumerate(header):
|
|
254
|
+
t.rows[0].cells[j].text = h
|
|
255
|
+
for row in rows:
|
|
256
|
+
cells = t.add_row().cells
|
|
257
|
+
for j, c in enumerate(row):
|
|
258
|
+
cells[j].text = str(c)
|
|
259
|
+
elif btype == "figure":
|
|
260
|
+
raster = _decode_raster(block.get("image"))
|
|
261
|
+
if raster is not None:
|
|
262
|
+
from docx.shared import Inches
|
|
263
|
+
doc.add_picture(_io.BytesIO(raster), width=Inches(6))
|
|
264
|
+
if block.get("caption"):
|
|
265
|
+
doc.add_paragraph().add_run(str(block["caption"])).italic = True
|
|
266
|
+
else:
|
|
267
|
+
doc.add_paragraph(f"[figure: {block.get('name', '')}]")
|
|
268
|
+
buf = _io.BytesIO()
|
|
269
|
+
doc.save(buf)
|
|
270
|
+
return buf.getvalue()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _to_pptx(report: Mapping[str, Any]) -> bytes:
|
|
274
|
+
try:
|
|
275
|
+
from pptx import Presentation # python-pptx (MIT)
|
|
276
|
+
from pptx.util import Inches, Pt
|
|
277
|
+
except ImportError as exc: # pragma: no cover - exercised only without the dep
|
|
278
|
+
raise ReportExportError(
|
|
279
|
+
"PowerPoint export needs 'python-pptx' (pip install quantized[office])"
|
|
280
|
+
) from exc
|
|
281
|
+
|
|
282
|
+
prs = Presentation()
|
|
283
|
+
blank = prs.slide_layouts[6]
|
|
284
|
+
title_layout = prs.slide_layouts[5]
|
|
285
|
+
first = prs.slides.add_slide(title_layout)
|
|
286
|
+
first.shapes.title.text = str(report.get("title", "Report"))
|
|
287
|
+
for sec in report.get("sections", []):
|
|
288
|
+
slide = prs.slides.add_slide(blank)
|
|
289
|
+
box = slide.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(9), Inches(0.8))
|
|
290
|
+
box.text_frame.text = str(sec.get("title", ""))
|
|
291
|
+
box.text_frame.paragraphs[0].font.size = Pt(28)
|
|
292
|
+
top = 1.3
|
|
293
|
+
for block in sec.get("blocks", []):
|
|
294
|
+
btype = block.get("type")
|
|
295
|
+
if btype == "text":
|
|
296
|
+
tb = slide.shapes.add_textbox(Inches(0.5), Inches(top), Inches(9), Inches(0.6))
|
|
297
|
+
tb.text_frame.text = block["text"]
|
|
298
|
+
tb.text_frame.word_wrap = True
|
|
299
|
+
top += 0.7
|
|
300
|
+
elif btype in ("params", "table"):
|
|
301
|
+
header, rows = (_params_rows if btype == "params" else _table_rows)(block)
|
|
302
|
+
nrows, ncols = len(rows) + 1, len(header)
|
|
303
|
+
height = min(0.35 * nrows, 5.0)
|
|
304
|
+
gt = slide.shapes.add_table(
|
|
305
|
+
nrows, ncols, Inches(0.5), Inches(top), Inches(9), Inches(height)
|
|
306
|
+
).table
|
|
307
|
+
for j, h in enumerate(header):
|
|
308
|
+
gt.cell(0, j).text = h
|
|
309
|
+
for i, row in enumerate(rows, start=1):
|
|
310
|
+
for j, c in enumerate(row):
|
|
311
|
+
gt.cell(i, j).text = str(c)
|
|
312
|
+
top += height + 0.3
|
|
313
|
+
elif btype == "figure":
|
|
314
|
+
raster = _decode_raster(block.get("image"))
|
|
315
|
+
if raster is not None:
|
|
316
|
+
slide.shapes.add_picture(
|
|
317
|
+
_io.BytesIO(raster), Inches(0.5), Inches(top), width=Inches(6)
|
|
318
|
+
)
|
|
319
|
+
top += 4.0
|
|
320
|
+
else:
|
|
321
|
+
tb = slide.shapes.add_textbox(Inches(0.5), Inches(top), Inches(9), Inches(0.5))
|
|
322
|
+
tb.text_frame.text = f"[figure: {block.get('name', '')}]"
|
|
323
|
+
top += 0.6
|
|
324
|
+
buf = _io.BytesIO()
|
|
325
|
+
prs.save(buf)
|
|
326
|
+
return buf.getvalue()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ── dispatch ──────────────────────────────────────────────────────────────
|
|
330
|
+
def render_report(report: Mapping[str, Any], fmt: str) -> tuple[bytes, str, bool]:
|
|
331
|
+
"""Render ``report`` to ``fmt``; return ``(data, mime, is_text)``.
|
|
332
|
+
|
|
333
|
+
``is_text`` is True for latex/html (utf-8 text), False for docx/pptx
|
|
334
|
+
(binary — the route base64-encodes these). Unknown or unavailable formats
|
|
335
|
+
raise :class:`ReportExportError`.
|
|
336
|
+
"""
|
|
337
|
+
if fmt == "latex":
|
|
338
|
+
return to_latex(report).encode("utf-8"), "text/x-tex", True
|
|
339
|
+
if fmt == "html":
|
|
340
|
+
return to_html(report).encode("utf-8"), "text/html", True
|
|
341
|
+
if fmt == "docx":
|
|
342
|
+
return (_to_docx(report),
|
|
343
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", False)
|
|
344
|
+
if fmt == "pptx":
|
|
345
|
+
return (_to_pptx(report),
|
|
346
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation", False)
|
|
347
|
+
raise ReportExportError(f"unknown report format {fmt!r}; expected one of {FORMATS}")
|
quantized/io/rigaku.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Rigaku SmartLab ``.raw`` binary parser. Port of MATLAB parser.importRigaku_raw.
|
|
2
|
+
|
|
3
|
+
Binary layout (magic "FI", little-endian, 1-indexed offsets in the MATLAB
|
|
4
|
+
source shown here 0-indexed):
|
|
5
|
+
0..1 magic "FI"
|
|
6
|
+
2958..2961 counting time per step (float32, s)
|
|
7
|
+
2962..2965 start 2theta (float32, deg)
|
|
8
|
+
2966..2969 end 2theta (float32, deg)
|
|
9
|
+
2970..2973 step size (float32, deg)
|
|
10
|
+
3154..3157 number of points (uint32)
|
|
11
|
+
3158.. intensity data (float32, 4 bytes/point)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import struct
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from quantized.datastruct import DataStruct
|
|
23
|
+
|
|
24
|
+
__all__ = ["import_rigaku_raw", "is_rigaku_raw"]
|
|
25
|
+
|
|
26
|
+
_HEADER_SIZE = 3158
|
|
27
|
+
_MIN_FILE_SIZE = 3162 # header + at least one float32
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_rigaku_raw(path: Path) -> bool:
|
|
31
|
+
"""Sniff a ``.raw`` as Rigaku SmartLab via the 'FI' magic bytes."""
|
|
32
|
+
with Path(path).open("rb") as fh:
|
|
33
|
+
return fh.read(2) == b"FI"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def import_rigaku_raw(
|
|
37
|
+
filepath: str | Path,
|
|
38
|
+
*,
|
|
39
|
+
use_counts_per_sec: bool = False,
|
|
40
|
+
allow_partial: bool = False,
|
|
41
|
+
) -> DataStruct:
|
|
42
|
+
"""Import a Rigaku SmartLab ``.raw`` (2theta vs intensity)."""
|
|
43
|
+
path = Path(filepath)
|
|
44
|
+
raw = path.read_bytes()
|
|
45
|
+
n_bytes = len(raw)
|
|
46
|
+
if n_bytes < _MIN_FILE_SIZE:
|
|
47
|
+
raise ValueError(f"file too small to be a Rigaku .raw ({n_bytes} bytes): {path.name}")
|
|
48
|
+
if raw[0:2] != b"FI":
|
|
49
|
+
raise ValueError(f"bad magic {raw[0:2]!r} (expected b'FI'): {path.name}")
|
|
50
|
+
|
|
51
|
+
counting_time = float(struct.unpack_from("<f", raw, 2958)[0])
|
|
52
|
+
start_angle = float(struct.unpack_from("<f", raw, 2962)[0])
|
|
53
|
+
end_angle = float(struct.unpack_from("<f", raw, 2966)[0])
|
|
54
|
+
step_size = float(struct.unpack_from("<f", raw, 2970)[0])
|
|
55
|
+
num_points = int(struct.unpack_from("<I", raw, 3154)[0])
|
|
56
|
+
|
|
57
|
+
if step_size == 0:
|
|
58
|
+
raise ValueError(f"zero step size (variable-step scans unsupported): {path.name}")
|
|
59
|
+
if step_size < 0 or step_size > 10:
|
|
60
|
+
raise ValueError(f"implausible step size ({step_size:.6g} deg): {path.name}")
|
|
61
|
+
|
|
62
|
+
n_avail = (n_bytes - _HEADER_SIZE) // 4
|
|
63
|
+
if num_points == 0 or num_points > n_avail:
|
|
64
|
+
if n_avail == 0:
|
|
65
|
+
raise ValueError(f"no data bytes after header: {path.name}")
|
|
66
|
+
num_points = n_avail
|
|
67
|
+
|
|
68
|
+
first_range_end = _HEADER_SIZE + num_points * 4
|
|
69
|
+
if n_bytes > first_range_end + 3 and not allow_partial:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"multi-range .raw detected ({n_bytes - first_range_end} bytes after first "
|
|
72
|
+
f"range); pass allow_partial=True to import the first range only: {path.name}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
intensities = np.frombuffer(raw, dtype="<f4", count=num_points, offset=_HEADER_SIZE).astype(
|
|
76
|
+
float
|
|
77
|
+
)
|
|
78
|
+
two_theta = start_angle + np.arange(num_points) * step_size
|
|
79
|
+
|
|
80
|
+
if use_counts_per_sec and counting_time > 0:
|
|
81
|
+
values = intensities / counting_time
|
|
82
|
+
unit = "counts/s"
|
|
83
|
+
else:
|
|
84
|
+
values = intensities
|
|
85
|
+
unit = "counts"
|
|
86
|
+
|
|
87
|
+
metadata: dict[str, Any] = {
|
|
88
|
+
"source": str(path),
|
|
89
|
+
"parser_name": "import_rigaku_raw",
|
|
90
|
+
"x_column_name": "2-Theta",
|
|
91
|
+
"x_column_unit": "deg",
|
|
92
|
+
"num_points": num_points,
|
|
93
|
+
"start_angle": start_angle,
|
|
94
|
+
"end_angle": end_angle,
|
|
95
|
+
"step_size": step_size,
|
|
96
|
+
"counting_time": counting_time,
|
|
97
|
+
}
|
|
98
|
+
return DataStruct.create(
|
|
99
|
+
two_theta, values, labels=["Intensity"], units=[unit], metadata=metadata
|
|
100
|
+
)
|