quantized-lab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,137 @@
1
+ """Publication rendering for vector field plots (quiver and streamline).
2
+
3
+ ORIGIN_GAP_PLAN #23 / GAP_TIER3_PLAN item 4. Pure layer: gridded (x, y, u, v)
4
+ field data -> quiver or streamline vector plot in image bytes. Shares
5
+ ``calc.figure_styles`` presets and ``calc.figure``'s resolved-dpi convention.
6
+
7
+ Validates that x/y axes have matching grid shape to the (u, v) components.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from io import BytesIO
13
+ from typing import Any
14
+
15
+ import matplotlib
16
+
17
+ matplotlib.use("Agg") # headless
18
+
19
+ import matplotlib.pyplot as plt # noqa: E402
20
+ import numpy as np # noqa: E402
21
+ from numpy.typing import ArrayLike # noqa: E402
22
+
23
+ from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
24
+ from quantized.calc.figure_styles import figure_style # noqa: E402
25
+
26
+ __all__ = ["render_field_figure"]
27
+
28
+ _FORMATS = ("pdf", "svg", "png", "tiff")
29
+ _FIGURE_SIZE_IN = (10, 8) # inches
30
+
31
+
32
+ def render_field_figure(
33
+ x_axis: ArrayLike,
34
+ y_axis: ArrayLike,
35
+ u_grid: ArrayLike,
36
+ v_grid: ArrayLike,
37
+ *,
38
+ kind: str = "quiver",
39
+ fmt: str = "pdf",
40
+ style: str = "default",
41
+ dpi: int | None = None,
42
+ title: str = "",
43
+ x_label: str = "",
44
+ y_label: str = "",
45
+ ) -> bytes:
46
+ """Render a vector field plot (quiver or streamline).
47
+
48
+ ``x_axis`` and ``y_axis`` are 1-D coordinate arrays defining a regular grid.
49
+ ``u_grid`` and ``v_grid`` are 2-D arrays (shape: len(y_axis) × len(x_axis))
50
+ of x and y components of the field at each grid point.
51
+
52
+ ``kind`` is either "quiver" (arrows at each grid point) or "streamline"
53
+ (streamlines following the field). ``fmt``, ``style``, and ``dpi`` follow
54
+ ``render_figure``'s conventions: ``dpi`` defaults to the preset's calibrated
55
+ resolution when not given.
56
+
57
+ ``title``, ``x_label``, and ``y_label`` are optional figure labels.
58
+
59
+ Returns image bytes in the requested ``fmt``. Raises ``ValueError`` on
60
+ malformed input (wrong shape, bad kind, bad format).
61
+ """
62
+ if fmt not in _FORMATS:
63
+ raise ValueError(f"fmt must be one of {_FORMATS}")
64
+ if kind not in ("quiver", "streamline"):
65
+ raise ValueError("kind must be 'quiver' or 'streamline'")
66
+ # Rich-text labels (GOTO #5): de-math INVALID $...$ so savefig never raises.
67
+ title = safe_mathtext_label(title)
68
+ x_label = safe_mathtext_label(x_label)
69
+ y_label = safe_mathtext_label(y_label)
70
+
71
+ x_arr = np.asarray(x_axis, dtype=float)
72
+ y_arr = np.asarray(y_axis, dtype=float)
73
+ u_arr = np.asarray(u_grid, dtype=float)
74
+ v_arr = np.asarray(v_grid, dtype=float)
75
+
76
+ if x_arr.ndim != 1 or y_arr.ndim != 1:
77
+ raise ValueError("x_axis and y_axis must be 1-D arrays")
78
+ if u_arr.ndim != 2 or v_arr.ndim != 2:
79
+ raise ValueError("u_grid and v_grid must be 2-D arrays")
80
+ if u_arr.shape != v_arr.shape:
81
+ raise ValueError("u_grid and v_grid must have the same shape")
82
+ if u_arr.shape != (len(y_arr), len(x_arr)):
83
+ raise ValueError(
84
+ f"u_grid/v_grid shape {u_arr.shape} doesn't match "
85
+ f"grid dimensions ({len(y_arr)}, {len(x_arr)})"
86
+ )
87
+
88
+ st = figure_style(style)
89
+ resolved_dpi = int(dpi) if dpi is not None else int(st.dpi)
90
+ fallback = "DejaVu Serif" if st.font_generic == "serif" else "DejaVu Sans"
91
+ rc: dict[str, Any] = {
92
+ "font.family": st.font_generic,
93
+ f"font.{st.font_generic}": [st.font_name, fallback],
94
+ "font.size": st.font_size,
95
+ "axes.labelsize": st.font_size,
96
+ "axes.titlesize": st.title_font_size,
97
+ "xtick.labelsize": st.font_size - 1,
98
+ "ytick.labelsize": st.font_size - 1,
99
+ "xtick.direction": st.tick_dir,
100
+ "ytick.direction": st.tick_dir,
101
+ }
102
+
103
+ # Build a meshgrid for proper vector field plotting
104
+ xx, yy = np.meshgrid(x_arr, y_arr, indexing="xy")
105
+
106
+ with matplotlib.rc_context(rc): # type: ignore[arg-type]
107
+ fig, ax = plt.subplots(figsize=_FIGURE_SIZE_IN)
108
+ try:
109
+ if kind == "quiver":
110
+ # Quiver: arrows at grid points, colored by magnitude
111
+ magnitude = np.sqrt(u_arr**2 + v_arr**2)
112
+ q = ax.quiver(xx, yy, u_arr, v_arr, magnitude, cmap="viridis")
113
+ fig.colorbar(q, ax=ax, label="Magnitude")
114
+ else: # streamline
115
+ # Streamline: field lines following the vector field
116
+ speed = np.sqrt(u_arr**2 + v_arr**2)
117
+ strm = ax.streamplot(
118
+ x_arr, y_arr, u_arr, v_arr,
119
+ color=speed, cmap="viridis", density=1.5, linewidth=1.0,
120
+ )
121
+ fig.colorbar(strm.lines, ax=ax, label="Speed")
122
+
123
+ ax.set_xlabel(x_label)
124
+ ax.set_ylabel(y_label)
125
+ if title:
126
+ ax.set_title(title, fontsize=st.title_font_size)
127
+
128
+ if not st.box_on:
129
+ ax.spines["top"].set_visible(False)
130
+ ax.spines["right"].set_visible(False)
131
+
132
+ fig.tight_layout()
133
+ buf = BytesIO()
134
+ fig.savefig(buf, format=fmt, dpi=resolved_dpi)
135
+ return buf.getvalue()
136
+ finally:
137
+ plt.close(fig)
@@ -0,0 +1,116 @@
1
+ """Figure-hitmap element collection (gap #13): draw a figure and harvest one
2
+ pixel bounding box per interactive artist (title / axis labels / legend /
3
+ series lines / annotations) + the axes rect with data limits, so the client
4
+ can hit-test a preview render and map pixels back to data coordinates.
5
+
6
+ Split out of ``calc.figure`` purely to stay under the 500-line god-module
7
+ ceiling (mirrors ``figure_break``/``figure_scale``/``figure_overrides``);
8
+ ``figure.render_figure_map`` (``_render_impl(..., collect_map=True)``) is the
9
+ only caller. Pure layer: a live ``Figure``/``Axes`` in -> a plain dict out.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ from collections.abc import Sequence
16
+ from io import BytesIO
17
+ from typing import Any
18
+
19
+ __all__ = ["collect_map"]
20
+
21
+
22
+ def _bbox_to_pixels(bbox: Any, height: float) -> dict[str, float]:
23
+ """Window extent (origin bottom-left) -> image pixels (origin top-left)."""
24
+ return {
25
+ "x0": float(bbox.x0),
26
+ "y0": float(height - bbox.y1),
27
+ "x1": float(bbox.x1),
28
+ "y1": float(height - bbox.y0),
29
+ }
30
+
31
+
32
+ def _artist_window_extent(artist: Any, renderer: Any) -> Any:
33
+ """``artist.get_window_extent(renderer)``, with a workaround for
34
+ matplotlib's ``Collection`` (what ``ax.scatter`` -- MAIN #14's colour-
35
+ mapped scatter -- returns): ``Collection.get_window_extent`` calls
36
+ ``get_datalim(IdentityTransform())`` instead of transforming to display
37
+ space, which returns a degenerate all-``inf`` bbox for a plain scatter.
38
+ Detected via ``get_offsets``/``get_offset_transform`` (present on any
39
+ ``Collection`` with point offsets, scatter included) -- compute the real
40
+ screen-space bbox from the transformed offsets instead. Falls through to
41
+ the artist's own ``get_window_extent`` for everything else (``Line2D``,
42
+ ``Text``, ``Legend``, ...)."""
43
+ get_offsets = getattr(artist, "get_offsets", None)
44
+ get_offset_transform = getattr(artist, "get_offset_transform", None)
45
+ if get_offsets is not None and get_offset_transform is not None:
46
+ pts = get_offset_transform().transform(get_offsets())
47
+ if len(pts):
48
+ from matplotlib.transforms import Bbox
49
+
50
+ return Bbox([pts.min(axis=0), pts.max(axis=0)])
51
+ return artist.get_window_extent(renderer)
52
+
53
+
54
+ def collect_map(
55
+ fig: Any, ax: Any, *, series_artists: Sequence[Any], dpi: int, x_scale: str, y_scale: str
56
+ ) -> dict[str, Any]:
57
+ """Draw at ``dpi`` and harvest artist extents in image-pixel coords.
58
+ ``series_artists`` is ``figure.draw_series_axes``'s return value (one
59
+ artist per series, in order -- a ``Line2D`` normally, a
60
+ ``PathCollection`` for a colour-mapped-scatter series) rather than
61
+ re-derived from ``ax.lines``: a colour-mapped series draws via
62
+ ``ax.scatter``, so it has NO entry in ``ax.lines`` at all -- indexing
63
+ ``ax.lines[:n_series]`` would silently misalign every series hit-box
64
+ after it. ``x_scale``/``y_scale`` are the ALREADY-RESOLVED scale names
65
+ (MAIN #12) -- not re-derived from ``ax.get_xscale()``, which reports a
66
+ reciprocal axis as ``"function"`` (matplotlib's generic custom-scale
67
+ name), not ``"reciprocal"`` -- the client's ``lib/previewmap.ts`` needs
68
+ the real name to invert a preview pixel drag back to data coordinates
69
+ (``pxToData``)."""
70
+ fig.set_dpi(dpi)
71
+ fig.canvas.draw()
72
+ renderer = fig.canvas.get_renderer()
73
+ width, height = fig.canvas.get_width_height()
74
+
75
+ elements: list[dict[str, Any]] = []
76
+
77
+ def add(el_id: str, artist: Any) -> None:
78
+ try:
79
+ bbox = _artist_window_extent(artist, renderer)
80
+ except (RuntimeError, AttributeError):
81
+ return
82
+ if bbox.width <= 0 or bbox.height <= 0:
83
+ return
84
+ elements.append({"id": el_id, **_bbox_to_pixels(bbox, height)})
85
+
86
+ if ax.get_title():
87
+ add("title", ax.title)
88
+ if ax.get_xlabel():
89
+ add("xlabel", ax.xaxis.label)
90
+ if ax.get_ylabel():
91
+ add("ylabel", ax.yaxis.label)
92
+ if ax.get_legend() is not None:
93
+ add("legend", ax.get_legend())
94
+ for i, artist in enumerate(series_artists):
95
+ add(f"series:{i}", artist)
96
+ for i, txt in enumerate(ax.texts):
97
+ add(f"ann:{i}", txt)
98
+
99
+ axes_px = _bbox_to_pixels(ax.get_window_extent(renderer), height)
100
+ buf = BytesIO()
101
+ fig.savefig(buf, format="png")
102
+ return {
103
+ "image": base64.b64encode(buf.getvalue()).decode("ascii"),
104
+ "width": int(width),
105
+ "height": int(height),
106
+ "elements": elements,
107
+ "axes": {
108
+ **axes_px,
109
+ "xlim": [float(v) for v in ax.get_xlim()],
110
+ "ylim": [float(v) for v in ax.get_ylim()],
111
+ "xlog": x_scale == "log",
112
+ "ylog": y_scale == "log",
113
+ "xscale": x_scale,
114
+ "yscale": y_scale,
115
+ },
116
+ }
@@ -0,0 +1,62 @@
1
+ """Mathtext label guard for figure export (GOTO #5 rich-text labels).
2
+
3
+ Labels flow from the UI as plain text with optional ``$...$`` mathtext
4
+ regions (the frontend's ``lib/richtext`` micro-syntax is a strict subset of
5
+ matplotlib mathtext, so passthrough is the rendering mechanism). matplotlib
6
+ parses math regions at DRAW time, which means an invalid label would raise
7
+ inside ``fig.savefig`` and turn an export into a 500. This module is the
8
+ shared chokepoint every ``calc.figure*`` renderer routes its user-supplied
9
+ strings through: it pre-validates with matplotlib's own mathtext parser and,
10
+ on failure, escapes the dollar signs so the label renders as literal text
11
+ (matplotlib renders ``\\$`` as a plain ``$``) -- an invalid label must NEVER
12
+ error an export.
13
+
14
+ Pure layer: string in -> string out. matplotlib is imported lazily (same
15
+ convention as the figure modules -- the heavy import is paid only on export).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from functools import lru_cache
22
+ from typing import Any
23
+
24
+ __all__ = ["safe_mathtext_label"]
25
+
26
+ # A "$" not preceded by a backslash -- matplotlib's own math-region rule
27
+ # (matplotlib.cbook.is_math_text counts these; an odd count means the string
28
+ # is rendered as literal text, dollars visible, no mathtext at all).
29
+ _UNESCAPED_DOLLAR = re.compile(r"(?<!\\)\$")
30
+
31
+
32
+ @lru_cache(maxsize=1)
33
+ def _parser() -> Any:
34
+ """The mathtext trial parser (cached -- construction is not free)."""
35
+ from matplotlib.mathtext import MathTextParser
36
+
37
+ return MathTextParser("agg")
38
+
39
+
40
+ def safe_mathtext_label(label: str) -> str:
41
+ """Return ``label`` unchanged when it renders safely; else de-math it.
42
+
43
+ - No ``$`` at all: fast path, returned as-is (byte-identical to today).
44
+ - Odd count of unescaped ``$``: matplotlib already treats the whole
45
+ string as literal text -- returned as-is.
46
+ - Balanced ``$...$`` regions: trial-parsed with matplotlib's mathtext
47
+ parser. Valid -> unchanged (mathtext renders it at export). Invalid ->
48
+ every unescaped ``$`` is escaped to ``\\$`` so matplotlib renders the
49
+ literal string (dollars visible) instead of raising at savefig time.
50
+ """
51
+ if not label or "$" not in label:
52
+ return label
53
+ n_math = len(_UNESCAPED_DOLLAR.findall(label))
54
+ if n_math == 0 or n_math % 2 == 1:
55
+ # Zero unescaped $ (all \$-escaped) or an odd count: matplotlib
56
+ # already renders the whole string as literal text -- leave it be.
57
+ return label
58
+ try:
59
+ _parser().parse(label)
60
+ except Exception: # ANY parse failure means "render literal", never raise
61
+ return _UNESCAPED_DOLLAR.sub(r"\\$", label)
62
+ return label
@@ -0,0 +1,287 @@
1
+ """Publication rendering for 2-D maps: contour, filled contour, 3-D surface.
2
+
3
+ ORIGIN_GAP_PLAN #17 (filled/labeled contour, incl. the scattered tri-contour
4
+ remainder) + #19 (3-D static export). Pure layer: either a gridded map
5
+ (``x_axis``/``y_axis``/``z_grid``, the ``calc.map.MapData`` shape,
6
+ ``contour_source="grid"``) or a raw scattered cloud (``x_axis``/``y_axis``/
7
+ ``z_values`` all the same length -- the RSM point-cloud shape produced by
8
+ ``io/_xrdml_scan.py``'s snapshot/coupled layouts, ``contour_source="points"``)
9
+ in -> image bytes out, via matplotlib (vector by default), matching
10
+ ``calc.figure.render_figure``'s style/format/dpi/tick conventions so 1-D and
11
+ 2-D exports share presets. 3-D (surface / scatter / waterfall) is the static
12
+ publication path; interactive 3-D is deferred (#22).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from io import BytesIO
18
+ from typing import Any
19
+
20
+ import matplotlib
21
+
22
+ matplotlib.use("Agg") # headless
23
+
24
+ import matplotlib.pyplot as plt # noqa: E402
25
+ import matplotlib.tri as mtri # noqa: E402
26
+ import numpy as np # noqa: E402
27
+ from mpl_toolkits.mplot3d import Axes3D # noqa: E402,F401 (registers the 3d projection)
28
+ from numpy.typing import ArrayLike, NDArray # noqa: E402
29
+
30
+ from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
31
+ from quantized.calc.figure_styles import figure_style # noqa: E402
32
+
33
+ __all__ = ["MAP_KINDS", "render_map_figure"]
34
+
35
+ _FORMATS = ("pdf", "svg", "png", "tiff")
36
+ MAP_KINDS = ("contourf", "contour", "heatmap", "surface", "scatter3d", "waterfall")
37
+ _3D_KINDS = ("surface", "scatter3d", "waterfall")
38
+ _CONTOUR_SOURCES = ("grid", "points")
39
+ _POINTS_KINDS = ("contour", "contourf") # tricontour has no heatmap/3-D analogue here
40
+
41
+
42
+ def _contour_levels(
43
+ z_min: float, z_max: float, levels: int | list[float], scale: str
44
+ ) -> NDArray[np.float64]:
45
+ """Explicit contour levels: ``levels`` count (or a list), lin or log spaced."""
46
+ if isinstance(levels, (list, tuple)):
47
+ arr = np.asarray(sorted(float(x) for x in levels), dtype=float)
48
+ if arr.size < 2: # matplotlib's contourf needs >= 2 levels
49
+ raise ValueError("levels list needs at least 2 entries")
50
+ return arr
51
+ n = int(levels)
52
+ if n < 2:
53
+ raise ValueError("levels count must be >= 2")
54
+ if not (np.isfinite(z_min) and np.isfinite(z_max) and z_max > z_min):
55
+ raise ValueError("map has no finite z-range to contour")
56
+ if scale == "log":
57
+ if z_max <= 0:
58
+ raise ValueError("log level_scale needs a positive z-range")
59
+ lo = z_min if z_min > 0 else z_max * 1e-3
60
+ return np.asarray(np.logspace(np.log10(lo), np.log10(z_max), n), dtype=float)
61
+ if scale != "linear":
62
+ raise ValueError("level_scale must be 'linear' or 'log'")
63
+ return np.asarray(np.linspace(z_min, z_max, n), dtype=float)
64
+
65
+
66
+ def render_map_figure(
67
+ x_axis: ArrayLike,
68
+ y_axis: ArrayLike,
69
+ z_grid: ArrayLike | None = None,
70
+ *,
71
+ contour_source: str = "grid",
72
+ z_values: ArrayLike | None = None,
73
+ kind: str = "contourf",
74
+ title: str = "",
75
+ x_label: str = "",
76
+ y_label: str = "",
77
+ z_label: str = "",
78
+ fmt: str = "pdf",
79
+ style: str = "default",
80
+ cmap: str = "viridis",
81
+ levels: int | list[float] = 12,
82
+ level_scale: str = "linear",
83
+ label_contours: bool = True,
84
+ colorbar: bool = True,
85
+ width_in: float | None = None,
86
+ height_in: float | None = None,
87
+ dpi: int | None = None,
88
+ view_elev: float = 30.0,
89
+ view_azim: float = -60.0,
90
+ ) -> bytes:
91
+ """Render a 2-D map to image bytes in the chosen ``kind``.
92
+
93
+ Two input shapes, selected by ``contour_source``:
94
+
95
+ - ``"grid"`` (default) — ``z_grid`` is ``(ny, nx)`` over ``x_axis``
96
+ ``(nx,)`` / ``y_axis`` ``(ny,)`` (NaN outside the data hull is left
97
+ blank), the regridded ``calc.map.MapData`` shape. All of ``MAP_KINDS``
98
+ are available.
99
+ - ``"points"`` — ``x_axis`` / ``y_axis`` / ``z_values`` are raw scattered
100
+ arrays of equal length (e.g. an RSM point cloud straight off
101
+ ``io/_xrdml_scan.py``'s snapshot/coupled layouts, never regridded).
102
+ Only ``kind`` ``"contour"`` / ``"contourf"`` apply; the cloud is
103
+ Delaunay-triangulated (``matplotlib.tri.Triangulation``) and drawn with
104
+ ``tricontour`` / ``tricontourf``. Degenerate input (e.g. collinear
105
+ points, or fewer than 3 finite points) raises ``ValueError``.
106
+
107
+ ``kind``:
108
+
109
+ - ``contourf`` / ``contour`` — filled / line contours; ``levels`` is a
110
+ count or explicit list, ``level_scale`` lin or log, ``label_contours``
111
+ draws inline labels on the line variant. Same level semantics
112
+ (:func:`_contour_levels`) for both ``contour_source`` values.
113
+ - ``heatmap`` — ``pcolormesh`` of the grid (``"grid"`` source only).
114
+ - ``surface`` / ``scatter3d`` / ``waterfall`` — static 3-D (mplot3d),
115
+ viewed from (``view_elev``, ``view_azim``) (``"grid"`` source only).
116
+
117
+ ``fmt`` / ``style`` / size overrides match ``render_figure``. ``dpi``
118
+ defaults to the style preset's calibrated resolution when not given
119
+ (``None``), same as ``calc.figure``'s ``resolved_dpi`` convention; the
120
+ preset's box-tick convention (``xtick.top``/``ytick.right`` mirrored
121
+ when the preset draws a closed box) is honored too.
122
+ """
123
+ if fmt not in _FORMATS:
124
+ raise ValueError(f"fmt must be one of {_FORMATS}")
125
+ if kind not in MAP_KINDS:
126
+ raise ValueError(f"kind must be one of {MAP_KINDS}")
127
+ if contour_source not in _CONTOUR_SOURCES:
128
+ raise ValueError(f"contour_source must be one of {_CONTOUR_SOURCES}")
129
+ # Rich-text labels (GOTO #5): de-math INVALID $...$ so savefig never raises.
130
+ title = safe_mathtext_label(title)
131
+ x_label = safe_mathtext_label(x_label)
132
+ y_label = safe_mathtext_label(y_label)
133
+ z_label = safe_mathtext_label(z_label)
134
+ st = figure_style(style)
135
+ resolved_dpi = int(dpi) if dpi is not None else int(st.dpi)
136
+
137
+ if contour_source == "points":
138
+ if kind not in _POINTS_KINDS:
139
+ raise ValueError(
140
+ f"contour_source='points' only supports kind {_POINTS_KINDS}, got {kind!r}"
141
+ )
142
+ if z_values is None:
143
+ raise ValueError("z_values is required when contour_source='points'")
144
+ xr = np.asarray(x_axis, dtype=float).ravel()
145
+ yr = np.asarray(y_axis, dtype=float).ravel()
146
+ zr = np.asarray(z_values, dtype=float).ravel()
147
+ if not (xr.size == yr.size == zr.size):
148
+ raise ValueError(
149
+ "x_axis/y_axis/z_values must have the same length for "
150
+ f"contour_source='points', got {xr.size}/{yr.size}/{zr.size}"
151
+ )
152
+ finite = np.isfinite(xr) & np.isfinite(yr) & np.isfinite(zr)
153
+ x, y, z = xr[finite], yr[finite], zr[finite]
154
+ if x.size < 3:
155
+ raise ValueError("need at least 3 finite points to triangulate a scattered contour")
156
+ z_min, z_max = float(np.min(z)), float(np.max(z))
157
+ else:
158
+ if z_grid is None:
159
+ raise ValueError("z_grid is required when contour_source='grid'")
160
+ x = np.asarray(x_axis, dtype=float).ravel()
161
+ y = np.asarray(y_axis, dtype=float).ravel()
162
+ z = np.asarray(z_grid, dtype=float)
163
+ if z.ndim != 2 or z.shape != (y.size, x.size):
164
+ raise ValueError(f"z_grid must be (ny, nx) = ({y.size}, {x.size}), got {z.shape}")
165
+ if x.size < 2 or y.size < 2:
166
+ raise ValueError(f"a map needs at least a 2x2 grid, got ({y.size}, {x.size})")
167
+
168
+ if np.any(np.isfinite(z)):
169
+ z_min, z_max = float(np.nanmin(z)), float(np.nanmax(z))
170
+ else:
171
+ z_min = z_max = float("nan") # all-gaps map; contour kinds raise below
172
+
173
+ figsize = (width_in or st.fig_width_in, height_in or st.fig_height_in)
174
+ fallback = "DejaVu Serif" if st.font_generic == "serif" else "DejaVu Sans"
175
+ rc: dict[str, Any] = {
176
+ "font.family": st.font_generic,
177
+ f"font.{st.font_generic}": [st.font_name, fallback],
178
+ "font.size": st.font_size,
179
+ "axes.labelsize": st.font_size,
180
+ "axes.titlesize": st.title_font_size,
181
+ # Mirror ticks onto the top/right spines whenever the preset draws a
182
+ # closed box (matches calc.figure's convention; matplotlib's default
183
+ # leaves top/right bare even with the full rectangular border).
184
+ "xtick.top": st.box_on,
185
+ "ytick.right": st.box_on,
186
+ }
187
+
188
+ with matplotlib.rc_context(rc): # type: ignore[arg-type]
189
+ if kind in _3D_KINDS:
190
+ fig = plt.figure(figsize=figsize)
191
+ ax = fig.add_subplot(projection="3d")
192
+ else:
193
+ fig, ax = plt.subplots(figsize=figsize)
194
+ try:
195
+ mappable = _draw(
196
+ ax, kind, x, y, z, z_min, z_max, cmap, levels, level_scale,
197
+ label_contours, view_elev, view_azim, contour_source=contour_source,
198
+ )
199
+ if title:
200
+ ax.set_title(title)
201
+ if x_label:
202
+ ax.set_xlabel(x_label)
203
+ if y_label:
204
+ ax.set_ylabel(y_label)
205
+ if kind in _3D_KINDS and z_label:
206
+ ax.set_zlabel(z_label)
207
+ if colorbar and mappable is not None:
208
+ fig.colorbar(mappable, ax=ax, label=z_label or None, shrink=0.8)
209
+ fig.tight_layout()
210
+ buf = BytesIO()
211
+ fig.savefig(buf, format=fmt, dpi=resolved_dpi)
212
+ return buf.getvalue()
213
+ finally:
214
+ plt.close(fig)
215
+
216
+
217
+ def _draw(
218
+ ax: Any,
219
+ kind: str,
220
+ x: NDArray[np.float64],
221
+ y: NDArray[np.float64],
222
+ z: NDArray[np.float64],
223
+ z_min: float,
224
+ z_max: float,
225
+ cmap: str,
226
+ levels: int | list[float],
227
+ level_scale: str,
228
+ label_contours: bool,
229
+ view_elev: float,
230
+ view_azim: float,
231
+ *,
232
+ contour_source: str = "grid",
233
+ ) -> Any:
234
+ """Draw the requested mark; return the colorbar mappable (or None)."""
235
+ if contour_source == "points":
236
+ # x/y/z are flat, equal-length scattered arrays here (not a grid) --
237
+ # Delaunay-triangulate the cloud and contour straight off the
238
+ # triangulation, no regridding. Same level semantics as the grid path.
239
+ lv = _contour_levels(z_min, z_max, levels, level_scale)
240
+ try:
241
+ tri = mtri.Triangulation(x, y)
242
+ except RuntimeError as exc:
243
+ # qhull raises RuntimeError on degenerate input (e.g. all points
244
+ # collinear) -- surface it as a clean ValueError (-> 422), not a
245
+ # matplotlib internals leak.
246
+ raise ValueError(
247
+ "points are degenerate (e.g. collinear) and cannot be triangulated"
248
+ ) from exc
249
+ if kind == "contourf":
250
+ return ax.tricontourf(tri, z, levels=lv, cmap=cmap)
251
+ cs = ax.tricontour(tri, z, levels=lv, cmap=cmap)
252
+ if label_contours:
253
+ ax.clabel(cs, inline=True, fontsize=7, fmt="%.3g")
254
+ return cs
255
+
256
+ if kind == "heatmap":
257
+ return ax.pcolormesh(x, y, z, cmap=cmap, shading="auto")
258
+ if kind in ("contourf", "contour"):
259
+ lv = _contour_levels(z_min, z_max, levels, level_scale)
260
+ if kind == "contourf":
261
+ return ax.contourf(x, y, z, levels=lv, cmap=cmap)
262
+ cs = ax.contour(x, y, z, levels=lv, cmap=cmap)
263
+ if label_contours:
264
+ ax.clabel(cs, inline=True, fontsize=7, fmt="%.3g")
265
+ return cs
266
+
267
+ # 3-D kinds
268
+ ax.view_init(elev=view_elev, azim=view_azim)
269
+ xg, yg = np.meshgrid(x, y) # (ny, nx)
270
+ if kind == "surface":
271
+ return ax.plot_surface(xg, yg, z, cmap=cmap, linewidth=0, antialiased=True)
272
+ if kind == "scatter3d":
273
+ finite = np.isfinite(z)
274
+ sc = ax.scatter(
275
+ xg[finite], yg[finite], z[finite], c=z[finite], cmap=cmap, s=6, depthshade=True
276
+ )
277
+ return sc
278
+ # waterfall: one profile line per y-row, stacked in depth
279
+ line_cmap = matplotlib.colormaps[cmap]
280
+ for j in range(y.size):
281
+ row = z[j]
282
+ finite = np.isfinite(row)
283
+ if not finite.any():
284
+ continue
285
+ frac = j / max(1, y.size - 1)
286
+ ax.plot(x[finite], np.full(int(finite.sum()), y[j]), row[finite], color=line_cmap(frac))
287
+ return None