quantized-lab 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
quantized/calc/errors.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Error propagation. Ports of MATLAB +utilities/error*.m.
|
|
2
|
+
|
|
3
|
+
Scalar uncertainty combination (quadrature) plus the general ``error_prop``
|
|
4
|
+
driver (first-order Taylor + Monte Carlo). Pure functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import ArrayLike, NDArray
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"error_add",
|
|
18
|
+
"error_div",
|
|
19
|
+
"error_func",
|
|
20
|
+
"error_mul",
|
|
21
|
+
"error_prop",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_EPS = 2.220446049250313e-16 # MATLAB eps
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def error_add(a: float, da: float, b: float, db: float) -> tuple[float, float]:
|
|
28
|
+
"""a + b with quadrature error sqrt(da^2 + db^2)."""
|
|
29
|
+
return a + b, math.sqrt(da**2 + db**2)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def error_mul(a: float, da: float, b: float, db: float) -> tuple[float, float]:
|
|
33
|
+
"""a * b with relative quadrature error."""
|
|
34
|
+
val = a * b
|
|
35
|
+
rel_a = da / max(abs(a), _EPS)
|
|
36
|
+
rel_b = db / max(abs(b), _EPS)
|
|
37
|
+
return val, abs(val) * math.sqrt(rel_a**2 + rel_b**2)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def error_div(a: float, da: float, b: float, db: float) -> tuple[float, float]:
|
|
41
|
+
"""a / b with relative quadrature error."""
|
|
42
|
+
val = a / b
|
|
43
|
+
rel_a = da / max(abs(a), _EPS)
|
|
44
|
+
rel_b = db / max(abs(b), _EPS)
|
|
45
|
+
return val, abs(val) * math.sqrt(rel_a**2 + rel_b**2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def error_func(func: Callable[[float], float], a: float, da: float) -> tuple[float, float]:
|
|
49
|
+
"""Propagate da through a 1-arg function via central-difference derivative."""
|
|
50
|
+
val = func(a)
|
|
51
|
+
h = max(abs(a) * 1e-7, 1e-10)
|
|
52
|
+
dfdx = (func(a + h) - func(a - h)) / (2.0 * h)
|
|
53
|
+
return val, abs(dfdx) * da
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
57
|
+
# errorProp — propagate uncertainties through an arbitrary function
|
|
58
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def error_prop(
|
|
62
|
+
func: Callable[..., Any],
|
|
63
|
+
values: ArrayLike,
|
|
64
|
+
errors: ArrayLike,
|
|
65
|
+
*,
|
|
66
|
+
method: str = "linear",
|
|
67
|
+
n_samples: int = 10000,
|
|
68
|
+
correlated: ArrayLike | None = None,
|
|
69
|
+
confidence: float = 0.95,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Propagate uncertainties through an arbitrary function. Port of errorProp.m.
|
|
72
|
+
|
|
73
|
+
``func`` takes N scalar (or array) arguments and returns a scalar or vector.
|
|
74
|
+
``values`` / ``errors`` are length-N sequences: the nominal inputs and their
|
|
75
|
+
1-sigma uncertainties.
|
|
76
|
+
|
|
77
|
+
Returns a dict with keys:
|
|
78
|
+
* ``value`` — ``func(*values)`` at the nominal inputs (float or ndarray)
|
|
79
|
+
* ``error`` — propagated 1-sigma uncertainty (matches ``value`` shape)
|
|
80
|
+
* ``rel_error`` — ``error / max(|value|, eps)``
|
|
81
|
+
* ``formula`` — readable variance expression (``linear`` only; ``""`` for MC)
|
|
82
|
+
* ``ci`` — ``(lo, hi)`` confidence interval (``montecarlo`` only; else None)
|
|
83
|
+
* ``partials`` — flat ndarray of partial derivatives (``linear`` only; else None)
|
|
84
|
+
|
|
85
|
+
Methods:
|
|
86
|
+
* ``"linear"`` (default) — first-order Taylor with the *full* covariance
|
|
87
|
+
matrix, so ``correlated`` correlations are honoured; supports
|
|
88
|
+
vector-valued ``func`` (element-wise error).
|
|
89
|
+
* ``"montecarlo"`` — draw the (optionally correlated) input distribution via
|
|
90
|
+
a Cholesky factor and report the empirical std + percentile CI. Scalar
|
|
91
|
+
inputs only. Seeded (42) for reproducibility, but NOT bit-for-bit equal to
|
|
92
|
+
MATLAB's RNG — invariants hold; exact draws differ (see golden-tests rule).
|
|
93
|
+
"""
|
|
94
|
+
if method not in ("linear", "montecarlo"):
|
|
95
|
+
raise ValueError(f'method must be "linear" or "montecarlo", got "{method}"')
|
|
96
|
+
|
|
97
|
+
vals = _to_inputs(values)
|
|
98
|
+
errs = _to_inputs(errors)
|
|
99
|
+
n = len(vals)
|
|
100
|
+
if len(errs) != n:
|
|
101
|
+
raise ValueError("values and errors must have the same number of elements")
|
|
102
|
+
|
|
103
|
+
if correlated is None:
|
|
104
|
+
corr = np.eye(n)
|
|
105
|
+
else:
|
|
106
|
+
corr = np.asarray(correlated, dtype=float)
|
|
107
|
+
if corr.shape != (n, n):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"correlated must be [{n}x{n}], got {corr.shape[0]}x{corr.shape[1]}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
nom = np.asarray(func(*vals), dtype=float)
|
|
113
|
+
if method == "linear":
|
|
114
|
+
return _propagate_linear(func, vals, errs, corr, nom, n)
|
|
115
|
+
return _propagate_mc(func, vals, errs, corr, nom, n, n_samples, confidence)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _to_inputs(arg: ArrayLike) -> list[Any]:
|
|
119
|
+
"""Normalise to a length-N list of per-variable inputs (MATLAB ``num2cell``).
|
|
120
|
+
|
|
121
|
+
A list/tuple is kept as-is (each element is one variable, scalar or array). A
|
|
122
|
+
bare numeric array is split along its first axis into N scalar variables.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(arg, (list, tuple)):
|
|
125
|
+
return list(arg)
|
|
126
|
+
a = np.asarray(arg, dtype=float)
|
|
127
|
+
if a.ndim == 0:
|
|
128
|
+
return [float(a)]
|
|
129
|
+
return [a[i] for i in range(a.shape[0])]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _propagate_linear(
|
|
133
|
+
func: Callable[..., Any],
|
|
134
|
+
values: list[Any],
|
|
135
|
+
errors: list[Any],
|
|
136
|
+
corr: NDArray[np.float64],
|
|
137
|
+
nom: NDArray[np.float64],
|
|
138
|
+
n: int,
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""First-order Taylor propagation with the full covariance matrix."""
|
|
141
|
+
partials: list[NDArray[np.float64]] = []
|
|
142
|
+
for i in range(n):
|
|
143
|
+
xi = np.asarray(values[i], dtype=float)
|
|
144
|
+
# Step size: relative if possible, absolute fallback (matches MATLAB).
|
|
145
|
+
h = np.asarray(np.maximum(np.abs(xi) * 1e-7, 1e-10), dtype=float)
|
|
146
|
+
fwd = list(values)
|
|
147
|
+
bwd = list(values)
|
|
148
|
+
fwd[i] = values[i] + h
|
|
149
|
+
bwd[i] = values[i] - h
|
|
150
|
+
f_fwd = np.asarray(func(*fwd), dtype=float)
|
|
151
|
+
f_bwd = np.asarray(func(*bwd), dtype=float)
|
|
152
|
+
partials.append(np.asarray((f_fwd - f_bwd) / (2.0 * h), dtype=float))
|
|
153
|
+
|
|
154
|
+
# Variance via full covariance: var_f = sum_ij dfi*dfj*corr_ij*sigma_i*sigma_j.
|
|
155
|
+
var_f = np.zeros(nom.shape, dtype=float)
|
|
156
|
+
for i in range(n):
|
|
157
|
+
for j in range(n):
|
|
158
|
+
cov_ij = corr[i, j] * np.asarray(errors[i], dtype=float) * np.asarray(
|
|
159
|
+
errors[j], dtype=float
|
|
160
|
+
)
|
|
161
|
+
var_f = np.asarray(var_f + partials[i] * partials[j] * cov_ij, dtype=float)
|
|
162
|
+
|
|
163
|
+
prop_error = np.asarray(np.sqrt(np.maximum(var_f, 0.0)), dtype=float)
|
|
164
|
+
rel_err = np.asarray(prop_error / np.maximum(np.abs(nom), _EPS), dtype=float)
|
|
165
|
+
partials_out = np.concatenate(
|
|
166
|
+
[np.atleast_1d(np.asarray(p, dtype=float)).ravel() for p in partials]
|
|
167
|
+
)
|
|
168
|
+
return {
|
|
169
|
+
"value": _unwrap(nom),
|
|
170
|
+
"error": _unwrap(prop_error),
|
|
171
|
+
"rel_error": _unwrap(rel_err),
|
|
172
|
+
"formula": _build_formula(partials, errors, n),
|
|
173
|
+
"ci": None,
|
|
174
|
+
"partials": partials_out,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _build_formula(partials: list[NDArray[np.float64]], errors: list[Any], n: int) -> str:
|
|
179
|
+
"""Readable ``sigma_f^2 = ...`` string (replicates errorProp's scalar/vector forms)."""
|
|
180
|
+
parts: list[str] = []
|
|
181
|
+
for i in range(n):
|
|
182
|
+
p = np.asarray(partials[i], dtype=float)
|
|
183
|
+
e = np.asarray(errors[i], dtype=float)
|
|
184
|
+
if p.size == 1:
|
|
185
|
+
parts.append(f"(df/dx{i + 1}={float(p):.4g})^2*({float(e):.4g})^2")
|
|
186
|
+
else:
|
|
187
|
+
parts.append(f"(df/dx{i + 1})*^2*(sigma{i + 1})^2 [vector]")
|
|
188
|
+
return "sigma_f^2 = " + " + ".join(parts)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _propagate_mc(
|
|
192
|
+
func: Callable[..., Any],
|
|
193
|
+
values: list[Any],
|
|
194
|
+
errors: list[Any],
|
|
195
|
+
corr: NDArray[np.float64],
|
|
196
|
+
nom: NDArray[np.float64],
|
|
197
|
+
n: int,
|
|
198
|
+
n_samples: int,
|
|
199
|
+
confidence: float,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""Monte Carlo propagation (scalar inputs only); seeded but not RNG-identical."""
|
|
202
|
+
for v in values:
|
|
203
|
+
if np.asarray(v, dtype=float).size != 1:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
"Monte Carlo method requires scalar inputs; use method='linear' "
|
|
206
|
+
"for vector inputs"
|
|
207
|
+
)
|
|
208
|
+
sigmas = np.asarray([float(np.asarray(e, dtype=float)) for e in errors], dtype=float)
|
|
209
|
+
means = np.asarray([float(np.asarray(v, dtype=float)) for v in values], dtype=float)
|
|
210
|
+
|
|
211
|
+
# Covariance = diag(sigma) * corr * diag(sigma); symmetrise then Cholesky.
|
|
212
|
+
cov = np.asarray(corr * np.outer(sigmas, sigmas), dtype=float)
|
|
213
|
+
cov = np.asarray((cov + cov.T) / 2.0, dtype=float)
|
|
214
|
+
try:
|
|
215
|
+
chol = np.linalg.cholesky(cov)
|
|
216
|
+
except np.linalg.LinAlgError:
|
|
217
|
+
# Not positive definite → fall back to the uncorrelated diagonal.
|
|
218
|
+
chol = np.diag(sigmas)
|
|
219
|
+
|
|
220
|
+
rng = np.random.default_rng(42)
|
|
221
|
+
z = rng.standard_normal((n, n_samples))
|
|
222
|
+
samples = means[:, None] + chol @ z
|
|
223
|
+
f_samples = np.asarray(
|
|
224
|
+
[float(func(*samples[:, s])) for s in range(n_samples)], dtype=float
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
mc_mean = float(np.mean(f_samples))
|
|
228
|
+
mc_std = float(np.std(f_samples, ddof=1))
|
|
229
|
+
rel_err = mc_std / max(abs(mc_mean), _EPS)
|
|
230
|
+
alpha = 1.0 - confidence
|
|
231
|
+
lo = float(np.percentile(f_samples, 100.0 * alpha / 2.0))
|
|
232
|
+
hi = float(np.percentile(f_samples, 100.0 * (1.0 - alpha / 2.0)))
|
|
233
|
+
return {
|
|
234
|
+
"value": _unwrap(nom),
|
|
235
|
+
"error": mc_std,
|
|
236
|
+
"rel_error": rel_err,
|
|
237
|
+
"formula": f"Monte Carlo (N={n_samples}, conf={confidence * 100:.0f}%)",
|
|
238
|
+
"ci": (lo, hi),
|
|
239
|
+
"partials": None,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _unwrap(a: NDArray[np.float64]) -> float | NDArray[np.float64]:
|
|
244
|
+
"""0-d array → python float; otherwise return the array unchanged."""
|
|
245
|
+
arr = np.asarray(a, dtype=float)
|
|
246
|
+
return float(arr) if arr.ndim == 0 else arr
|
quantized/calc/figure.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Publication figure rendering via matplotlib. Pure layer: data in -> bytes.
|
|
2
|
+
|
|
3
|
+
Renders a clean publication-style figure (white background, vector by default)
|
|
4
|
+
to PDF / SVG (vector) or PNG / TIFF (raster, at a chosen DPI). Server-side so the
|
|
5
|
+
browser gets a real vector file — the architecture's vector-by-default export
|
|
6
|
+
preference; raster formats are available for journals that demand them. TIFF
|
|
7
|
+
output goes through Pillow (a matplotlib dependency). matplotlib is imported here
|
|
8
|
+
only (the heavy import is lazy at the route boundary).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import matplotlib
|
|
18
|
+
|
|
19
|
+
matplotlib.use("Agg") # headless: render to a buffer, never to a display
|
|
20
|
+
|
|
21
|
+
import matplotlib.pyplot as plt # noqa: E402 (must follow matplotlib.use)
|
|
22
|
+
import numpy as np
|
|
23
|
+
from numpy.typing import ArrayLike, NDArray
|
|
24
|
+
|
|
25
|
+
from quantized.calc.figure_hitmap import collect_map as _collect_map_impl # noqa: E402
|
|
26
|
+
from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
|
|
27
|
+
from quantized.calc.figure_overrides import _apply_overrides, _validate_overrides # noqa: E402
|
|
28
|
+
from quantized.calc.figure_scale import apply_axis_scale, resolve_axis_scale # noqa: E402
|
|
29
|
+
from quantized.calc.figure_styles import FigureStyle, figure_style # noqa: E402
|
|
30
|
+
from quantized.calc.figure_ticks import apply_tick_formats # noqa: E402
|
|
31
|
+
|
|
32
|
+
__all__ = ["draw_series_axes", "render_figure", "style_rc"]
|
|
33
|
+
|
|
34
|
+
_FORMATS = ("pdf", "svg", "png", "tiff")
|
|
35
|
+
_LINESTYLE = {"solid": "-", "dashed": "--", "dotted": ":"}
|
|
36
|
+
# Fixed fill translucency for MAIN #13 (fill under/between curves) — matches
|
|
37
|
+
# the screen side's `uplotFill.ts` FILL_ALPHA_PCT (25%) so an exported figure
|
|
38
|
+
# reads the same as its on-screen counterpart.
|
|
39
|
+
_FILL_ALPHA = 0.25
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _plot_kwargs(
|
|
43
|
+
default_lw: float, default_marker_size: float, spec: Mapping[str, Any] | None
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Translate a per-series style spec (color/width/line/marker[/marker_size])
|
|
46
|
+
into matplotlib ``plot`` kwargs, so the export matches the on-screen styling.
|
|
47
|
+
``default_marker_size`` is the active preset's calibrated marker size,
|
|
48
|
+
used only when a marker is requested without an explicit per-series size."""
|
|
49
|
+
kw: dict[str, Any] = {"linewidth": default_lw}
|
|
50
|
+
if not spec:
|
|
51
|
+
return kw
|
|
52
|
+
color = spec.get("color")
|
|
53
|
+
if color:
|
|
54
|
+
kw["color"] = color
|
|
55
|
+
width = spec.get("width")
|
|
56
|
+
if width is not None:
|
|
57
|
+
kw["linewidth"] = width
|
|
58
|
+
line = spec.get("line")
|
|
59
|
+
if line in _LINESTYLE:
|
|
60
|
+
kw["linestyle"] = _LINESTYLE[line]
|
|
61
|
+
if spec.get("marker"):
|
|
62
|
+
kw["marker"] = "o"
|
|
63
|
+
kw["markersize"] = spec.get("marker_size") or default_marker_size
|
|
64
|
+
return kw
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _apply_fill(
|
|
68
|
+
ax: Any,
|
|
69
|
+
xv: NDArray[np.float64],
|
|
70
|
+
yv: NDArray[np.float64],
|
|
71
|
+
series: Sequence[tuple[str, ArrayLike]],
|
|
72
|
+
idx: int,
|
|
73
|
+
spec: Mapping[str, Any] | None,
|
|
74
|
+
color: Any,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Fill under (to a zero baseline) or between this series and another
|
|
77
|
+
plotted one (MAIN #13), via matplotlib ``fill_between`` -- the export
|
|
78
|
+
counterpart of the screen's native uPlot fill/band mechanism. ``color``
|
|
79
|
+
is the series' OWN drawn line colour (whatever ``ax.plot`` resolved to,
|
|
80
|
+
explicit or matplotlib's auto cycle) -- a fill is always derived from it,
|
|
81
|
+
never a separately stored colour. ``spec["fill"]["vs"]`` is already
|
|
82
|
+
resolved to a DISPLAY POSITION within ``series`` by
|
|
83
|
+
``calc.plotting.resolve_style_channels`` -- this function never sees a
|
|
84
|
+
raw channel index."""
|
|
85
|
+
if not spec:
|
|
86
|
+
return
|
|
87
|
+
fill = spec.get("fill")
|
|
88
|
+
if fill == "under":
|
|
89
|
+
ax.fill_between(xv, yv, 0.0, color=color, alpha=_FILL_ALPHA)
|
|
90
|
+
elif isinstance(fill, Mapping):
|
|
91
|
+
vs = fill.get("vs")
|
|
92
|
+
if isinstance(vs, int) and 0 <= vs < len(series) and vs != idx:
|
|
93
|
+
other = np.asarray(series[vs][1], dtype=float)
|
|
94
|
+
n = min(len(xv), len(yv), len(other))
|
|
95
|
+
if n > 0:
|
|
96
|
+
ax.fill_between(xv[:n], yv[:n], other[:n], color=color, alpha=_FILL_ALPHA)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _draw_color_scatter(
|
|
100
|
+
fig: Any,
|
|
101
|
+
ax: Any,
|
|
102
|
+
xv: NDArray[np.float64],
|
|
103
|
+
yv: NDArray[np.float64],
|
|
104
|
+
label: str,
|
|
105
|
+
spec: Mapping[str, Any],
|
|
106
|
+
st: FigureStyle,
|
|
107
|
+
) -> Any:
|
|
108
|
+
"""Colour-mapped scatter (MAIN #14): each point coloured by a THIRD
|
|
109
|
+
channel's value -- ``spec["color_by"]``, already resolved by
|
|
110
|
+
``calc.plotting.resolve_style_channels`` to a concrete per-row array (this
|
|
111
|
+
module never sees a raw channel index). Replaces the normal line draw
|
|
112
|
+
entirely for this series -- screen-side parity: ``uplotOpts.ts`` hides the
|
|
113
|
+
native line/points the same way whenever a series' ``colorBy`` is set.
|
|
114
|
+
Adds a colourbar so the mapping is legible. Returns the ``PathCollection``
|
|
115
|
+
artist (for the figure-hitmap element collector)."""
|
|
116
|
+
z = np.asarray(spec["color_by"], dtype=float)
|
|
117
|
+
n = min(len(xv), len(yv), len(z))
|
|
118
|
+
size = float(spec.get("marker_size") or st.marker_size) ** 2
|
|
119
|
+
sc = ax.scatter(
|
|
120
|
+
xv[:n], yv[:n], c=z[:n], cmap=str(spec.get("colormap") or "viridis"), s=size, label=label
|
|
121
|
+
)
|
|
122
|
+
fig.colorbar(sc, ax=ax)
|
|
123
|
+
return sc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def style_rc(st: FigureStyle, ov: Mapping[str, Any]) -> dict[str, Any]:
|
|
127
|
+
"""The rc-param dict a preset (+ optional font/tick overrides) resolves to.
|
|
128
|
+
|
|
129
|
+
Scoped to one render via ``matplotlib.rc_context`` by the callers (the
|
|
130
|
+
single-figure renderer below and the page composer in ``figure_page``).
|
|
131
|
+
The named font is given a generic fallback so matplotlib stays silent when
|
|
132
|
+
Helvetica/Arial/Times aren't installed on the host.
|
|
133
|
+
"""
|
|
134
|
+
fallback = "DejaVu Serif" if st.font_generic == "serif" else "DejaVu Sans"
|
|
135
|
+
font_size = float(ov.get("font_size", st.font_size))
|
|
136
|
+
font_name = str(ov.get("font_name", st.font_name))
|
|
137
|
+
tick_dir = str(ov.get("ticks", {}).get("dir", st.tick_dir))
|
|
138
|
+
rc: dict[str, Any] = {
|
|
139
|
+
"font.family": st.font_generic,
|
|
140
|
+
f"font.{st.font_generic}": [font_name, fallback],
|
|
141
|
+
"font.size": font_size,
|
|
142
|
+
"axes.labelsize": font_size,
|
|
143
|
+
"axes.titlesize": float(ov.get("title_size", st.title_font_size)),
|
|
144
|
+
"xtick.labelsize": font_size,
|
|
145
|
+
"ytick.labelsize": font_size,
|
|
146
|
+
"xtick.direction": tick_dir,
|
|
147
|
+
"ytick.direction": tick_dir,
|
|
148
|
+
# Mirror ticks onto the top/right spines whenever the box is drawn
|
|
149
|
+
# (the journal "closed box, inward ticks on all four sides" look) --
|
|
150
|
+
# matplotlib's own default leaves top/right bare even with the full
|
|
151
|
+
# rectangular border, which reads as an unfinished box.
|
|
152
|
+
"xtick.top": st.box_on,
|
|
153
|
+
"ytick.right": st.box_on,
|
|
154
|
+
}
|
|
155
|
+
tick_len = ov.get("ticks", {}).get("len")
|
|
156
|
+
if tick_len is not None:
|
|
157
|
+
rc["xtick.major.size"] = float(tick_len)
|
|
158
|
+
rc["ytick.major.size"] = float(tick_len)
|
|
159
|
+
return rc
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def draw_series_axes(
|
|
163
|
+
fig: Any,
|
|
164
|
+
ax: Any,
|
|
165
|
+
xv: NDArray[np.float64],
|
|
166
|
+
series: Sequence[tuple[str, ArrayLike]],
|
|
167
|
+
*,
|
|
168
|
+
st: FigureStyle,
|
|
169
|
+
ov: Mapping[str, Any],
|
|
170
|
+
x_log: bool = False,
|
|
171
|
+
y_log: bool = False,
|
|
172
|
+
x_scale: str | None = None,
|
|
173
|
+
y_scale: str | None = None,
|
|
174
|
+
title: str = "",
|
|
175
|
+
x_label: str = "",
|
|
176
|
+
y_label: str = "",
|
|
177
|
+
series_styles: Sequence[Mapping[str, Any] | None] | None = None,
|
|
178
|
+
x_fmt: Mapping[str, Any] | None = None,
|
|
179
|
+
y_fmt: Mapping[str, Any] | None = None,
|
|
180
|
+
) -> list[Any]:
|
|
181
|
+
"""Plot ``series`` into an EXISTING Axes: lines, scales, labels, spines,
|
|
182
|
+
legend, grid, and the per-figure override sweep (:func:`_apply_overrides`).
|
|
183
|
+
Returns the per-series drawn artist (a ``Line2D`` normally, or a
|
|
184
|
+
``PathCollection`` for a colour-mapped-scatter series -- MAIN #14), in
|
|
185
|
+
``series`` order, for the figure-hitmap element collector
|
|
186
|
+
(:func:`quantized.calc.figure_hitmap.collect_map`).
|
|
187
|
+
|
|
188
|
+
The single per-axes rendering body, shared by the single-figure renderer
|
|
189
|
+
(``_render_impl``) and the multi-panel page composer
|
|
190
|
+
(``figure_page.render_figure_page``) so a panel on a page looks exactly
|
|
191
|
+
like its single-figure export. Callers own the figure lifecycle (rc
|
|
192
|
+
context, layout, savefig, close) and must have sanitized every
|
|
193
|
+
user-supplied string through ``safe_mathtext_label`` already.
|
|
194
|
+
|
|
195
|
+
``x_scale``/``y_scale`` (MAIN #12: "linear"/"log"/"reciprocal") are the
|
|
196
|
+
axis-scale source of truth when given; ``x_log``/``y_log`` are the
|
|
197
|
+
back-compat fallback for a caller that only sets the boolean (see
|
|
198
|
+
:func:`quantized.calc.figure_scale.resolve_axis_scale`).
|
|
199
|
+
|
|
200
|
+
Per-series ``series_styles`` (MAIN #13/#14, resolved against the raw
|
|
201
|
+
``DataStruct`` by ``calc.plotting.resolve_style_channels`` -- this
|
|
202
|
+
function only ever sees resolved values): ``fill: "under"`` or
|
|
203
|
+
``{"vs": <display index>}`` draws a translucent fill derived from the
|
|
204
|
+
series' own colour (:func:`_apply_fill`); ``color_by: <array>`` replaces
|
|
205
|
+
the normal line draw with a colour-mapped scatter + colourbar
|
|
206
|
+
(:func:`_draw_color_scatter`) instead.
|
|
207
|
+
|
|
208
|
+
``x_fmt``/``y_fmt`` (MAIN #24: ``{"mode": "auto"|"fixed"|"sci"|"eng",
|
|
209
|
+
"digits": n}``) are the tick-label number format, mirroring the screen's
|
|
210
|
+
``AxisFormat`` (see :func:`quantized.calc.figure_ticks.apply_tick_formats`);
|
|
211
|
+
``None``/``"auto"`` leaves matplotlib's own default formatter untouched.
|
|
212
|
+
"""
|
|
213
|
+
artists: list[Any] = []
|
|
214
|
+
for i, (label, y) in enumerate(series):
|
|
215
|
+
spec = series_styles[i] if series_styles and i < len(series_styles) else None
|
|
216
|
+
yv = np.asarray(y, dtype=float)
|
|
217
|
+
if spec and spec.get("color_by") is not None:
|
|
218
|
+
artists.append(_draw_color_scatter(fig, ax, xv, yv, label, spec, st))
|
|
219
|
+
continue
|
|
220
|
+
kw = _plot_kwargs(st.line_width, st.marker_size, spec)
|
|
221
|
+
(line,) = ax.plot(xv, yv, label=label, **kw)
|
|
222
|
+
_apply_fill(ax, xv, yv, series, i, spec, line.get_color())
|
|
223
|
+
artists.append(line)
|
|
224
|
+
apply_axis_scale(ax, "x", resolve_axis_scale(x_scale, x_log))
|
|
225
|
+
apply_axis_scale(ax, "y", resolve_axis_scale(y_scale, y_log))
|
|
226
|
+
apply_tick_formats(ax, x_fmt, y_fmt)
|
|
227
|
+
if title:
|
|
228
|
+
ax.set_title(title)
|
|
229
|
+
if x_label:
|
|
230
|
+
ax.set_xlabel(x_label)
|
|
231
|
+
if y_label:
|
|
232
|
+
ax.set_ylabel(y_label)
|
|
233
|
+
if not st.box_on:
|
|
234
|
+
ax.spines["top"].set_visible(False)
|
|
235
|
+
ax.spines["right"].set_visible(False)
|
|
236
|
+
if len(series) > 1 and "legend" not in ov:
|
|
237
|
+
# legend_location is a plain str (FigureStyle field); every preset's
|
|
238
|
+
# value is one of matplotlib's accepted location strings.
|
|
239
|
+
ax.legend(
|
|
240
|
+
frameon=st.legend_box,
|
|
241
|
+
fontsize=st.legend_font_size,
|
|
242
|
+
loc=st.legend_location,
|
|
243
|
+
)
|
|
244
|
+
if st.grid_alpha > 0:
|
|
245
|
+
ax.grid(True, alpha=st.grid_alpha)
|
|
246
|
+
else:
|
|
247
|
+
ax.grid(False)
|
|
248
|
+
_apply_overrides(fig, ax, st, ov, n_series=len(series))
|
|
249
|
+
return artists
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _render_impl(
|
|
253
|
+
x: ArrayLike,
|
|
254
|
+
series: Sequence[tuple[str, ArrayLike]],
|
|
255
|
+
*,
|
|
256
|
+
title: str = "",
|
|
257
|
+
x_label: str = "",
|
|
258
|
+
y_label: str = "",
|
|
259
|
+
x_log: bool = False,
|
|
260
|
+
y_log: bool = False,
|
|
261
|
+
x_scale: str | None = None,
|
|
262
|
+
y_scale: str | None = None,
|
|
263
|
+
fmt: str = "pdf",
|
|
264
|
+
style: str = "default",
|
|
265
|
+
series_styles: Sequence[Mapping[str, Any] | None] | None = None,
|
|
266
|
+
width_in: float | None = None,
|
|
267
|
+
height_in: float | None = None,
|
|
268
|
+
dpi: int | None = None,
|
|
269
|
+
overrides: Mapping[str, Any] | None = None,
|
|
270
|
+
collect_map: bool = False,
|
|
271
|
+
x_fmt: Mapping[str, Any] | None = None,
|
|
272
|
+
y_fmt: Mapping[str, Any] | None = None,
|
|
273
|
+
) -> bytes | dict[str, Any]:
|
|
274
|
+
"""Render ``series`` (each ``(label, y)``) against ``x`` to image bytes.
|
|
275
|
+
|
|
276
|
+
``fmt`` is ``pdf`` / ``svg`` (vector) or ``png`` / ``tiff`` (raster, sized by
|
|
277
|
+
``dpi``). ``style`` names a publication preset (``aps`` / ``report`` / ``web``
|
|
278
|
+
/ …) that sets font, line width, figure geometry, grid, and legend; pass
|
|
279
|
+
``width_in`` / ``height_in`` to override the preset's size. ``dpi`` defaults
|
|
280
|
+
to the preset's own calibrated resolution (e.g. ``aps`` / ``nature`` -> 600,
|
|
281
|
+
matching journal raster requirements) when not given explicitly; pass a
|
|
282
|
+
value to override it. ``title`` / ``x_label`` / ``y_label`` are optional
|
|
283
|
+
(empty = omit). ``series_styles`` (aligned 1:1 with ``series``) carries
|
|
284
|
+
per-series color/width/line/marker so the export matches the on-screen
|
|
285
|
+
plot, plus MAIN #13's ``fill`` (translucent fill under/between curves —
|
|
286
|
+
see :func:`_apply_fill`) and MAIN #14's ``color_by`` (colour-mapped
|
|
287
|
+
scatter + colourbar, replacing the line entirely for that series — see
|
|
288
|
+
:func:`_draw_color_scatter`); both expect values already resolved by
|
|
289
|
+
``calc.plotting.resolve_style_channels`` (a raw channel index never
|
|
290
|
+
reaches this function). A legend is drawn only for multiple series, at
|
|
291
|
+
the preset's ``legend_location``. ``overrides`` (gap #11 — every property
|
|
292
|
+
UI-reachable) patches the preset per-figure: see :func:`_apply_overrides`;
|
|
293
|
+
unknown keys are ignored, invalid values raise ``ValueError``. Raises
|
|
294
|
+
``ValueError`` on an unknown format or style. ``x_scale``/``y_scale``
|
|
295
|
+
(MAIN #12) select linear/log/reciprocal; ``x_log``/``y_log`` are the
|
|
296
|
+
back-compat boolean fallback (see :func:`draw_series_axes`'s doc).
|
|
297
|
+
``x_fmt``/``y_fmt`` (MAIN #24) are the tick-label number format -- see
|
|
298
|
+
:func:`draw_series_axes`'s doc.
|
|
299
|
+
"""
|
|
300
|
+
if fmt not in _FORMATS:
|
|
301
|
+
raise ValueError(f"fmt must be one of {_FORMATS}")
|
|
302
|
+
# Rich-text labels (GOTO #5): valid $...$ mathtext passes through to
|
|
303
|
+
# matplotlib untouched; INVALID mathtext is de-mathed here so it renders
|
|
304
|
+
# literally instead of raising inside savefig (an export must never 500).
|
|
305
|
+
# Sanitizing at entry also covers the figure_break branch below, which
|
|
306
|
+
# receives these same strings.
|
|
307
|
+
title = safe_mathtext_label(title)
|
|
308
|
+
x_label = safe_mathtext_label(x_label)
|
|
309
|
+
y_label = safe_mathtext_label(y_label)
|
|
310
|
+
series = [(safe_mathtext_label(label), y) for label, y in series]
|
|
311
|
+
st = figure_style(style)
|
|
312
|
+
ov = dict(overrides or {})
|
|
313
|
+
_validate_overrides(ov)
|
|
314
|
+
resolved_dpi = int(dpi) if dpi is not None else int(st.dpi)
|
|
315
|
+
|
|
316
|
+
# rc_context scopes typography to this render (see style_rc). (matplotlib's
|
|
317
|
+
# RcParams Literal-key type is impractical with the dynamic font.<generic>
|
|
318
|
+
# key, hence the targeted ignore at the context below.)
|
|
319
|
+
rc = style_rc(st, ov)
|
|
320
|
+
figsize = (width_in or st.fig_width_in, height_in or st.fig_height_in)
|
|
321
|
+
|
|
322
|
+
xv: NDArray[np.float64] = np.asarray(x, dtype=float)
|
|
323
|
+
x_breaks = ov.get("x_breaks")
|
|
324
|
+
with matplotlib.rc_context(rc): # type: ignore[arg-type]
|
|
325
|
+
# Manual axis breaks (gap #21): a distinct, twinned-panel rendering
|
|
326
|
+
# path — not compatible with the hit-map collector (collect_map is
|
|
327
|
+
# figure-hitmap's single-axes pixel harvesting) or the full
|
|
328
|
+
# `_apply_overrides` sweep (legend/spines/limits/margins apply to a
|
|
329
|
+
# SINGLE axes; a broken figure has several). Breaks render the plot +
|
|
330
|
+
# title/labels/basic legend/grid honestly; other overrides are simply
|
|
331
|
+
# not applied together with x_breaks in this pass.
|
|
332
|
+
if x_breaks and not collect_map:
|
|
333
|
+
# Lazy import: split out purely to stay under the 500-line ceiling.
|
|
334
|
+
from quantized.calc.figure_break import render_breaks_impl
|
|
335
|
+
|
|
336
|
+
return render_breaks_impl(
|
|
337
|
+
xv,
|
|
338
|
+
series,
|
|
339
|
+
breaks=[(float(b[0]), float(b[1])) for b in x_breaks],
|
|
340
|
+
x_log=x_log,
|
|
341
|
+
y_log=y_log,
|
|
342
|
+
x_scale=x_scale,
|
|
343
|
+
y_scale=y_scale,
|
|
344
|
+
title=title,
|
|
345
|
+
x_label=x_label,
|
|
346
|
+
y_label=y_label,
|
|
347
|
+
fmt=fmt,
|
|
348
|
+
st=st,
|
|
349
|
+
ov=ov,
|
|
350
|
+
dpi=resolved_dpi,
|
|
351
|
+
figsize=figsize,
|
|
352
|
+
series_styles=series_styles,
|
|
353
|
+
x_fmt=x_fmt,
|
|
354
|
+
y_fmt=y_fmt,
|
|
355
|
+
)
|
|
356
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
357
|
+
try:
|
|
358
|
+
artists = draw_series_axes(
|
|
359
|
+
fig,
|
|
360
|
+
ax,
|
|
361
|
+
xv,
|
|
362
|
+
series,
|
|
363
|
+
st=st,
|
|
364
|
+
ov=ov,
|
|
365
|
+
x_log=x_log,
|
|
366
|
+
y_log=y_log,
|
|
367
|
+
x_scale=x_scale,
|
|
368
|
+
y_scale=y_scale,
|
|
369
|
+
title=title,
|
|
370
|
+
x_label=x_label,
|
|
371
|
+
y_label=y_label,
|
|
372
|
+
series_styles=series_styles,
|
|
373
|
+
x_fmt=x_fmt,
|
|
374
|
+
y_fmt=y_fmt,
|
|
375
|
+
)
|
|
376
|
+
if not ov.get("margins"):
|
|
377
|
+
fig.tight_layout()
|
|
378
|
+
if collect_map:
|
|
379
|
+
return _collect_map_impl(
|
|
380
|
+
fig,
|
|
381
|
+
ax,
|
|
382
|
+
series_artists=artists,
|
|
383
|
+
dpi=resolved_dpi,
|
|
384
|
+
x_scale=resolve_axis_scale(x_scale, x_log),
|
|
385
|
+
y_scale=resolve_axis_scale(y_scale, y_log),
|
|
386
|
+
)
|
|
387
|
+
buf = BytesIO()
|
|
388
|
+
fig.savefig(buf, format=fmt, dpi=resolved_dpi)
|
|
389
|
+
return buf.getvalue()
|
|
390
|
+
finally:
|
|
391
|
+
plt.close(fig)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def render_figure(
|
|
395
|
+
x: ArrayLike,
|
|
396
|
+
series: Sequence[tuple[str, ArrayLike]],
|
|
397
|
+
**kwargs: Any,
|
|
398
|
+
) -> bytes:
|
|
399
|
+
"""Render ``series`` against ``x`` to image bytes (see ``_render_impl``)."""
|
|
400
|
+
out = _render_impl(x, series, **kwargs)
|
|
401
|
+
assert isinstance(out, bytes)
|
|
402
|
+
return out
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def render_figure_map(
|
|
406
|
+
x: ArrayLike,
|
|
407
|
+
series: Sequence[tuple[str, ArrayLike]],
|
|
408
|
+
**kwargs: Any,
|
|
409
|
+
) -> dict[str, Any]:
|
|
410
|
+
"""Render a PNG AND its element hit-map (gap #13): base64 image + one
|
|
411
|
+
pixel bounding box per interactive artist (title / axis labels / legend /
|
|
412
|
+
series lines / annotations) + the axes rect with data limits, so the
|
|
413
|
+
client can hit-test the preview and map pixels back to data coords."""
|
|
414
|
+
kwargs.pop("fmt", None)
|
|
415
|
+
out = _render_impl(x, series, fmt="png", collect_map=True, **kwargs)
|
|
416
|
+
assert isinstance(out, dict)
|
|
417
|
+
return out
|