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,471 @@
1
+ """Figure export routes: render dataset/statplot/map/ternary/field visualizations.
2
+
3
+ Wraps ``calc.figure`` (basic plots), ``calc.figure_statplots`` (box/violin/
4
+ Q-Q/histogram), ``calc.figure_map`` (gridded 2-D heatmap/contour/surface),
5
+ ``calc.figure_corner`` (posterior/bootstrap pairs plots), ``calc.figure_ternary``
6
+ (3-component compositions), and ``calc.figure_field`` (quiver/streamline vector
7
+ fields). Output formats: PDF/SVG/PNG/TIFF. No formatting logic here — renderers
8
+ own it. Filenames are sanitized before reaching the Content-Disposition header.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Literal
14
+
15
+ from fastapi import APIRouter, HTTPException, Response
16
+ from pydantic import BaseModel
17
+
18
+ from quantized.datastruct import DataStruct
19
+ from quantized.routes._export_common import (
20
+ _DPI_MAX,
21
+ _DPI_MIN,
22
+ _FIGURE_MIME,
23
+ _attachment,
24
+ _safe_name,
25
+ )
26
+
27
+ router = APIRouter(prefix="/api/export", tags=["export"])
28
+
29
+
30
+ class TickFormatSpec(BaseModel):
31
+ """Wire model for the screen's `AxisFormat` (MAIN #24,
32
+ `frontend/src/lib/types.ts`): the tick-label number format for one axis.
33
+ `"auto"` (the default) leaves matplotlib's own formatter untouched --
34
+ see `calc.figure_ticks.axis_tick_formatter`."""
35
+
36
+ mode: Literal["auto", "fixed", "sci", "eng"] = "auto"
37
+ digits: float = 2
38
+
39
+
40
+ class FigureRequest(BaseModel):
41
+ dataset: dict[str, Any]
42
+ x_key: int | str | None = None
43
+ y_keys: list[int | str] | None = None
44
+ x_log: bool = False
45
+ y_log: bool = False
46
+ # MAIN #12 (Arrhenius reciprocal axis): "linear"/"log"/"reciprocal", the
47
+ # scale source of truth when set; x_log/y_log are the back-compat
48
+ # fallback for an older caller (see calc.figure_scale.resolve_axis_scale).
49
+ x_scale: str | None = None
50
+ y_scale: str | None = None
51
+ # MAIN #24: tick-label number format, mirroring the screen's xFmt/yFmt
52
+ # (yFmt also drives the screen's y2 axis; this backend has no y2/twinx
53
+ # rendering to mirror it onto). None = auto (omit to keep requests lean).
54
+ x_fmt: TickFormatSpec | None = None
55
+ y_fmt: TickFormatSpec | None = None
56
+ fmt: str = "pdf"
57
+ style: str = "default" # publication preset: aps / report / web / …
58
+ dpi: int = 200 # raster (png/tiff) resolution; ignored by vector formats
59
+ title: str = "" # optional figure title
60
+ x_label: str | None = None # override the auto-derived axis labels (None = derive)
61
+ y_label: str | None = None
62
+ # Per-series style (aligned to the plotted y_keys order): color/width/line/
63
+ # marker, plus MAIN #13's `fill` ("under" or `{"vs": <channel>}`) and MAIN
64
+ # #14's `color_by`/`colormap` (channel indices — resolved against `dataset`
65
+ # by `calc.plotting.resolve_style_channels`, called from `_figure_series`).
66
+ series_styles: list[dict[str, Any] | None] | None = None
67
+ # Property-panel overrides (gap #11): fonts / legend / ticks / spines /
68
+ # limits / margins / grid / annotations — validated in calc.
69
+ overrides: dict[str, Any] | None = None
70
+ filename: str = "figure"
71
+
72
+
73
+ def _figure_series(
74
+ req: FigureRequest,
75
+ ) -> tuple[Any, list[tuple[str, Any]], str, str, list[dict[str, Any] | None] | None]:
76
+ """Resolve a ``FigureRequest``'s dataset + channel picks into the
77
+ renderer's ``(x, series, x_label, y_label, series_styles)`` — shared by
78
+ ``/figure``, ``/figure-hitmap``, and the figure-page route
79
+ (``routes.export_page``). Caller-supplied labels override the
80
+ auto-derived "label (unit)" strings. ``series_styles`` is
81
+ ``req.series_styles`` resolved against ``ds``/the plotted channel order
82
+ (MAIN #13/#14's ``fill``/``color_by`` channel references —
83
+ ``calc.plotting.resolve_style_channels``) — the ONLY place this
84
+ resolution happens, so every figure-export route gets it for free."""
85
+ from quantized.calc.plotting import PlotState, build_series, resolve_style_channels
86
+
87
+ ds = DataStruct.from_dict(req.dataset)
88
+ state = PlotState(
89
+ x_key=req.x_key,
90
+ y_keys=tuple(req.y_keys) if req.y_keys is not None else None,
91
+ x_log=req.x_log,
92
+ y_log=req.y_log,
93
+ )
94
+ plot = build_series(ds, state)
95
+ x_label = req.x_label
96
+ if x_label is None:
97
+ x_label = f"{plot.x_label} ({plot.x_unit})" if plot.x_unit else plot.x_label
98
+ y_label = req.y_label
99
+ if y_label is None:
100
+ y_label = ""
101
+ if len(plot.series) == 1:
102
+ only = plot.series[0]
103
+ y_label = f"{only.label} ({only.unit})" if only.unit else only.label
104
+ series: list[tuple[str, Any]] = [
105
+ (f"{s.label} ({s.unit})" if s.unit else s.label, s.values) for s in plot.series
106
+ ]
107
+ styles = resolve_style_channels(ds, req.y_keys, req.series_styles)
108
+ return plot.x, series, x_label, y_label, styles
109
+
110
+
111
+ def _tick_fmt(spec: TickFormatSpec | None) -> dict[str, Any] | None:
112
+ """``TickFormatSpec`` (route-layer pydantic) -> the plain mapping
113
+ ``calc.figure_ticks.axis_tick_formatter`` expects (calc/ never imports
114
+ pydantic — see the layering guard)."""
115
+ return spec.model_dump() if spec is not None else None
116
+
117
+
118
+ @router.post("/figure")
119
+ def export_figure(req: FigureRequest) -> Response:
120
+ """Render the dataset (selected channels + log scales) to a publication
121
+ figure: PDF / SVG (vector) or PNG / TIFF (raster, at ``dpi``)."""
122
+ if req.fmt not in _FIGURE_MIME:
123
+ raise HTTPException(
124
+ status_code=422, detail=f"fmt must be one of {sorted(_FIGURE_MIME)}"
125
+ )
126
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi))
127
+ # Lazy import: matplotlib is heavy — only pay it when a figure is exported.
128
+ from quantized.calc.figure import render_figure
129
+
130
+ try:
131
+ x, series, x_label, y_label, styles = _figure_series(req)
132
+ data = render_figure(
133
+ x,
134
+ series,
135
+ title=req.title,
136
+ x_label=x_label,
137
+ y_label=y_label,
138
+ x_log=req.x_log,
139
+ y_log=req.y_log,
140
+ x_scale=req.x_scale,
141
+ y_scale=req.y_scale,
142
+ fmt=req.fmt,
143
+ style=req.style,
144
+ series_styles=styles,
145
+ dpi=dpi,
146
+ overrides=req.overrides,
147
+ x_fmt=_tick_fmt(req.x_fmt),
148
+ y_fmt=_tick_fmt(req.y_fmt),
149
+ )
150
+ except (ValueError, KeyError, IndexError) as exc:
151
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
152
+ return Response(
153
+ content=data,
154
+ media_type=_FIGURE_MIME[req.fmt],
155
+ headers=_attachment(_safe_name(req.filename, f".{req.fmt}")),
156
+ )
157
+
158
+
159
+ @router.post("/figure-hitmap")
160
+ def export_figure_hitmap(req: FigureRequest) -> dict[str, Any]:
161
+ """Preview render + element hit-map (gap #13): base64 PNG, per-artist
162
+ pixel boxes (title/labels/legend/series/annotations), and the axes rect
163
+ with data limits — the client hit-tests the preview and maps drags back
164
+ to data coordinates. ``fmt`` is ignored (always PNG at ``dpi``)."""
165
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi))
166
+ from quantized.calc.figure import render_figure_map
167
+
168
+ try:
169
+ x, series, x_label, y_label, styles = _figure_series(req)
170
+ return render_figure_map(
171
+ x,
172
+ series,
173
+ title=req.title,
174
+ x_label=x_label,
175
+ y_label=y_label,
176
+ x_log=req.x_log,
177
+ y_log=req.y_log,
178
+ x_scale=req.x_scale,
179
+ y_scale=req.y_scale,
180
+ style=req.style,
181
+ series_styles=styles,
182
+ dpi=dpi,
183
+ overrides=req.overrides,
184
+ x_fmt=_tick_fmt(req.x_fmt),
185
+ y_fmt=_tick_fmt(req.y_fmt),
186
+ )
187
+ except (ValueError, KeyError, IndexError) as exc:
188
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
189
+
190
+
191
+ class StatplotFigureRequest(BaseModel):
192
+ kind: str # box|violin|qq|probability|histogram
193
+ data: list[list[float]] | list[float] # groups (box/violin) or one sample
194
+ labels: list[str] | None = None
195
+ fmt: str = "pdf"
196
+ style: str = "default"
197
+ dist: str = "norm"
198
+ bins: str | int = "fd"
199
+ fit: str | None = None
200
+ title: str = ""
201
+ x_label: str = ""
202
+ y_label: str = ""
203
+ # None (default) resolves to the style preset's calibrated dpi, matching
204
+ # calc.figure's resolved_dpi convention (see corner/ternary/field siblings).
205
+ dpi: int | None = None
206
+ filename: str = "statplot"
207
+
208
+
209
+ @router.post("/statplot-figure")
210
+ def export_statplot_figure(req: StatplotFigureRequest) -> Response:
211
+ """Render a statistical plot (box/violin/Q-Q/histogram) to a publication
212
+ figure (PDF/SVG/PNG/TIFF)."""
213
+ if req.fmt not in _FIGURE_MIME:
214
+ raise HTTPException(
215
+ status_code=422, detail=f"fmt must be one of {sorted(_FIGURE_MIME)}"
216
+ )
217
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi)) if req.dpi is not None else None
218
+ from quantized.calc.figure_statplots import render_statplot_figure # lazy: matplotlib
219
+
220
+ try:
221
+ data: Any = req.data
222
+ data = [list(g) for g in data] if req.kind in ("box", "violin") else list(data)
223
+ img = render_statplot_figure(
224
+ req.kind, data, labels=req.labels, fmt=req.fmt, style=req.style,
225
+ dist=req.dist, bins=req.bins, fit=req.fit,
226
+ title=req.title, x_label=req.x_label, y_label=req.y_label, dpi=dpi,
227
+ )
228
+ except (ValueError, KeyError, IndexError, TypeError) as exc:
229
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
230
+ return Response(
231
+ content=img,
232
+ media_type=_FIGURE_MIME[req.fmt],
233
+ headers=_attachment(_safe_name(req.filename, f".{req.fmt}")),
234
+ )
235
+
236
+
237
+ class CategoricalFigureRequest(BaseModel):
238
+ groups: list[str] # category tick labels, in axis order
239
+ series: list[str] # series (legend) labels, in stack/cluster order
240
+ values: list[list[float]] # [group][series] bar height (mean)
241
+ errors: list[list[float | None]] | None = None # [group][series] SEM
242
+ stacked: bool = False
243
+ fmt: str = "pdf"
244
+ style: str = "default"
245
+ title: str = ""
246
+ x_label: str = ""
247
+ y_label: str = ""
248
+ dpi: int = 200
249
+ filename: str = "bar"
250
+
251
+
252
+ @router.post("/categorical-figure")
253
+ def export_categorical_figure(req: CategoricalFigureRequest) -> Response:
254
+ """Render a grouped/stacked bar chart (gap #20) to a publication figure
255
+ (PDF/SVG/PNG/TIFF) — the same category x series matrix (mean ± SEM) the
256
+ interactive stat stage's "bar" mode draws on-screen."""
257
+ if req.fmt not in _FIGURE_MIME:
258
+ raise HTTPException(
259
+ status_code=422, detail=f"fmt must be one of {sorted(_FIGURE_MIME)}"
260
+ )
261
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi))
262
+ from quantized.calc.figure_categorical import render_categorical_figure # lazy: matplotlib
263
+
264
+ try:
265
+ img = render_categorical_figure(
266
+ req.groups, req.series, req.values, req.errors, stacked=req.stacked,
267
+ fmt=req.fmt, style=req.style, title=req.title, x_label=req.x_label,
268
+ y_label=req.y_label, dpi=dpi,
269
+ )
270
+ except (ValueError, KeyError, IndexError, TypeError) as exc:
271
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
272
+ return Response(
273
+ content=img,
274
+ media_type=_FIGURE_MIME[req.fmt],
275
+ headers=_attachment(_safe_name(req.filename, f".{req.fmt}")),
276
+ )
277
+
278
+
279
+ class MapFigureRequest(BaseModel):
280
+ x_axis: list[float]
281
+ y_axis: list[float]
282
+ # (ny, nx), NaN allowed for gaps -- required when contour_source="grid".
283
+ z_grid: list[list[float]] | None = None
284
+ # Scattered per-point z, same length as x_axis/y_axis -- required when
285
+ # contour_source="points" (gap #17 tri-contour: the RSM cloud shape,
286
+ # never regridded).
287
+ z_values: list[float] | None = None
288
+ contour_source: str = "grid" # grid (z_grid) | points (x_axis/y_axis/z_values cloud)
289
+ kind: str = "contourf" # contourf|contour|heatmap|surface|scatter3d|waterfall
290
+ fmt: str = "pdf"
291
+ style: str = "default"
292
+ # None (default) resolves to the style preset's calibrated dpi, matching
293
+ # calc.figure's resolved_dpi convention (see corner/ternary/field siblings).
294
+ dpi: int | None = None
295
+ cmap: str = "viridis"
296
+ levels: int | list[float] = 12
297
+ level_scale: str = "linear" # linear|log
298
+ label_contours: bool = True
299
+ colorbar: bool = True
300
+ title: str = ""
301
+ x_label: str = ""
302
+ y_label: str = ""
303
+ z_label: str = ""
304
+ width_in: float | None = None
305
+ height_in: float | None = None
306
+ view_elev: float = 30.0
307
+ view_azim: float = -60.0
308
+ filename: str = "map"
309
+
310
+
311
+ @router.post("/map-figure")
312
+ def export_map_figure(req: MapFigureRequest) -> Response:
313
+ """Render a 2-D map to a publication figure: filled/line contour, heatmap,
314
+ or static 3-D surface/scatter/waterfall (PDF/SVG/PNG/TIFF) — from either a
315
+ regridded ``z_grid`` (``contour_source="grid"``, default) or a raw
316
+ scattered ``(x_axis, y_axis, z_values)`` point cloud contoured straight
317
+ off a Delaunay triangulation, no regridding (``contour_source="points"``,
318
+ ``kind`` restricted to ``contour``/``contourf``)."""
319
+ if req.fmt not in _FIGURE_MIME:
320
+ raise HTTPException(
321
+ status_code=422, detail=f"fmt must be one of {sorted(_FIGURE_MIME)}"
322
+ )
323
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi)) if req.dpi is not None else None
324
+ from quantized.calc.figure_map import render_map_figure # lazy: matplotlib is heavy
325
+
326
+ try:
327
+ data = render_map_figure(
328
+ req.x_axis, req.y_axis, req.z_grid,
329
+ contour_source=req.contour_source, z_values=req.z_values,
330
+ kind=req.kind, fmt=req.fmt, style=req.style, dpi=dpi, cmap=req.cmap,
331
+ levels=req.levels, level_scale=req.level_scale,
332
+ label_contours=req.label_contours, colorbar=req.colorbar,
333
+ title=req.title, x_label=req.x_label, y_label=req.y_label, z_label=req.z_label,
334
+ width_in=req.width_in, height_in=req.height_in,
335
+ view_elev=req.view_elev, view_azim=req.view_azim,
336
+ )
337
+ except (ValueError, KeyError, IndexError, TypeError) as exc:
338
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
339
+ return Response(
340
+ content=data,
341
+ media_type=_FIGURE_MIME[req.fmt],
342
+ headers=_attachment(_safe_name(req.filename, f".{req.fmt}")),
343
+ )
344
+
345
+
346
+ class CornerFigureRequest(BaseModel):
347
+ samples: list[list[float]] # (n_samples, n_params) joint parameter draws
348
+ param_names: list[str]
349
+ truths: list[float] | None = None # reference value per parameter (e.g. the fit)
350
+ fmt: str = "pdf"
351
+ style: str = "default"
352
+ # None (default) resolves to the style preset's calibrated dpi, matching
353
+ # calc.figure's resolved_dpi convention — unlike its statplot/map
354
+ # siblings above, which always pass an explicit dpi (a documented,
355
+ # separately-tracked gap; GAP_TIER3_PLAN open question 2 follow-ups).
356
+ dpi: int | None = None
357
+ bins: str | int = "fd"
358
+ title: str = ""
359
+ width_in: float | None = None
360
+ height_in: float | None = None
361
+ filename: str = "corner"
362
+
363
+
364
+ @router.post("/corner-figure")
365
+ def export_corner_figure(req: CornerFigureRequest) -> Response:
366
+ """Render a pairwise posterior/bootstrap corner (pairs) plot from posted
367
+ joint parameter samples — e.g. ``/api/fitting/posterior``'s ``samples``
368
+ or ``/api/fitting/bootstrap``'s ``boot_samples``
369
+ (``return_samples: true``). Stateless: the client posts samples it
370
+ already has; no fit is re-run server-side."""
371
+ if req.fmt not in _FIGURE_MIME:
372
+ raise HTTPException(
373
+ status_code=422, detail=f"fmt must be one of {sorted(_FIGURE_MIME)}"
374
+ )
375
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi)) if req.dpi is not None else None
376
+ from quantized.calc.figure_corner import render_corner_figure # lazy: matplotlib is heavy
377
+
378
+ try:
379
+ img = render_corner_figure(
380
+ req.samples, req.param_names, truths=req.truths,
381
+ title=req.title, fmt=req.fmt, style=req.style, dpi=dpi, bins=req.bins,
382
+ width_in=req.width_in, height_in=req.height_in,
383
+ )
384
+ except (ValueError, KeyError, IndexError, TypeError) as exc:
385
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
386
+ return Response(
387
+ content=img,
388
+ media_type=_FIGURE_MIME[req.fmt],
389
+ headers=_attachment(_safe_name(req.filename, f".{req.fmt}")),
390
+ )
391
+
392
+
393
+ class TernaryFigureRequest(BaseModel):
394
+ data: list[list[float]] # (n, 3) composition array
395
+ labels: tuple[str, str, str] = ("A", "B", "C") # corner labels
396
+ values: list[float] | None = None # optional color values (length n)
397
+ fmt: str = "pdf"
398
+ style: str = "default"
399
+ dpi: int | None = None
400
+ marker_size: float | None = None
401
+ title: str = ""
402
+ filename: str = "ternary"
403
+
404
+
405
+ @router.post("/ternary-figure")
406
+ def export_ternary_figure(req: TernaryFigureRequest) -> Response:
407
+ """Render a ternary diagram (3-component composition scatter plot) to a
408
+ publication figure (PDF/SVG/PNG/TIFF). Points are normalized so each row
409
+ sums to 1; non-positive components raise 422 error."""
410
+ if req.fmt not in _FIGURE_MIME:
411
+ raise HTTPException(
412
+ status_code=422, detail=f"fmt must be one of {sorted(_FIGURE_MIME)}"
413
+ )
414
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi)) if req.dpi is not None else None
415
+ from quantized.calc.figure_ternary import render_ternary_figure # lazy: matplotlib
416
+
417
+ try:
418
+ img = render_ternary_figure(
419
+ req.data, labels=req.labels, values=req.values,
420
+ fmt=req.fmt, style=req.style, dpi=dpi, marker_size=req.marker_size,
421
+ title=req.title,
422
+ )
423
+ except (ValueError, KeyError, IndexError, TypeError) as exc:
424
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
425
+ return Response(
426
+ content=img,
427
+ media_type=_FIGURE_MIME[req.fmt],
428
+ headers=_attachment(_safe_name(req.filename, f".{req.fmt}")),
429
+ )
430
+
431
+
432
+ class FieldFigureRequest(BaseModel):
433
+ x_axis: list[float] # 1-D coordinate array
434
+ y_axis: list[float] # 1-D coordinate array
435
+ u_grid: list[list[float]] # (ny, nx) component grid
436
+ v_grid: list[list[float]] # (ny, nx) component grid
437
+ kind: str = "quiver" # quiver|streamline
438
+ fmt: str = "pdf"
439
+ style: str = "default"
440
+ dpi: int | None = None
441
+ title: str = ""
442
+ x_label: str = ""
443
+ y_label: str = ""
444
+ filename: str = "field"
445
+
446
+
447
+ @router.post("/field-figure")
448
+ def export_field_figure(req: FieldFigureRequest) -> Response:
449
+ """Render a vector field plot (quiver arrows or streamlines) to a
450
+ publication figure (PDF/SVG/PNG/TIFF). u_grid and v_grid must have shape
451
+ (len(y_axis), len(x_axis))."""
452
+ if req.fmt not in _FIGURE_MIME:
453
+ raise HTTPException(
454
+ status_code=422, detail=f"fmt must be one of {sorted(_FIGURE_MIME)}"
455
+ )
456
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi)) if req.dpi is not None else None
457
+ from quantized.calc.figure_field import render_field_figure # lazy: matplotlib
458
+
459
+ try:
460
+ img = render_field_figure(
461
+ req.x_axis, req.y_axis, req.u_grid, req.v_grid,
462
+ kind=req.kind, fmt=req.fmt, style=req.style, dpi=dpi,
463
+ title=req.title, x_label=req.x_label, y_label=req.y_label,
464
+ )
465
+ except (ValueError, KeyError, IndexError, TypeError) as exc:
466
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
467
+ return Response(
468
+ content=img,
469
+ media_type=_FIGURE_MIME[req.fmt],
470
+ headers=_attachment(_safe_name(req.filename, f".{req.fmt}")),
471
+ )
@@ -0,0 +1,125 @@
1
+ """Figure-page export route (GOTO #4): N different plots -> ONE exported page.
2
+
3
+ Thin adapter over ``calc.figure_page``: validates the page spec (grid +
4
+ per-panel figure payloads -- each panel embeds the SAME payload shape
5
+ ``POST /api/export/figure`` takes), resolves every panel's dataset/channels
6
+ through the shared ``_figure_series`` helper, and hands plain dataclasses to
7
+ the pure composer. Vector formats (PDF/SVG) are the default export
8
+ convention; PNG/TIFF raster at a clamped DPI (the low-DPI PNG render is also
9
+ the composer UI's preview image). All layout/label validation lives in calc
10
+ -- a ``ValueError`` maps to 422 here, never a 500. Split into its own router
11
+ file (rather than joining ``routes/export_figures.py``) to respect the
12
+ 500-line module ceiling.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from fastapi import APIRouter, HTTPException, Response
18
+ from pydantic import BaseModel
19
+
20
+ from quantized.routes._export_common import (
21
+ _DPI_MAX,
22
+ _DPI_MIN,
23
+ _FIGURE_MIME,
24
+ _attachment,
25
+ _safe_name,
26
+ )
27
+ from quantized.routes.export_figures import FigureRequest, _figure_series, _tick_fmt
28
+
29
+ router = APIRouter(prefix="/api/export", tags=["export"])
30
+
31
+
32
+ class PagePanelSpec(BaseModel):
33
+ """One panel: a single-figure export payload plus its grid placement.
34
+ The nested figure's own ``fmt`` / ``style`` / ``dpi`` / ``filename`` are
35
+ ignored -- those are page-level decisions."""
36
+
37
+ figure: FigureRequest
38
+ row: int
39
+ col: int
40
+ row_span: int = 1
41
+ col_span: int = 1
42
+ # None = auto label from row-major placement order ("(a)", "(b)", ...);
43
+ # "" = no label on this panel only.
44
+ label: str | None = None
45
+ # Per-panel title override; None = the nested figure payload's own title.
46
+ title: str | None = None
47
+
48
+
49
+ class FigurePageRequest(BaseModel):
50
+ rows: int
51
+ cols: int
52
+ panels: list[PagePanelSpec]
53
+ fmt: str = "pdf" # vector by default (the architecture's export preference)
54
+ style: str = "default" # publication preset, applied page-wide
55
+ dpi: int | None = None # None = the preset's calibrated dpi
56
+ # Page size overrides (inches). None = the preset's journal-column width
57
+ # (aps ~3.39 in single / aps_double ~7.0 in double column), with height
58
+ # keeping each grid cell at the preset's own aspect ratio.
59
+ width_in: float | None = None
60
+ height_in: float | None = None
61
+ label_format: str = "(a)" # (a) | a) | a. | (A) | A) | A. | none
62
+ label_pos: str = "nw" # nw | ne | outside
63
+ filename: str = "figure_page"
64
+
65
+
66
+ @router.post("/figure-page")
67
+ def export_figure_page(req: FigurePageRequest) -> Response:
68
+ """Compose the panels onto one page (rows x cols grid, optional spans,
69
+ journal panel labels) and render server-side: PDF / SVG (vector) or
70
+ PNG / TIFF (raster at ``dpi``) -- the multi-panel "Figure 1(a)-(d)"
71
+ workflow with zero external post-processing."""
72
+ if req.fmt not in _FIGURE_MIME:
73
+ raise HTTPException(
74
+ status_code=422, detail=f"fmt must be one of {sorted(_FIGURE_MIME)}"
75
+ )
76
+ dpi = max(_DPI_MIN, min(_DPI_MAX, req.dpi)) if req.dpi is not None else None
77
+ # Lazy import: matplotlib is heavy — only pay it when a page is exported.
78
+ from quantized.calc.figure_page import PagePanel, render_figure_page
79
+
80
+ try:
81
+ panels = []
82
+ for spec in req.panels:
83
+ f = spec.figure
84
+ x, series, x_label, y_label, styles = _figure_series(f)
85
+ panels.append(
86
+ PagePanel(
87
+ x=x,
88
+ series=series,
89
+ row=spec.row,
90
+ col=spec.col,
91
+ row_span=spec.row_span,
92
+ col_span=spec.col_span,
93
+ title=spec.title if spec.title is not None else f.title,
94
+ x_label=x_label,
95
+ y_label=y_label,
96
+ x_log=f.x_log,
97
+ y_log=f.y_log,
98
+ x_scale=f.x_scale,
99
+ y_scale=f.y_scale,
100
+ x_fmt=_tick_fmt(f.x_fmt),
101
+ y_fmt=_tick_fmt(f.y_fmt),
102
+ series_styles=styles,
103
+ overrides=f.overrides,
104
+ label=spec.label,
105
+ )
106
+ )
107
+ data = render_figure_page(
108
+ panels,
109
+ rows=req.rows,
110
+ cols=req.cols,
111
+ fmt=req.fmt,
112
+ style=req.style,
113
+ width_in=req.width_in,
114
+ height_in=req.height_in,
115
+ dpi=dpi,
116
+ label_format=req.label_format,
117
+ label_pos=req.label_pos,
118
+ )
119
+ except (ValueError, KeyError, IndexError) as exc:
120
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
121
+ return Response(
122
+ content=data,
123
+ media_type=_FIGURE_MIME[req.fmt],
124
+ headers=_attachment(_safe_name(req.filename, f".{req.fmt}")),
125
+ )