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,159 @@
|
|
|
1
|
+
"""Figure property overrides (gap #11). Split out of ``calc.figure`` purely to
|
|
2
|
+
stay under the 500-line god-module ceiling (the same reason ``figure_break.py``
|
|
3
|
+
and ``figure_labels.py`` exist separately) -- the behavioural contract is still
|
|
4
|
+
``calc.figure``'s; ``_render_impl`` calls ``_validate_overrides`` up front and
|
|
5
|
+
``draw_series_axes`` calls ``_apply_overrides`` as its last step. Pure layer:
|
|
6
|
+
mapping in -> mutates the passed matplotlib Figure/Axes, no return value.
|
|
7
|
+
|
|
8
|
+
The one config object behind the property panels: every export property the
|
|
9
|
+
UI exposes lands in ``overrides``, patching the preset per-figure. Plain dict
|
|
10
|
+
(calc stays pydantic-free); unknown keys are ignored so old clients keep
|
|
11
|
+
working.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from quantized.calc.figure_labels import safe_mathtext_label
|
|
20
|
+
|
|
21
|
+
__all__ = ["_apply_overrides", "_validate_overrides"]
|
|
22
|
+
|
|
23
|
+
_LEGEND_LOCS = frozenset({
|
|
24
|
+
"best", "upper right", "upper left", "lower left", "lower right",
|
|
25
|
+
"right", "center left", "center right", "lower center", "upper center",
|
|
26
|
+
"center", "outside right", "outside top", "custom",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_overrides(ov: Mapping[str, Any]) -> None:
|
|
31
|
+
"""Raise ``ValueError`` on invalid override values (bad keys are ignored)."""
|
|
32
|
+
legend = ov.get("legend")
|
|
33
|
+
if legend is not None:
|
|
34
|
+
loc = legend.get("loc")
|
|
35
|
+
if loc is not None and loc not in _LEGEND_LOCS:
|
|
36
|
+
raise ValueError(f"legend loc must be one of {sorted(_LEGEND_LOCS)}")
|
|
37
|
+
ticks = ov.get("ticks")
|
|
38
|
+
if ticks is not None:
|
|
39
|
+
tdir = ticks.get("dir")
|
|
40
|
+
if tdir is not None and tdir not in ("in", "out"):
|
|
41
|
+
raise ValueError("ticks dir must be 'in' or 'out'")
|
|
42
|
+
for key in ("x_lim", "y_lim"):
|
|
43
|
+
lim = ov.get(key)
|
|
44
|
+
if lim is not None and (not isinstance(lim, (list, tuple)) or len(lim) != 2):
|
|
45
|
+
raise ValueError(f"{key} must be a [lo, hi] pair (null member = auto)")
|
|
46
|
+
margins = ov.get("margins")
|
|
47
|
+
if margins is not None:
|
|
48
|
+
for side in ("left", "right", "top", "bottom"):
|
|
49
|
+
v = margins.get(side)
|
|
50
|
+
if v is not None and not 0.0 <= float(v) <= 1.0:
|
|
51
|
+
raise ValueError("margins are figure fractions in [0, 1]")
|
|
52
|
+
breaks = ov.get("x_breaks")
|
|
53
|
+
if breaks is not None:
|
|
54
|
+
if not isinstance(breaks, (list, tuple)) or len(breaks) == 0:
|
|
55
|
+
raise ValueError("x_breaks must be a non-empty list of [lo, hi] pairs")
|
|
56
|
+
prev_hi: float | None = None
|
|
57
|
+
for b in breaks:
|
|
58
|
+
if not isinstance(b, (list, tuple)) or len(b) != 2:
|
|
59
|
+
raise ValueError("each x_breaks entry must be a [lo, hi] pair")
|
|
60
|
+
lo, hi = float(b[0]), float(b[1])
|
|
61
|
+
if not lo < hi:
|
|
62
|
+
raise ValueError("each x_breaks entry must have lo < hi")
|
|
63
|
+
if prev_hi is not None and lo < prev_hi:
|
|
64
|
+
raise ValueError("x_breaks entries must be sorted and non-overlapping")
|
|
65
|
+
prev_hi = hi
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _apply_overrides(
|
|
69
|
+
fig: Any, ax: Any, st: Any, ov: Mapping[str, Any], *, n_series: int
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Apply the post-plot override properties (legend / ticks / spines /
|
|
72
|
+
limits / margins / grid / annotations). rc-level properties (fonts, tick
|
|
73
|
+
direction/length) are folded into the rc context by the caller."""
|
|
74
|
+
legend = ov.get("legend")
|
|
75
|
+
if legend is not None:
|
|
76
|
+
show = legend.get("show")
|
|
77
|
+
if (show is None and n_series > 1) or show:
|
|
78
|
+
frame = bool(legend.get("frame", st.legend_box))
|
|
79
|
+
loc = str(legend.get("loc", "best"))
|
|
80
|
+
kw: dict[str, Any] = {"frameon": frame, "fontsize": st.legend_font_size}
|
|
81
|
+
if loc == "outside right":
|
|
82
|
+
kw.update(loc="center left", bbox_to_anchor=(1.02, 0.5))
|
|
83
|
+
elif loc == "outside top":
|
|
84
|
+
kw.update(loc="lower center", bbox_to_anchor=(0.5, 1.02), ncols=max(1, n_series))
|
|
85
|
+
elif loc == "custom":
|
|
86
|
+
# #14 drag-to-place: anchor is a figure-fraction (fx, fy).
|
|
87
|
+
anchor = legend.get("anchor") or (0.5, 0.5)
|
|
88
|
+
kw.update(
|
|
89
|
+
loc="center",
|
|
90
|
+
bbox_to_anchor=(float(anchor[0]), float(anchor[1])),
|
|
91
|
+
bbox_transform=fig.transFigure,
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
kw["loc"] = loc
|
|
95
|
+
ax.legend(**kw)
|
|
96
|
+
elif ax.get_legend() is not None:
|
|
97
|
+
ax.get_legend().remove()
|
|
98
|
+
|
|
99
|
+
ticks = ov.get("ticks")
|
|
100
|
+
if ticks is not None and ticks.get("minor"):
|
|
101
|
+
ax.minorticks_on()
|
|
102
|
+
|
|
103
|
+
spines = ov.get("spines")
|
|
104
|
+
if spines is not None:
|
|
105
|
+
for side in ("top", "right", "left", "bottom"):
|
|
106
|
+
if side in spines:
|
|
107
|
+
ax.spines[side].set_visible(bool(spines[side]))
|
|
108
|
+
|
|
109
|
+
for key, setter in (("x_lim", ax.set_xlim), ("y_lim", ax.set_ylim)):
|
|
110
|
+
lim = ov.get(key)
|
|
111
|
+
if lim is not None:
|
|
112
|
+
lo, hi = lim
|
|
113
|
+
setter(
|
|
114
|
+
None if lo is None else float(lo),
|
|
115
|
+
None if hi is None else float(hi),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if "grid" in ov:
|
|
119
|
+
ax.grid(bool(ov["grid"]), alpha=st.grid_alpha or 0.3)
|
|
120
|
+
|
|
121
|
+
for ann in ov.get("annotations", []):
|
|
122
|
+
# MAIN #18: a per-annotation `size` (the pointer tool's corner-handle
|
|
123
|
+
# font-size resize, screen px) wins over the property panel's global
|
|
124
|
+
# font_size override -- matches the screen, where each annotation's
|
|
125
|
+
# OWN size (Annotation.size) always overrides the plot's base font.
|
|
126
|
+
size = ann.get("size")
|
|
127
|
+
ann_kw: dict[str, Any] = {}
|
|
128
|
+
x, y = float(ann.get("x", 0.0)), float(ann.get("y", 0.0))
|
|
129
|
+
if ann.get("anchor") == "page":
|
|
130
|
+
# MAIN #21: page-anchored on screen (CANVAS fractions, x
|
|
131
|
+
# rightward/y DOWNWARD -- see Annotation.anchor's doc) -> figure
|
|
132
|
+
# fraction placement (x rightward/y UPWARD) rather than
|
|
133
|
+
# axes-data coords, so the label stays pinned to the same page
|
|
134
|
+
# position independent of the axes' data range. The Y AXIS IS
|
|
135
|
+
# FLIPPED between the two conventions -- canvas y=0 is the top,
|
|
136
|
+
# figure fraction y=0 is the bottom -- so `1 - y` is required,
|
|
137
|
+
# not optional. Canvas fractions and matplotlib figure fractions
|
|
138
|
+
# are NOT geometrically identical (the canvas includes the axes
|
|
139
|
+
# margins at a different proportion than matplotlib's own
|
|
140
|
+
# figure margins) -- this is deliberately Origin-parity-in-
|
|
141
|
+
# spirit (pin to the page, stay put through zoom/pan), not
|
|
142
|
+
# pixel-identical placement between screen and export.
|
|
143
|
+
ann_kw["xycoords"] = "figure fraction"
|
|
144
|
+
y = 1.0 - y
|
|
145
|
+
ax.annotate(
|
|
146
|
+
safe_mathtext_label(str(ann.get("text", ""))),
|
|
147
|
+
xy=(x, y),
|
|
148
|
+
fontsize=float(size) if size else float(ov.get("font_size", st.font_size)),
|
|
149
|
+
**ann_kw,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
margins = ov.get("margins")
|
|
153
|
+
if margins is not None:
|
|
154
|
+
fig.subplots_adjust(
|
|
155
|
+
left=margins.get("left"),
|
|
156
|
+
right=None if margins.get("right") is None else 1.0 - float(margins["right"]),
|
|
157
|
+
top=None if margins.get("top") is None else 1.0 - float(margins["top"]),
|
|
158
|
+
bottom=margins.get("bottom"),
|
|
159
|
+
)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Multi-panel figure page composition (GOTO #4): N different plots -> ONE page.
|
|
2
|
+
|
|
3
|
+
Pure layer: panel data in -> image bytes out. Composes pre-built panels (each
|
|
4
|
+
the same ``(x, series)`` payload ``calc.figure`` renders singly) onto a single
|
|
5
|
+
matplotlib page: a rows x cols grid with optional per-panel row/col spans,
|
|
6
|
+
journal-style panel labels ("(a)", "(b)", ... -- auto-generated in row-major
|
|
7
|
+
placement order, or overridden per panel), and ONE style preset applied
|
|
8
|
+
page-wide. This is the "Figure 1(a)-(d)" workflow with zero external
|
|
9
|
+
post-processing: PDF / SVG vector by default, PNG / TIFF raster at a chosen
|
|
10
|
+
DPI (the low-DPI PNG render doubles as the composer UI's preview image).
|
|
11
|
+
|
|
12
|
+
The per-axes rendering body is shared with the single-figure renderer via
|
|
13
|
+
``figure.draw_series_axes``, so a panel on a page looks exactly like its
|
|
14
|
+
single-figure export. Every user-supplied string (panel titles, axis labels,
|
|
15
|
+
series labels, panel labels) is routed through the GOTO #5 rich-text guard
|
|
16
|
+
(``figure_labels.safe_mathtext_label``): valid ``$...$`` mathtext renders,
|
|
17
|
+
invalid markup degrades to literal text -- an export must never error on a
|
|
18
|
+
label. Two single-figure overrides are page-incompatible and rejected with a
|
|
19
|
+
clear ``ValueError`` (-> 422 at the route): per-panel ``x_breaks`` (the break
|
|
20
|
+
renderer owns its own figure) and per-panel ``margins`` (page layout is
|
|
21
|
+
constrained-layout, figure-level).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from io import BytesIO
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import matplotlib
|
|
32
|
+
|
|
33
|
+
matplotlib.use("Agg") # headless: render to a buffer, never to a display
|
|
34
|
+
|
|
35
|
+
import matplotlib.pyplot as plt # noqa: E402 (must follow matplotlib.use)
|
|
36
|
+
import numpy as np # noqa: E402
|
|
37
|
+
from numpy.typing import ArrayLike # noqa: E402
|
|
38
|
+
|
|
39
|
+
from quantized.calc.figure import draw_series_axes, style_rc # noqa: E402
|
|
40
|
+
from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
|
|
41
|
+
from quantized.calc.figure_overrides import _validate_overrides # noqa: E402
|
|
42
|
+
from quantized.calc.figure_styles import FigureStyle, figure_style # noqa: E402
|
|
43
|
+
|
|
44
|
+
__all__ = ["PagePanel", "panel_label", "render_figure_page"]
|
|
45
|
+
|
|
46
|
+
_FORMATS = ("pdf", "svg", "png", "tiff")
|
|
47
|
+
_LABEL_POSITIONS = ("nw", "ne", "outside")
|
|
48
|
+
# Auto-label formats, keyed by the rendered form of the FIRST panel:
|
|
49
|
+
# (wrap template, uppercase letters?). "none" suppresses auto labels entirely.
|
|
50
|
+
_LABEL_TEMPLATES: dict[str, tuple[str, bool]] = {
|
|
51
|
+
"(a)": ("({})", False),
|
|
52
|
+
"a)": ("{})", False),
|
|
53
|
+
"a.": ("{}.", False),
|
|
54
|
+
"(A)": ("({})", True),
|
|
55
|
+
"A)": ("{})", True),
|
|
56
|
+
"A.": ("{}.", True),
|
|
57
|
+
}
|
|
58
|
+
# Grid cap: a journal page never needs more; guards absurd allocations.
|
|
59
|
+
_MAX_GRID = 8
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _letters(index: int) -> str:
|
|
63
|
+
"""0 -> "a", 25 -> "z", 26 -> "aa", ... (spreadsheet-style rollover)."""
|
|
64
|
+
out = ""
|
|
65
|
+
n = index
|
|
66
|
+
while True:
|
|
67
|
+
out = chr(ord("a") + n % 26) + out
|
|
68
|
+
n = n // 26 - 1
|
|
69
|
+
if n < 0:
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def panel_label(index: int, label_format: str = "(a)") -> str:
|
|
74
|
+
"""The auto-generated label for the ``index``-th panel (0-based, row-major
|
|
75
|
+
placement order): ``panel_label(1, "(a)") == "(b)"``. ``"none"`` returns
|
|
76
|
+
an empty string (no labels). Raises ``ValueError`` on an unknown format
|
|
77
|
+
or a negative index."""
|
|
78
|
+
if index < 0:
|
|
79
|
+
raise ValueError("panel index must be >= 0")
|
|
80
|
+
if label_format == "none":
|
|
81
|
+
return ""
|
|
82
|
+
try:
|
|
83
|
+
template, upper = _LABEL_TEMPLATES[label_format]
|
|
84
|
+
except KeyError as exc:
|
|
85
|
+
allowed = (*_LABEL_TEMPLATES, "none")
|
|
86
|
+
raise ValueError(f"label_format must be one of {allowed}") from exc
|
|
87
|
+
letters = _letters(index)
|
|
88
|
+
return template.format(letters.upper() if upper else letters)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class PagePanel:
|
|
93
|
+
"""One panel of a figure page: the same ``(x, series)`` payload
|
|
94
|
+
``calc.figure`` renders singly, plus its grid placement. ``label=None``
|
|
95
|
+
means auto ("(a)", "(b)", ... in row-major placement order); ``label=""``
|
|
96
|
+
suppresses the label on this panel only."""
|
|
97
|
+
|
|
98
|
+
x: ArrayLike
|
|
99
|
+
series: Sequence[tuple[str, ArrayLike]]
|
|
100
|
+
row: int
|
|
101
|
+
col: int
|
|
102
|
+
row_span: int = 1
|
|
103
|
+
col_span: int = 1
|
|
104
|
+
title: str = ""
|
|
105
|
+
x_label: str = ""
|
|
106
|
+
y_label: str = ""
|
|
107
|
+
x_log: bool = False
|
|
108
|
+
y_log: bool = False
|
|
109
|
+
# MAIN #12: linear/log/reciprocal, source of truth when set; x_log/y_log
|
|
110
|
+
# are the back-compat fallback (see figure_scale.resolve_axis_scale).
|
|
111
|
+
x_scale: str | None = None
|
|
112
|
+
y_scale: str | None = None
|
|
113
|
+
# MAIN #24: tick-label number format ({"mode": ..., "digits": ...},
|
|
114
|
+
# AxisFormat-shaped) -- each panel carries its OWN, mirroring the screen
|
|
115
|
+
# (a figure page composes several independently-configured plot views).
|
|
116
|
+
x_fmt: Mapping[str, Any] | None = None
|
|
117
|
+
y_fmt: Mapping[str, Any] | None = None
|
|
118
|
+
series_styles: Sequence[Mapping[str, Any] | None] | None = None
|
|
119
|
+
overrides: Mapping[str, Any] | None = None
|
|
120
|
+
label: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _validate_page(rows: int, cols: int, panels: Sequence[PagePanel]) -> None:
|
|
124
|
+
"""Raise ``ValueError`` on an invalid page spec: bad grid, empty page,
|
|
125
|
+
out-of-bounds or overlapping panels, page-incompatible overrides."""
|
|
126
|
+
if rows < 1 or cols < 1:
|
|
127
|
+
raise ValueError("page grid must have at least 1 row and 1 column")
|
|
128
|
+
if rows > _MAX_GRID or cols > _MAX_GRID:
|
|
129
|
+
raise ValueError(f"page grid is capped at {_MAX_GRID}x{_MAX_GRID}")
|
|
130
|
+
if not panels:
|
|
131
|
+
raise ValueError("page must contain at least one panel")
|
|
132
|
+
occupied: dict[tuple[int, int], int] = {}
|
|
133
|
+
for n, p in enumerate(panels):
|
|
134
|
+
if p.row_span < 1 or p.col_span < 1:
|
|
135
|
+
raise ValueError(f"panel {n}: row_span and col_span must be >= 1")
|
|
136
|
+
if p.row < 0 or p.col < 0 or p.row + p.row_span > rows or p.col + p.col_span > cols:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"panel {n} does not fit the {rows}x{cols} grid (row={p.row} "
|
|
139
|
+
f"col={p.col} row_span={p.row_span} col_span={p.col_span})"
|
|
140
|
+
)
|
|
141
|
+
for r in range(p.row, p.row + p.row_span):
|
|
142
|
+
for c in range(p.col, p.col + p.col_span):
|
|
143
|
+
other = occupied.get((r, c))
|
|
144
|
+
if other is not None:
|
|
145
|
+
raise ValueError(f"panels {other} and {n} overlap at grid cell ({r}, {c})")
|
|
146
|
+
occupied[(r, c)] = n
|
|
147
|
+
ov = dict(p.overrides or {})
|
|
148
|
+
if "x_breaks" in ov:
|
|
149
|
+
raise ValueError(f"panel {n}: x_breaks is not supported on a figure page")
|
|
150
|
+
if "margins" in ov:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"panel {n}: margins are page-level on a figure page; "
|
|
153
|
+
"remove the per-panel margins override"
|
|
154
|
+
)
|
|
155
|
+
_validate_overrides(ov)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _place_label(ax: Any, text: str, pos: str, st: FigureStyle) -> None:
|
|
159
|
+
"""Draw one panel label. ``nw``/``ne`` sit inside the axes at the top
|
|
160
|
+
corner; ``outside`` uses matplotlib's LEFT title slot above the axes,
|
|
161
|
+
which coexists with the panel's own (center) title -- the standard
|
|
162
|
+
journal placement."""
|
|
163
|
+
if not text:
|
|
164
|
+
return
|
|
165
|
+
size = float(st.title_font_size)
|
|
166
|
+
if pos == "outside":
|
|
167
|
+
ax.set_title(text, loc="left", fontweight="bold", fontsize=size)
|
|
168
|
+
elif pos == "ne":
|
|
169
|
+
ax.text(
|
|
170
|
+
0.97, 0.96, text, transform=ax.transAxes,
|
|
171
|
+
ha="right", va="top", fontweight="bold", fontsize=size,
|
|
172
|
+
)
|
|
173
|
+
else: # "nw"
|
|
174
|
+
ax.text(
|
|
175
|
+
0.03, 0.96, text, transform=ax.transAxes,
|
|
176
|
+
ha="left", va="top", fontweight="bold", fontsize=size,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_figure_page(
|
|
181
|
+
panels: Sequence[PagePanel],
|
|
182
|
+
*,
|
|
183
|
+
rows: int,
|
|
184
|
+
cols: int,
|
|
185
|
+
fmt: str = "pdf",
|
|
186
|
+
style: str = "default",
|
|
187
|
+
width_in: float | None = None,
|
|
188
|
+
height_in: float | None = None,
|
|
189
|
+
dpi: int | None = None,
|
|
190
|
+
label_format: str = "(a)",
|
|
191
|
+
label_pos: str = "nw",
|
|
192
|
+
) -> bytes:
|
|
193
|
+
"""Compose ``panels`` onto one rows x cols page and render to image bytes.
|
|
194
|
+
|
|
195
|
+
``fmt`` is ``pdf`` / ``svg`` (vector, the default convention) or ``png`` /
|
|
196
|
+
``tiff`` (raster at ``dpi``; ``None`` = the preset's calibrated dpi).
|
|
197
|
+
``style`` names a publication preset applied page-wide (fonts, line
|
|
198
|
+
widths, box/ticks/grid). Page size: ``width_in`` defaults to the preset's
|
|
199
|
+
figure width -- the journal-column convention the preset encodes (``aps``
|
|
200
|
+
8.6 cm ~ 3.39 in single column; ``aps_double`` 17.8 cm ~ 7.0 in double
|
|
201
|
+
column, the APS sizes) -- and ``height_in`` defaults so each grid cell
|
|
202
|
+
keeps the preset's own aspect ratio. ``label_format`` / ``label_pos``
|
|
203
|
+
control the auto panel labels (see :func:`panel_label`, :func:`_place_label`);
|
|
204
|
+
a panel's explicit ``label`` wins over the auto sequence. Raises
|
|
205
|
+
``ValueError`` on any invalid spec (unknown format/style/label options,
|
|
206
|
+
empty grid, out-of-bounds or overlapping panels).
|
|
207
|
+
"""
|
|
208
|
+
if fmt not in _FORMATS:
|
|
209
|
+
raise ValueError(f"fmt must be one of {_FORMATS}")
|
|
210
|
+
if label_pos not in _LABEL_POSITIONS:
|
|
211
|
+
raise ValueError(f"label_pos must be one of {_LABEL_POSITIONS}")
|
|
212
|
+
if label_format != "none" and label_format not in _LABEL_TEMPLATES:
|
|
213
|
+
allowed = (*_LABEL_TEMPLATES, "none")
|
|
214
|
+
raise ValueError(f"label_format must be one of {allowed}")
|
|
215
|
+
_validate_page(rows, cols, panels)
|
|
216
|
+
st = figure_style(style)
|
|
217
|
+
resolved_dpi = int(dpi) if dpi is not None else int(st.dpi)
|
|
218
|
+
|
|
219
|
+
w = float(width_in) if width_in is not None else st.fig_width_in
|
|
220
|
+
h = (
|
|
221
|
+
float(height_in)
|
|
222
|
+
if height_in is not None
|
|
223
|
+
else w * (st.fig_height_in / st.fig_width_in) * (rows / cols)
|
|
224
|
+
)
|
|
225
|
+
if w <= 0 or h <= 0:
|
|
226
|
+
raise ValueError("width_in and height_in must be positive")
|
|
227
|
+
|
|
228
|
+
# Row-major placement order defines the auto-label sequence.
|
|
229
|
+
ordered = sorted(panels, key=lambda p: (p.row, p.col))
|
|
230
|
+
# (matplotlib's RcParams Literal-key type is impractical with the dynamic
|
|
231
|
+
# font.<generic> key -- same targeted ignore as calc.figure.)
|
|
232
|
+
with matplotlib.rc_context(style_rc(st, {})): # type: ignore[arg-type]
|
|
233
|
+
fig = plt.figure(figsize=(w, h), layout="constrained")
|
|
234
|
+
try:
|
|
235
|
+
gs = fig.add_gridspec(rows, cols)
|
|
236
|
+
for idx, p in enumerate(ordered):
|
|
237
|
+
ax = fig.add_subplot(
|
|
238
|
+
gs[p.row : p.row + p.row_span, p.col : p.col + p.col_span]
|
|
239
|
+
)
|
|
240
|
+
# Rich-text guard (GOTO #5) on every user string; see figure.py.
|
|
241
|
+
series = [(safe_mathtext_label(label), y) for label, y in p.series]
|
|
242
|
+
draw_series_axes(
|
|
243
|
+
fig,
|
|
244
|
+
ax,
|
|
245
|
+
np.asarray(p.x, dtype=float),
|
|
246
|
+
series,
|
|
247
|
+
st=st,
|
|
248
|
+
ov=dict(p.overrides or {}),
|
|
249
|
+
x_log=p.x_log,
|
|
250
|
+
y_log=p.y_log,
|
|
251
|
+
x_scale=p.x_scale,
|
|
252
|
+
y_scale=p.y_scale,
|
|
253
|
+
title=safe_mathtext_label(p.title),
|
|
254
|
+
x_label=safe_mathtext_label(p.x_label),
|
|
255
|
+
y_label=safe_mathtext_label(p.y_label),
|
|
256
|
+
series_styles=p.series_styles,
|
|
257
|
+
x_fmt=p.x_fmt,
|
|
258
|
+
y_fmt=p.y_fmt,
|
|
259
|
+
)
|
|
260
|
+
text = p.label if p.label is not None else panel_label(idx, label_format)
|
|
261
|
+
_place_label(ax, safe_mathtext_label(text), label_pos, st)
|
|
262
|
+
buf = BytesIO()
|
|
263
|
+
fig.savefig(buf, format=fmt, dpi=resolved_dpi)
|
|
264
|
+
return buf.getvalue()
|
|
265
|
+
finally:
|
|
266
|
+
plt.close(fig)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Axis-scale resolution + the reciprocal (1/x) transform for matplotlib
|
|
2
|
+
export (MAIN #12 -- Arrhenius-style plots: ln(rho) or log tau vs 1/T). Pure
|
|
3
|
+
layer. Split out of ``calc.figure`` purely to stay under the 500-line
|
|
4
|
+
god-module ceiling (mirrors ``figure_break``/``figure_overrides``).
|
|
5
|
+
|
|
6
|
+
matplotlib has no built-in reciprocal scale, so this applies one via
|
|
7
|
+
``Axes.set_xscale("function", functions=(f, finv))`` -- matplotlib's own
|
|
8
|
+
documented custom-scale hook (``matplotlib.scale.FuncScale``) -- with the
|
|
9
|
+
self-inverse transform ``f(v) = 1/v`` (``f(f(v)) == v`` for ``v != 0``, so one
|
|
10
|
+
function serves as both the forward and inverse transform) plus a tick
|
|
11
|
+
locator that places ticks at "nice" values evenly spaced IN 1/x SPACE,
|
|
12
|
+
returned in the ORIGINAL x units. That mirrors the screen-side reciprocal
|
|
13
|
+
scale (``frontend/src/lib/uplotOpts.ts``'s ``reciprocalTransform`` /
|
|
14
|
+
``reciprocalAxisSplits``): the axis POSITIONS by 1/x, but tick LABELS still
|
|
15
|
+
read the natural variable (e.g. T in Kelvin) -- Origin's "Reciprocal" axis
|
|
16
|
+
type convention.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from matplotlib.ticker import Locator
|
|
25
|
+
from numpy.typing import NDArray
|
|
26
|
+
|
|
27
|
+
__all__ = ["apply_axis_scale", "reciprocal_tick_values", "resolve_axis_scale"]
|
|
28
|
+
|
|
29
|
+
_SCALES = ("linear", "log", "reciprocal")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_axis_scale(explicit: str | None, legacy_log: bool) -> str:
|
|
33
|
+
"""MAIN #12 back-compat bridge: an explicit ``x_scale``/``y_scale`` (a
|
|
34
|
+
new caller) wins when it's one of the 3 valid tokens; otherwise fall back
|
|
35
|
+
to the old ``x_log``/``y_log`` boolean (``True`` -> ``"log"``, ``False``
|
|
36
|
+
-> ``"linear"``) -- the same convention as the frontend's
|
|
37
|
+
``lib/plotview.ts``'s ``scaleFromLog``."""
|
|
38
|
+
if explicit in _SCALES:
|
|
39
|
+
return explicit
|
|
40
|
+
return "log" if legacy_log else "linear"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _reciprocal(v: NDArray[np.float64] | float) -> NDArray[np.float64]:
|
|
44
|
+
"""``1/v``, self-inverse. Non-positive input degrades to ``NaN`` --
|
|
45
|
+
matplotlib omits a NaN point from the drawn view rather than erroring --
|
|
46
|
+
the SAME domain restriction the log scale already has (physically apt
|
|
47
|
+
for the Arrhenius case: T in Kelvin is always positive)."""
|
|
48
|
+
arr = np.asarray(v, dtype=float)
|
|
49
|
+
out = np.full_like(arr, np.nan)
|
|
50
|
+
pos = arr > 0
|
|
51
|
+
out[pos] = 1.0 / arr[pos]
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _nice_step(raw: float) -> float:
|
|
56
|
+
"""A nice round step >= raw, snapped to 1/2/5/10 x 10^n (mirrors the
|
|
57
|
+
frontend's ``niceLinearStep``)."""
|
|
58
|
+
if raw <= 0 or not np.isfinite(raw):
|
|
59
|
+
return 1.0
|
|
60
|
+
mag = float(10.0 ** np.floor(np.log10(raw)))
|
|
61
|
+
norm = raw / mag
|
|
62
|
+
snapped = 1.0 if norm < 1.5 else 2.0 if norm < 3.0 else 5.0 if norm < 7.0 else 10.0
|
|
63
|
+
return snapped * mag
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def reciprocal_tick_values(vmin: float, vmax: float, target: int = 5) -> list[float]:
|
|
67
|
+
"""Reciprocal-axis tick positions for a ``[vmin, vmax]`` data-space range:
|
|
68
|
+
"nice" values evenly spaced in 1/x space, mapped back to ORIGINAL x units
|
|
69
|
+
(mirrors the frontend's ``reciprocalAxisSplits``). Degenerate ranges
|
|
70
|
+
(non-positive, or inverted/zero-width) return ``[]``."""
|
|
71
|
+
if not (vmin > 0) or not (vmax > vmin):
|
|
72
|
+
return []
|
|
73
|
+
r0, r1 = 1.0 / vmin, 1.0 / vmax
|
|
74
|
+
lo, hi = min(r0, r1), max(r0, r1)
|
|
75
|
+
if not (hi > lo):
|
|
76
|
+
return [vmin, vmax]
|
|
77
|
+
step = _nice_step((hi - lo) / max(1, target))
|
|
78
|
+
eps = 1e-9
|
|
79
|
+
n0 = int(np.ceil(lo / step - eps))
|
|
80
|
+
n1 = int(np.floor(hi / step + eps))
|
|
81
|
+
out: list[float] = []
|
|
82
|
+
for n in range(n0, n1 + 1):
|
|
83
|
+
r = n * step
|
|
84
|
+
if r == 0:
|
|
85
|
+
continue # 1/0 is undefined -- skip the (rare) exact-zero tick
|
|
86
|
+
v = float(np.round(1.0 / r, 10))
|
|
87
|
+
if vmin * (1 - eps) <= v <= vmax * (1 + eps):
|
|
88
|
+
out.append(v)
|
|
89
|
+
return sorted(out)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _ReciprocalLocator(Locator):
|
|
93
|
+
"""Matplotlib tick locator for a reciprocal-scaled axis: ticks land at
|
|
94
|
+
"nice" 1/x-evenly-spaced values, returned in ORIGINAL x units (see
|
|
95
|
+
:func:`reciprocal_tick_values`). ``self.axis`` is set by matplotlib when
|
|
96
|
+
the locator is attached via ``set_major_locator``."""
|
|
97
|
+
|
|
98
|
+
def tick_values(self, vmin: float, vmax: float) -> list[float]:
|
|
99
|
+
return reciprocal_tick_values(min(vmin, vmax), max(vmin, vmax))
|
|
100
|
+
|
|
101
|
+
def __call__(self) -> list[float]:
|
|
102
|
+
if self.axis is None:
|
|
103
|
+
return []
|
|
104
|
+
vmin, vmax = self.axis.get_view_interval()
|
|
105
|
+
return self.tick_values(vmin, vmax)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def apply_axis_scale(ax: Any, axis: str, scale: str) -> None:
|
|
109
|
+
"""Apply ``scale`` (``"linear"``/``"log"``/``"reciprocal"``) to ``ax``'s
|
|
110
|
+
x or y axis (``axis`` is ``"x"`` or ``"y"``). ``"linear"`` is a no-op
|
|
111
|
+
(matplotlib's own default). ``"reciprocal"`` uses the ``"function"``
|
|
112
|
+
scale (matplotlib's documented custom-scale hook,
|
|
113
|
+
:class:`matplotlib.scale.FuncScale`) with the self-inverse 1/x transform
|
|
114
|
+
plus :class:`_ReciprocalLocator` for reciprocal-spaced ticks."""
|
|
115
|
+
set_scale = ax.set_xscale if axis == "x" else ax.set_yscale
|
|
116
|
+
if scale == "log":
|
|
117
|
+
set_scale("log")
|
|
118
|
+
return
|
|
119
|
+
if scale == "reciprocal":
|
|
120
|
+
set_scale("function", functions=(_reciprocal, _reciprocal))
|
|
121
|
+
locator = _ReciprocalLocator()
|
|
122
|
+
if axis == "x":
|
|
123
|
+
ax.xaxis.set_major_locator(locator)
|
|
124
|
+
else:
|
|
125
|
+
ax.yaxis.set_major_locator(locator)
|