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