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,303 @@
|
|
|
1
|
+
"""Headless calculator registry — the scripting/automation entry point for the
|
|
2
|
+
DiraCulator calculator domains (the Python analogue of MATLAB ``api = DiraCulator()``).
|
|
3
|
+
|
|
4
|
+
The ``calc/`` domain modules are already pure and headless; this module gives them
|
|
5
|
+
a **single, discoverable, stable surface**: a name → pure-function catalog so any
|
|
6
|
+
calculation can be listed, described, and invoked by name with a params dict —
|
|
7
|
+
without the GUI, the HTTP routes, or knowing each module's import path.
|
|
8
|
+
|
|
9
|
+
>>> from quantized.calc.registry import call_calculator, list_calculators
|
|
10
|
+
>>> call_calculator("crystal.d_spacing", {"system": "cubic",
|
|
11
|
+
... "a": 5.4309, "b": 5.4309, "c": 5.4309, "h": 1, "k": 1, "l": 1})["d"]
|
|
12
|
+
3.135...
|
|
13
|
+
|
|
14
|
+
Discovery:
|
|
15
|
+
|
|
16
|
+
>>> [op["name"] for op in list_calculators(domain="xray")]
|
|
17
|
+
['xray.bragg_d_spacing', 'xray.bragg_two_theta', 'xray.q_from_two_theta', ...]
|
|
18
|
+
>>> describe_calculator("xray.bragg_d_spacing")["params"] # signature params
|
|
19
|
+
[{'name': 'two_theta', 'required': True, 'default': None}, ...]
|
|
20
|
+
|
|
21
|
+
Pure layer: imports only ``quantized.calc.*`` (no fastapi/pydantic). The thin
|
|
22
|
+
``routes/calc.py`` adapter exposes the same catalog + call over HTTP; a CLI could
|
|
23
|
+
wrap it identically. Operation names are ``<domain>.<operation>`` and are stable —
|
|
24
|
+
treat them as API.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import inspect
|
|
30
|
+
import math
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from quantized.calc import (
|
|
36
|
+
constants,
|
|
37
|
+
crystallography,
|
|
38
|
+
diffusion,
|
|
39
|
+
electrical,
|
|
40
|
+
electrochemistry,
|
|
41
|
+
magnetic,
|
|
42
|
+
optics,
|
|
43
|
+
semiconductor,
|
|
44
|
+
sld_formula,
|
|
45
|
+
substrates,
|
|
46
|
+
superconductor,
|
|
47
|
+
thermal,
|
|
48
|
+
thin_film,
|
|
49
|
+
unit_convert,
|
|
50
|
+
vacuum,
|
|
51
|
+
xray,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"CALCULATORS",
|
|
56
|
+
"CalcOp",
|
|
57
|
+
"call_calculator",
|
|
58
|
+
"describe_calculator",
|
|
59
|
+
"list_calculators",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class CalcOp:
|
|
65
|
+
"""One headless calculator operation: a stable name → pure function.
|
|
66
|
+
|
|
67
|
+
``name`` is ``<domain>.<operation>``; ``fn`` is the pure ``calc`` callable it
|
|
68
|
+
dispatches to. The human summary is derived from the function's docstring, so
|
|
69
|
+
it never drifts from the implementation.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
domain: str
|
|
74
|
+
fn: Callable[..., Any]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def summary(self) -> str:
|
|
78
|
+
doc = (self.fn.__doc__ or "").strip()
|
|
79
|
+
return doc.splitlines()[0].strip() if doc else ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ops(domain: str, module: object, names: list[str]) -> list[CalcOp]:
|
|
83
|
+
"""Register ``<domain>.<name>`` → ``module.<name>`` for each name."""
|
|
84
|
+
return [CalcOp(f"{domain}.{n}", domain, getattr(module, n)) for n in names]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# The curated catalog. Operation names are stable public API; the second segment
|
|
88
|
+
# is the calc function name (so a name maps predictably to its source), except the
|
|
89
|
+
# two aliased singletons (units.convert / constants.list).
|
|
90
|
+
_OPS: list[CalcOp] = [
|
|
91
|
+
CalcOp("units.convert", "units", unit_convert.unit_convert),
|
|
92
|
+
CalcOp("constants.list", "constants", constants.constants),
|
|
93
|
+
*_ops(
|
|
94
|
+
"xray",
|
|
95
|
+
xray,
|
|
96
|
+
["bragg_d_spacing", "bragg_two_theta", "q_from_two_theta", "two_theta_from_q", "xray_calc"],
|
|
97
|
+
),
|
|
98
|
+
*_ops(
|
|
99
|
+
"crystal",
|
|
100
|
+
crystallography,
|
|
101
|
+
["d_spacing", "cell_volume", "theoretical_density", "plane_spacings"],
|
|
102
|
+
),
|
|
103
|
+
*_ops("sld", sld_formula, ["sld_from_formula"]),
|
|
104
|
+
*_ops(
|
|
105
|
+
"electrical",
|
|
106
|
+
electrical,
|
|
107
|
+
[
|
|
108
|
+
"resistivity",
|
|
109
|
+
"sheet_resistance",
|
|
110
|
+
"conductivity",
|
|
111
|
+
"mobility",
|
|
112
|
+
"current_density",
|
|
113
|
+
"hall_single_point",
|
|
114
|
+
"hall_analysis",
|
|
115
|
+
"wiedemann_franz",
|
|
116
|
+
],
|
|
117
|
+
),
|
|
118
|
+
*_ops("thermal", thermal, ["wiedemann_franz", "debye_temperature", "thermal_diffusivity"]),
|
|
119
|
+
*_ops("diffusion", diffusion, ["arrhenius", "diffusion_length", "fick_flux"]),
|
|
120
|
+
*_ops(
|
|
121
|
+
"optics",
|
|
122
|
+
optics,
|
|
123
|
+
[
|
|
124
|
+
"fresnel_coefficients",
|
|
125
|
+
"critical_angle",
|
|
126
|
+
"brewster_angle",
|
|
127
|
+
"penetration_depth",
|
|
128
|
+
"skin_depth",
|
|
129
|
+
"dielectric_to_refractive",
|
|
130
|
+
"refractive_to_dielectric",
|
|
131
|
+
],
|
|
132
|
+
),
|
|
133
|
+
*_ops(
|
|
134
|
+
"vacuum",
|
|
135
|
+
vacuum,
|
|
136
|
+
[
|
|
137
|
+
"mean_free_path",
|
|
138
|
+
"monolayer_time",
|
|
139
|
+
"knudsen_number",
|
|
140
|
+
"pump_down_time",
|
|
141
|
+
"sputter_yield",
|
|
142
|
+
"gas_flow",
|
|
143
|
+
],
|
|
144
|
+
),
|
|
145
|
+
*_ops(
|
|
146
|
+
"electrochemistry",
|
|
147
|
+
electrochemistry,
|
|
148
|
+
[
|
|
149
|
+
"nernst_potential",
|
|
150
|
+
"butler_volmer",
|
|
151
|
+
"tafel_slope",
|
|
152
|
+
"ohmic_drop",
|
|
153
|
+
"double_layer_capacitance",
|
|
154
|
+
],
|
|
155
|
+
),
|
|
156
|
+
*_ops(
|
|
157
|
+
"substrates",
|
|
158
|
+
substrates,
|
|
159
|
+
["get_substrate", "list_substrates", "lattice_mismatch", "substrate_table"],
|
|
160
|
+
),
|
|
161
|
+
*_ops(
|
|
162
|
+
"semiconductor",
|
|
163
|
+
semiconductor,
|
|
164
|
+
[
|
|
165
|
+
"intrinsic_carrier_conc",
|
|
166
|
+
"carrier_concentration",
|
|
167
|
+
"fermi_level",
|
|
168
|
+
"built_in_potential",
|
|
169
|
+
"depletion_width",
|
|
170
|
+
"debye_length",
|
|
171
|
+
"hall_coefficient",
|
|
172
|
+
"mobility_model",
|
|
173
|
+
"thermal_velocity",
|
|
174
|
+
"sheet_carrier_density",
|
|
175
|
+
"diffusion_coeff",
|
|
176
|
+
"diffusion_length",
|
|
177
|
+
"dos_effective_mass",
|
|
178
|
+
"material_presets",
|
|
179
|
+
],
|
|
180
|
+
),
|
|
181
|
+
*_ops(
|
|
182
|
+
"superconductor",
|
|
183
|
+
superconductor,
|
|
184
|
+
[
|
|
185
|
+
"london_depth",
|
|
186
|
+
"coherence_length",
|
|
187
|
+
"gl_parameter",
|
|
188
|
+
"critical_fields",
|
|
189
|
+
"depairing_current",
|
|
190
|
+
"bcs_gap",
|
|
191
|
+
"material_presets",
|
|
192
|
+
],
|
|
193
|
+
),
|
|
194
|
+
*_ops(
|
|
195
|
+
"thinfilm",
|
|
196
|
+
thin_film,
|
|
197
|
+
[
|
|
198
|
+
"deposition_rate",
|
|
199
|
+
"sputter_rate",
|
|
200
|
+
"kiessig_thickness",
|
|
201
|
+
"stoney_stress",
|
|
202
|
+
"projected_range",
|
|
203
|
+
"multilayer_thermal_conductivity",
|
|
204
|
+
"thermal_mismatch_strain",
|
|
205
|
+
"diffusion_length_thermal",
|
|
206
|
+
"dose_from_current",
|
|
207
|
+
"dose_to_concentration",
|
|
208
|
+
],
|
|
209
|
+
),
|
|
210
|
+
*_ops(
|
|
211
|
+
"magnetic",
|
|
212
|
+
magnetic,
|
|
213
|
+
[
|
|
214
|
+
"moment_convert",
|
|
215
|
+
"bohr_magneton_convert",
|
|
216
|
+
"demag_factor",
|
|
217
|
+
"demag_named",
|
|
218
|
+
"curie_weiss_moment",
|
|
219
|
+
"curie_weiss_fit",
|
|
220
|
+
"langevin",
|
|
221
|
+
"magnetization",
|
|
222
|
+
"domain_wall",
|
|
223
|
+
"moment_per_atom",
|
|
224
|
+
],
|
|
225
|
+
),
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
CALCULATORS: dict[str, CalcOp] = {op.name: op for op in _OPS}
|
|
229
|
+
|
|
230
|
+
# Stable-name invariant: no domain accidentally registers two ops under one name.
|
|
231
|
+
assert len(CALCULATORS) == len(_OPS), "duplicate calculator operation name"
|
|
232
|
+
|
|
233
|
+
DOMAINS: tuple[str, ...] = tuple(dict.fromkeys(op.domain for op in _OPS))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _jsonable(value: Any) -> Any:
|
|
237
|
+
"""A signature default reduced to a JSON-safe form (or ``str`` fallback)."""
|
|
238
|
+
if value is None or isinstance(value, bool | int | str):
|
|
239
|
+
return value
|
|
240
|
+
if isinstance(value, float):
|
|
241
|
+
return None if math.isnan(value) else value
|
|
242
|
+
return str(value)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _param_specs(fn: Callable[..., Any]) -> list[dict[str, Any]]:
|
|
246
|
+
"""Positional/keyword params of ``fn`` (name, required, default) for discovery."""
|
|
247
|
+
specs: list[dict[str, Any]] = []
|
|
248
|
+
for name, p in inspect.signature(fn).parameters.items():
|
|
249
|
+
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
250
|
+
continue
|
|
251
|
+
required = p.default is inspect.Parameter.empty
|
|
252
|
+
specs.append(
|
|
253
|
+
{
|
|
254
|
+
"name": name,
|
|
255
|
+
"required": required,
|
|
256
|
+
"default": None if required else _jsonable(p.default),
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
return specs
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def list_calculators(domain: str | None = None) -> list[dict[str, Any]]:
|
|
263
|
+
"""The catalog: ``{name, domain, summary}`` for every op (optionally one domain)."""
|
|
264
|
+
ops = _OPS if domain is None else [op for op in _OPS if op.domain == domain]
|
|
265
|
+
return [{"name": op.name, "domain": op.domain, "summary": op.summary} for op in ops]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def describe_calculator(name: str) -> dict[str, Any]:
|
|
269
|
+
"""Full description of one op: name, domain, summary, and its signature params."""
|
|
270
|
+
op = CALCULATORS.get(name)
|
|
271
|
+
if op is None:
|
|
272
|
+
raise KeyError(_unknown_message(name))
|
|
273
|
+
return {
|
|
274
|
+
"name": op.name,
|
|
275
|
+
"domain": op.domain,
|
|
276
|
+
"summary": op.summary,
|
|
277
|
+
"params": _param_specs(op.fn),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def call_calculator(name: str, params: dict[str, Any] | None = None) -> Any:
|
|
282
|
+
"""Invoke a calculator by name with a params dict (``fn(**params)``).
|
|
283
|
+
|
|
284
|
+
Raises ``KeyError`` for an unknown ``name``, and ``ValueError`` for invalid
|
|
285
|
+
parameters (a ``TypeError`` from the call — missing/extra/misnamed args) or a
|
|
286
|
+
domain-validation failure raised by the calc function itself.
|
|
287
|
+
"""
|
|
288
|
+
op = CALCULATORS.get(name)
|
|
289
|
+
if op is None:
|
|
290
|
+
raise KeyError(_unknown_message(name))
|
|
291
|
+
kwargs = params or {}
|
|
292
|
+
try:
|
|
293
|
+
return op.fn(**kwargs)
|
|
294
|
+
except TypeError as exc:
|
|
295
|
+
expected = ", ".join(s["name"] for s in _param_specs(op.fn)) or "(none)"
|
|
296
|
+
raise ValueError(f"invalid parameters for {name!r} ({exc}); expected: {expected}") from exc
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _unknown_message(name: str) -> str:
|
|
300
|
+
return (
|
|
301
|
+
f"unknown calculator {name!r}; "
|
|
302
|
+
f"see list_calculators() for the {len(CALCULATORS)} available"
|
|
303
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Magnetic relaxation model comparison. Port of utilities.compareRelaxation.
|
|
2
|
+
|
|
3
|
+
Pure calc layer. Fits Arrhenius (lnτ = lnτ₀ + Ea/(kB·T), linear/closed-form) and
|
|
4
|
+
Vogel-Fulcher-Tammann (lnτ = lnτ₀ + Ea/(kB·(T-T₀)), nonlinear Nelder-Mead) to
|
|
5
|
+
relaxation-time vs temperature data, then ranks them by AIC/BIC.
|
|
6
|
+
|
|
7
|
+
Parity note: the Arrhenius fit and its metrics are exact; the VFT fit uses
|
|
8
|
+
Nelder-Mead, so scipy and MATLAB ``fminsearch`` may land at marginally different
|
|
9
|
+
points — the VFT parameters are matched only to a loose tolerance.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from numpy.typing import ArrayLike, NDArray
|
|
19
|
+
from scipy.optimize import minimize
|
|
20
|
+
|
|
21
|
+
__all__ = ["compare_relaxation"]
|
|
22
|
+
|
|
23
|
+
_KB = 8.617333e-5 # Boltzmann constant, eV/K
|
|
24
|
+
_EPS = float(np.finfo(float).eps)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _compute_metrics(
|
|
28
|
+
y: NDArray[np.float64], residuals: NDArray[np.float64], n_params: int
|
|
29
|
+
) -> tuple[float, float, float]:
|
|
30
|
+
"""Return (AIC, BIC, R²) for a fit. Port of the local computeMetrics helper."""
|
|
31
|
+
n = y.size
|
|
32
|
+
rss = float(np.sum(residuals**2))
|
|
33
|
+
tss = float(np.sum((y - np.mean(y)) ** 2))
|
|
34
|
+
r2 = 1.0 - rss / max(tss, _EPS)
|
|
35
|
+
if rss < _EPS:
|
|
36
|
+
return float("-inf"), float("-inf"), r2
|
|
37
|
+
log_l = n * math.log(rss / n)
|
|
38
|
+
return log_l + 2 * n_params, log_l + n_params * math.log(n), r2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _vft_rss(
|
|
42
|
+
p: NDArray[np.float64], t: NDArray[np.float64], lntau_obs: NDArray[np.float64], tmin: float
|
|
43
|
+
) -> float:
|
|
44
|
+
"""VFT sum-of-squared residuals with the MATLAB penalty walls."""
|
|
45
|
+
lntau0, ea, t0 = float(p[0]), float(p[1]), float(p[2])
|
|
46
|
+
if t0 >= tmin - 0.5:
|
|
47
|
+
return 1e12 * (1.0 + abs(t0 - tmin + 0.5))
|
|
48
|
+
denom = _KB * (t - t0)
|
|
49
|
+
if np.any(denom <= 0):
|
|
50
|
+
return 1e12
|
|
51
|
+
pred = lntau0 + ea / denom
|
|
52
|
+
return float(np.sum((lntau_obs - pred) ** 2))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compare_relaxation(
|
|
56
|
+
temperature: ArrayLike, relaxation_time: ArrayLike
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Compare Arrhenius vs VFT relaxation fits. Port of utilities.compareRelaxation.
|
|
59
|
+
|
|
60
|
+
Returns ``{"arrhenius", "vft", "preferred", "deltaAIC", "deltaBIC"}``. Each
|
|
61
|
+
model sub-dict carries tau0, Ea_eV, R², AIC, BIC (VFT also T0). ``preferred``
|
|
62
|
+
is "VFT" when ΔBIC > 0 (VFT lower BIC), else "Arrhenius".
|
|
63
|
+
"""
|
|
64
|
+
t = np.asarray(temperature, dtype=float).ravel()
|
|
65
|
+
tau = np.asarray(relaxation_time, dtype=float).ravel()
|
|
66
|
+
n = t.size
|
|
67
|
+
if n != tau.size:
|
|
68
|
+
raise ValueError("temperature and relaxation_time must have the same length")
|
|
69
|
+
if n < 5:
|
|
70
|
+
raise ValueError("at least 5 data points are required")
|
|
71
|
+
if np.any(tau <= 0):
|
|
72
|
+
raise ValueError("all relaxation times must be positive")
|
|
73
|
+
if np.any(t <= 0):
|
|
74
|
+
raise ValueError("all temperatures must be positive (K)")
|
|
75
|
+
|
|
76
|
+
lntau = np.log(tau)
|
|
77
|
+
xmat = np.column_stack([np.ones(n), 1.0 / t])
|
|
78
|
+
b = np.linalg.lstsq(xmat, lntau, rcond=None)[0]
|
|
79
|
+
aic_a, bic_a, r2_a = _compute_metrics(lntau, lntau - xmat @ b, 2)
|
|
80
|
+
arrhenius = {
|
|
81
|
+
"tau0": math.exp(b[0]),
|
|
82
|
+
"Ea_eV": float(b[1] * _KB),
|
|
83
|
+
"R2": r2_a,
|
|
84
|
+
"AIC": aic_a,
|
|
85
|
+
"BIC": bic_a,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
tmin = float(t.min())
|
|
89
|
+
p0 = np.array([b[0], b[1] * _KB, max(0.0, tmin * 0.5)])
|
|
90
|
+
res = minimize(
|
|
91
|
+
_vft_rss,
|
|
92
|
+
p0,
|
|
93
|
+
args=(t, lntau, tmin),
|
|
94
|
+
method="Nelder-Mead",
|
|
95
|
+
options={"maxiter": 20000, "maxfev": 50000, "xatol": 1e-10, "fatol": 1e-12},
|
|
96
|
+
)
|
|
97
|
+
p_vft = np.asarray(res.x, dtype=float)
|
|
98
|
+
t0_vft = min(float(p_vft[2]), tmin - 1.0)
|
|
99
|
+
ea_vft = float(p_vft[1])
|
|
100
|
+
lntau_vft_fit = p_vft[0] + ea_vft / (_KB * np.maximum(t - t0_vft, _EPS))
|
|
101
|
+
aic_v, bic_v, r2_v = _compute_metrics(lntau, lntau - lntau_vft_fit, 3)
|
|
102
|
+
vft = {
|
|
103
|
+
"tau0": math.exp(p_vft[0]),
|
|
104
|
+
"Ea_eV": ea_vft,
|
|
105
|
+
"T0": t0_vft,
|
|
106
|
+
"R2": r2_v,
|
|
107
|
+
"AIC": aic_v,
|
|
108
|
+
"BIC": bic_v,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
delta_aic = aic_a - aic_v
|
|
112
|
+
delta_bic = bic_a - bic_v
|
|
113
|
+
return {
|
|
114
|
+
"arrhenius": arrhenius,
|
|
115
|
+
"vft": vft,
|
|
116
|
+
"preferred": "VFT" if delta_bic > 0 else "Arrhenius",
|
|
117
|
+
"deltaAIC": delta_aic,
|
|
118
|
+
"deltaBIC": delta_bic,
|
|
119
|
+
}
|
quantized/calc/report.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Structured report sheets: the serializable substrate for exports & batch.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #36 (W7 contract item). A :class:`ReportSheet` is plain,
|
|
4
|
+
diffable, JSON-round-trippable data (like ``DataStruct``) — never markup. Curve
|
|
5
|
+
fits, peak fits, W5 stats, the peak wizard, and batch runs all emit one (see
|
|
6
|
+
:mod:`quantized.calc.report_emit`); the #37/#38 exporters (docx / pptx / LaTeX /
|
|
7
|
+
HTML) and the future frontend viewer render the SAME schema with no
|
|
8
|
+
per-renderer special cases.
|
|
9
|
+
|
|
10
|
+
Structure — a report is a title + optional source references + an ordered list
|
|
11
|
+
of sections; each section is a title + an ordered list of typed blocks. Blocks
|
|
12
|
+
are a small closed set discriminated by ``"type"``::
|
|
13
|
+
|
|
14
|
+
text {"type": "text", "text": str}
|
|
15
|
+
table {"type": "table", "columns": [str], "rows": [[cell]], "caption"?}
|
|
16
|
+
params {"type": "params", "params": [{name, value, error?, unit?}], "caption"?}
|
|
17
|
+
figure {"type": "figure", "name": str, "image"?: {mime, data}, "caption"?}
|
|
18
|
+
|
|
19
|
+
A table cell is ``str | float | int | None``. Builders validate and normalize
|
|
20
|
+
on the way in (numpy scalars -> python scalars); :func:`validate_report`
|
|
21
|
+
re-checks a decoded dict so a round-tripped or hand-authored report is safe to
|
|
22
|
+
hand to any renderer.
|
|
23
|
+
|
|
24
|
+
Pure layer — no fastapi/pydantic imports (enforced by test_repo_integrity).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BLOCK_TYPES",
|
|
37
|
+
"ReportSheet",
|
|
38
|
+
"figure_block",
|
|
39
|
+
"params_block",
|
|
40
|
+
"section",
|
|
41
|
+
"source_ref",
|
|
42
|
+
"table_block",
|
|
43
|
+
"text_block",
|
|
44
|
+
"validate_report",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
BLOCK_TYPES = ("text", "table", "params", "figure")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cell(value: Any) -> str | float | int | None:
|
|
51
|
+
"""Coerce a table cell to a JSON scalar (numpy scalars -> python)."""
|
|
52
|
+
if value is None:
|
|
53
|
+
return None
|
|
54
|
+
if isinstance(value, bool): # keep bool out of the int branch
|
|
55
|
+
return str(value)
|
|
56
|
+
if isinstance(value, (int, float)):
|
|
57
|
+
v = float(value)
|
|
58
|
+
return None if not math.isfinite(v) else value
|
|
59
|
+
# numpy scalar or anything else -> try float, else str
|
|
60
|
+
try:
|
|
61
|
+
return float(value)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
return str(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Block builders ────────────────────────────────────────────────────────
|
|
67
|
+
def text_block(text: str) -> dict[str, Any]:
|
|
68
|
+
"""A free-text note block."""
|
|
69
|
+
return {"type": "text", "text": str(text)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def table_block(
|
|
73
|
+
columns: Sequence[str],
|
|
74
|
+
rows: Iterable[Sequence[Any]],
|
|
75
|
+
*,
|
|
76
|
+
caption: str | None = None,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""A generic table: column headers + a grid of scalar cells.
|
|
79
|
+
|
|
80
|
+
Covers ANOVA / post-hoc / descriptive-stats / peak tables — anything
|
|
81
|
+
rectangular. Every row must match the column count.
|
|
82
|
+
"""
|
|
83
|
+
cols = [str(c) for c in columns]
|
|
84
|
+
out_rows: list[list[str | float | int | None]] = []
|
|
85
|
+
for r in rows:
|
|
86
|
+
r = list(r)
|
|
87
|
+
if len(r) != len(cols):
|
|
88
|
+
raise ValueError(f"row has {len(r)} cells, expected {len(cols)}")
|
|
89
|
+
out_rows.append([_cell(v) for v in r])
|
|
90
|
+
block: dict[str, Any] = {"type": "table", "columns": cols, "rows": out_rows}
|
|
91
|
+
if caption:
|
|
92
|
+
block["caption"] = str(caption)
|
|
93
|
+
return block
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def params_block(
|
|
97
|
+
params: Iterable[Mapping[str, Any]],
|
|
98
|
+
*,
|
|
99
|
+
caption: str | None = None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""A fitted-parameter block: ordered ``{name, value, error?, unit?}`` rows.
|
|
102
|
+
|
|
103
|
+
Kept distinct from a plain table so renderers can format value ± error to
|
|
104
|
+
the precision implied by the uncertainty (the LaTeX/booktabs path, #38).
|
|
105
|
+
"""
|
|
106
|
+
out: list[dict[str, Any]] = []
|
|
107
|
+
for p in params:
|
|
108
|
+
# non-finite value/error -> None/omitted, so to_json() stays valid wire
|
|
109
|
+
# JSON (consistent with _cell / DataStruct's null-at-the-boundary rule).
|
|
110
|
+
val = float(p["value"])
|
|
111
|
+
entry: dict[str, Any] = {
|
|
112
|
+
"name": str(p["name"]),
|
|
113
|
+
"value": val if math.isfinite(val) else None,
|
|
114
|
+
}
|
|
115
|
+
err = p.get("error")
|
|
116
|
+
if err is not None and math.isfinite(float(err)):
|
|
117
|
+
entry["error"] = float(err)
|
|
118
|
+
unit = p.get("unit")
|
|
119
|
+
if unit:
|
|
120
|
+
entry["unit"] = str(unit)
|
|
121
|
+
out.append(entry)
|
|
122
|
+
block: dict[str, Any] = {"type": "params", "params": out}
|
|
123
|
+
if caption:
|
|
124
|
+
block["caption"] = str(caption)
|
|
125
|
+
return block
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def figure_block(
|
|
129
|
+
name: str,
|
|
130
|
+
*,
|
|
131
|
+
image: Mapping[str, str] | None = None,
|
|
132
|
+
caption: str | None = None,
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
"""A figure reference, optionally carrying an embedded image.
|
|
135
|
+
|
|
136
|
+
``image`` is ``{"mime": "image/png"|"image/svg+xml"|..., "data": <base64>}``
|
|
137
|
+
so an exporter can embed the figure without re-rendering; without it the
|
|
138
|
+
block is a pure reference (``name`` points at a FigureDoc, #12).
|
|
139
|
+
"""
|
|
140
|
+
block: dict[str, Any] = {"type": "figure", "name": str(name)}
|
|
141
|
+
if image is not None:
|
|
142
|
+
if "mime" not in image or "data" not in image:
|
|
143
|
+
raise ValueError("figure image needs 'mime' and 'data' keys")
|
|
144
|
+
block["image"] = {"mime": str(image["mime"]), "data": str(image["data"])}
|
|
145
|
+
if caption:
|
|
146
|
+
block["caption"] = str(caption)
|
|
147
|
+
return block
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def section(title: str, blocks: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
|
|
151
|
+
"""A titled, ordered group of blocks."""
|
|
152
|
+
return {"title": str(title), "blocks": [dict(b) for b in blocks]}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def source_ref(kind: str, ref_id: str, name: str | None = None) -> dict[str, Any]:
|
|
156
|
+
"""A pointer back to a source object (dataset / fit / figure id)."""
|
|
157
|
+
ref: dict[str, Any] = {"kind": str(kind), "id": str(ref_id)}
|
|
158
|
+
if name:
|
|
159
|
+
ref["name"] = str(name)
|
|
160
|
+
return ref
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── Validation ──────────────────────────────────────────────────────────────
|
|
164
|
+
def _validate_block(block: Mapping[str, Any], where: str) -> None:
|
|
165
|
+
if not isinstance(block, Mapping):
|
|
166
|
+
raise ValueError(f"{where}: block must be an object")
|
|
167
|
+
btype = block.get("type")
|
|
168
|
+
if btype not in BLOCK_TYPES:
|
|
169
|
+
raise ValueError(f"{where}: unknown block type {btype!r}")
|
|
170
|
+
if btype == "text":
|
|
171
|
+
if not isinstance(block.get("text"), str):
|
|
172
|
+
raise ValueError(f"{where}: text block needs a string 'text'")
|
|
173
|
+
elif btype == "table":
|
|
174
|
+
cols = block.get("columns")
|
|
175
|
+
rows = block.get("rows")
|
|
176
|
+
if not isinstance(cols, list) or not isinstance(rows, list):
|
|
177
|
+
raise ValueError(f"{where}: table needs list 'columns' and 'rows'")
|
|
178
|
+
for r in rows:
|
|
179
|
+
if not isinstance(r, list) or len(r) != len(cols):
|
|
180
|
+
raise ValueError(f"{where}: every table row must match the column count")
|
|
181
|
+
elif btype == "params":
|
|
182
|
+
ps = block.get("params")
|
|
183
|
+
if not isinstance(ps, list):
|
|
184
|
+
raise ValueError(f"{where}: params block needs a list 'params'")
|
|
185
|
+
for p in ps:
|
|
186
|
+
if not isinstance(p, Mapping) or "name" not in p or "value" not in p:
|
|
187
|
+
raise ValueError(f"{where}: each param needs 'name' and 'value'")
|
|
188
|
+
elif btype == "figure": # noqa: SIM102
|
|
189
|
+
if not isinstance(block.get("name"), str):
|
|
190
|
+
raise ValueError(f"{where}: figure block needs a string 'name'")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def validate_report(payload: Mapping[str, Any]) -> None:
|
|
194
|
+
"""Raise ``ValueError`` if ``payload`` is not a well-formed report dict."""
|
|
195
|
+
if not isinstance(payload.get("title"), str):
|
|
196
|
+
raise ValueError("report needs a string 'title'")
|
|
197
|
+
sections = payload.get("sections", [])
|
|
198
|
+
if not isinstance(sections, list):
|
|
199
|
+
raise ValueError("report 'sections' must be a list")
|
|
200
|
+
for si, sec in enumerate(sections):
|
|
201
|
+
if not isinstance(sec, Mapping):
|
|
202
|
+
raise ValueError(f"section {si} must be an object")
|
|
203
|
+
if not isinstance(sec.get("title"), str):
|
|
204
|
+
raise ValueError(f"section {si} needs a string 'title'")
|
|
205
|
+
blocks = sec.get("blocks", [])
|
|
206
|
+
if not isinstance(blocks, list):
|
|
207
|
+
raise ValueError(f"section {si} 'blocks' must be a list")
|
|
208
|
+
for bi, block in enumerate(blocks):
|
|
209
|
+
_validate_block(block, f"section {si} block {bi}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── The report sheet ──────────────────────────────────────────────────────
|
|
213
|
+
@dataclass(frozen=True, slots=True)
|
|
214
|
+
class ReportSheet:
|
|
215
|
+
"""An immutable, serializable report: title + source refs + sections."""
|
|
216
|
+
|
|
217
|
+
title: str
|
|
218
|
+
sections: tuple[dict[str, Any], ...] = ()
|
|
219
|
+
source_refs: tuple[dict[str, Any], ...] = ()
|
|
220
|
+
created: str | None = None # ISO string, set by the caller (keeps calc pure/deterministic)
|
|
221
|
+
meta: Mapping[str, Any] = field(default_factory=dict)
|
|
222
|
+
|
|
223
|
+
def to_dict(self) -> dict[str, Any]:
|
|
224
|
+
return {
|
|
225
|
+
"title": self.title,
|
|
226
|
+
"sections": [dict(s) for s in self.sections],
|
|
227
|
+
"source_refs": [dict(r) for r in self.source_refs],
|
|
228
|
+
"created": self.created,
|
|
229
|
+
"meta": dict(self.meta),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> ReportSheet:
|
|
234
|
+
validate_report(payload)
|
|
235
|
+
return cls(
|
|
236
|
+
title=str(payload["title"]),
|
|
237
|
+
sections=tuple(dict(s) for s in payload.get("sections", [])),
|
|
238
|
+
source_refs=tuple(dict(r) for r in payload.get("source_refs", [])),
|
|
239
|
+
created=payload.get("created"),
|
|
240
|
+
meta=dict(payload.get("meta", {})),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def to_json(self) -> str:
|
|
244
|
+
return json.dumps(self.to_dict())
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_json(cls, text: str) -> ReportSheet:
|
|
248
|
+
return cls.from_dict(json.loads(text))
|
|
249
|
+
|
|
250
|
+
def iter_blocks(self) -> Iterable[dict[str, Any]]:
|
|
251
|
+
"""Yield every block across all sections (renderer convenience)."""
|
|
252
|
+
for sec in self.sections:
|
|
253
|
+
yield from sec.get("blocks", [])
|