quantized-lab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,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
+ )