quantized-lab 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
quantized/calc/mcmc.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
r"""MCMC posterior sampling for nonlinear-fit uncertainty (``fitting.mcmcSample``).
|
|
2
|
+
|
|
3
|
+
Pure calc layer. A single-chain random-walk Metropolis sampler with a Gaussian
|
|
4
|
+
proposal — the same scaffold as the MATLAB original (the production target is an
|
|
5
|
+
affine-invariant ensemble sampler; not yet implemented on either side).
|
|
6
|
+
|
|
7
|
+
The algorithm, per step ``k``:
|
|
8
|
+
|
|
9
|
+
.. math::
|
|
10
|
+
|
|
11
|
+
p' = p_{k-1} + \sigma \odot \mathcal{N}(0, I), \qquad
|
|
12
|
+
\text{accept if } \log u < \log P(p') - \log P(p_{k-1}), \; u \sim U(0,1)
|
|
13
|
+
|
|
14
|
+
where :math:`\sigma` is a uniform per-dimension proposal scale (``step_size``).
|
|
15
|
+
Post burn-in the chain is thinned, and an effective sample size is estimated from
|
|
16
|
+
the integrated autocorrelation time (Sokal 1997) via a single batched FFT.
|
|
17
|
+
|
|
18
|
+
Because the sampler is RNG-driven it is **not** golden-frozen against MATLAB
|
|
19
|
+
(NumPy and MATLAB use different generators); it is verified by invariants —
|
|
20
|
+
posterior-mean recovery, an acceptance rate in the target band, correct output
|
|
21
|
+
shapes, and seeded reproducibility (see ``tests/test_calc_mcmc.py``), mirroring
|
|
22
|
+
how ``tests/fitting/test_mcmcSample.m`` tests the MATLAB scaffold.
|
|
23
|
+
|
|
24
|
+
References
|
|
25
|
+
----------
|
|
26
|
+
Goodman, J. & Weare, J., *Ensemble samplers with affine invariance*,
|
|
27
|
+
Commun. Appl. Math. Comput. Sci. **5**, 65 (2010).
|
|
28
|
+
Foreman-Mackey, D. et al., *emcee: the MCMC Hammer*, PASP **125**, 306 (2013).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import math
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
from numpy.typing import NDArray
|
|
39
|
+
|
|
40
|
+
__all__ = ["mcmc_sample"]
|
|
41
|
+
|
|
42
|
+
LogPosterior = Callable[[NDArray[np.float64]], float]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mcmc_sample(
|
|
46
|
+
log_posterior: LogPosterior,
|
|
47
|
+
initial_params: float | list[float] | NDArray[np.float64],
|
|
48
|
+
*,
|
|
49
|
+
num_steps: int = 10000,
|
|
50
|
+
burn_in: int = 1000,
|
|
51
|
+
thin: int = 1,
|
|
52
|
+
step_size: float = 0.05,
|
|
53
|
+
seed: int | None = None,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
r"""Random-walk Metropolis MCMC over a log-posterior.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
log_posterior
|
|
60
|
+
Callable taking a 1-D parameter vector and returning a scalar
|
|
61
|
+
log-posterior (log-likelihood + log-prior). Return ``-inf`` for
|
|
62
|
+
out-of-prior parameters.
|
|
63
|
+
initial_params
|
|
64
|
+
Starting parameter vector (scalar or length-``P`` sequence).
|
|
65
|
+
num_steps
|
|
66
|
+
Total MCMC steps (``> 0``).
|
|
67
|
+
burn_in
|
|
68
|
+
Leading steps to discard (``>= 0``).
|
|
69
|
+
thin
|
|
70
|
+
Keep every ``thin``-th post-burn-in sample (``> 0``).
|
|
71
|
+
step_size
|
|
72
|
+
Gaussian proposal scale, uniform across dimensions (``> 0``).
|
|
73
|
+
seed
|
|
74
|
+
RNG seed for reproducibility; ``None`` for a fresh generator.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
dict
|
|
79
|
+
``samples`` ``[N × P]`` post-burn-in thinned draws, ``log_posterior``
|
|
80
|
+
``[N]`` at each, ``accept_rate`` (target 0.2–0.5), and ``diagnostic``
|
|
81
|
+
(``n_steps``, ``n_accepted``, ``ess`` per dimension, ``r_hat`` = NaN
|
|
82
|
+
placeholder, ``sampler``).
|
|
83
|
+
"""
|
|
84
|
+
if num_steps < 1:
|
|
85
|
+
raise ValueError("num_steps must be a positive integer")
|
|
86
|
+
if burn_in < 0:
|
|
87
|
+
raise ValueError("burn_in must be non-negative")
|
|
88
|
+
if thin < 1:
|
|
89
|
+
raise ValueError("thin must be a positive integer")
|
|
90
|
+
if not (step_size > 0):
|
|
91
|
+
raise ValueError("step_size must be positive")
|
|
92
|
+
|
|
93
|
+
rng = np.random.default_rng(seed)
|
|
94
|
+
p0 = np.atleast_1d(np.asarray(initial_params, dtype=float)).ravel()
|
|
95
|
+
if p0.size == 0:
|
|
96
|
+
raise ValueError("initial_params must be non-empty")
|
|
97
|
+
n_params = int(p0.size)
|
|
98
|
+
n = int(num_steps)
|
|
99
|
+
|
|
100
|
+
chain = np.zeros((n, n_params), dtype=float)
|
|
101
|
+
log_post = np.zeros(n, dtype=float)
|
|
102
|
+
chain[0] = p0
|
|
103
|
+
log_post[0] = float(log_posterior(p0))
|
|
104
|
+
|
|
105
|
+
# Uniform proposal scale in every dimension. A fraction-of-|p| rule would
|
|
106
|
+
# collapse when any initial parameter is near zero (MATLAB note).
|
|
107
|
+
prop_scale = step_size * np.ones(n_params, dtype=float)
|
|
108
|
+
|
|
109
|
+
n_accepted = 0
|
|
110
|
+
for kk in range(1, n):
|
|
111
|
+
prop = chain[kk - 1] + rng.standard_normal(n_params) * prop_scale
|
|
112
|
+
lp_prop = float(log_posterior(prop))
|
|
113
|
+
log_ratio = lp_prop - log_post[kk - 1]
|
|
114
|
+
if math.log(rng.random()) < log_ratio:
|
|
115
|
+
chain[kk] = prop
|
|
116
|
+
log_post[kk] = lp_prop
|
|
117
|
+
n_accepted += 1
|
|
118
|
+
else:
|
|
119
|
+
chain[kk] = chain[kk - 1]
|
|
120
|
+
log_post[kk] = log_post[kk - 1]
|
|
121
|
+
|
|
122
|
+
# Burn-in + thinning. MATLAB (1-based) (BurnIn+1):Thin:N → 0-based
|
|
123
|
+
# burn_in:thin:N.
|
|
124
|
+
keep = np.arange(burn_in, n, thin)
|
|
125
|
+
samples = chain[keep]
|
|
126
|
+
lp_kept = log_post[keep]
|
|
127
|
+
ess = _effective_sample_size(samples)
|
|
128
|
+
|
|
129
|
+
accept_rate = n_accepted / (n - 1) if n > 1 else float("nan")
|
|
130
|
+
return {
|
|
131
|
+
"samples": samples,
|
|
132
|
+
"log_posterior": lp_kept,
|
|
133
|
+
"accept_rate": accept_rate,
|
|
134
|
+
"diagnostic": {
|
|
135
|
+
"n_steps": n,
|
|
136
|
+
"n_accepted": n_accepted,
|
|
137
|
+
"ess": ess,
|
|
138
|
+
"r_hat": float("nan"), # Gelman-Rubin needs multiple chains (TODO)
|
|
139
|
+
"sampler": "random-walk-metropolis",
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _effective_sample_size(samples: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
145
|
+
r"""Per-dimension ESS from the integrated autocorrelation time (Sokal 1997).
|
|
146
|
+
|
|
147
|
+
For each column the normalised autocorrelation is the inverse FFT of
|
|
148
|
+
``|FFT(z)|²`` (``z`` = mean-centred chain), zero-padded to avoid circular
|
|
149
|
+
wrap. ``τ = 1 + 2·Σ ρ_lag`` over positive-lag autocorrelations above 0.05,
|
|
150
|
+
and ``ESS = N / max(τ, 1)``. All-constant columns get ``ESS = N`` directly.
|
|
151
|
+
"""
|
|
152
|
+
n_kept, n_params = samples.shape
|
|
153
|
+
ess = np.zeros(n_params, dtype=float)
|
|
154
|
+
z = samples - samples.mean(axis=0) # mean-centred [n_kept × P]
|
|
155
|
+
|
|
156
|
+
zero_mask = np.all(z == 0.0, axis=0)
|
|
157
|
+
ess[zero_mask] = n_kept
|
|
158
|
+
active = ~zero_mask
|
|
159
|
+
if not np.any(active):
|
|
160
|
+
return ess
|
|
161
|
+
|
|
162
|
+
z_active = z[:, active]
|
|
163
|
+
nfft = 1 << _nextpow2(2 * n_kept - 1) # zero-pad past the wrap point
|
|
164
|
+
freq = np.fft.fft(z_active, n=nfft, axis=0)
|
|
165
|
+
ac = np.real(np.fft.ifft(freq * np.conj(freq), axis=0))
|
|
166
|
+
ac = np.asarray(ac / ac[0], dtype=float) # normalise each column by lag-0
|
|
167
|
+
ac_pos = ac[1:n_kept] # positive lags 1 … n_kept-1
|
|
168
|
+
tau = 1.0 + 2.0 * np.sum(ac_pos * (ac_pos > 0.05), axis=0)
|
|
169
|
+
ess[active] = n_kept / np.maximum(tau, 1.0)
|
|
170
|
+
return ess
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _nextpow2(value: int) -> int:
|
|
174
|
+
"""Smallest integer ``p`` with ``2**p >= value`` (MATLAB ``nextpow2``)."""
|
|
175
|
+
if value <= 1:
|
|
176
|
+
return 0
|
|
177
|
+
return int(math.ceil(math.log2(value)))
|
quantized/calc/optics.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
r"""Optics calculators (DiraCulator ``buildOpticsTab`` + ``+calc/+optics``).
|
|
2
|
+
|
|
3
|
+
Pure calc layer — closed-form scalars in, result dicts out. No fastapi /
|
|
4
|
+
pydantic imports. Ports the seven MATLAB ``calc.optics`` functions verbatim:
|
|
5
|
+
|
|
6
|
+
.. math::
|
|
7
|
+
|
|
8
|
+
r_s = \frac{n_1\cos\theta_i - n_2\cos\theta_t}
|
|
9
|
+
{n_1\cos\theta_i + n_2\cos\theta_t} \qquad R_s = |r_s|^2 \\
|
|
10
|
+
\theta_c = \arcsin(n_2/n_1) \qquad \theta_B = \arctan(n_2/n_1) \\
|
|
11
|
+
\delta_{\text{pen}} = \frac{\lambda}{4\pi k} \qquad
|
|
12
|
+
\delta_{\text{skin}} = \sqrt{\frac{2\rho}{\omega\mu_0}} \\
|
|
13
|
+
\varepsilon_1 = n^2 - k^2 \qquad \varepsilon_2 = 2 n k
|
|
14
|
+
|
|
15
|
+
Conventions follow the MATLAB toolbox (the behavioural reference): angles in
|
|
16
|
+
degrees, penetration-depth wavelength in any consistent length unit (output in
|
|
17
|
+
the same unit), skin-depth resistivity in Ω·m (SI) and frequency in Hz.
|
|
18
|
+
|
|
19
|
+
Reference values (from the MATLAB docstrings / closed form):
|
|
20
|
+
- ``fresnel_coefficients(1.0, 1.5, 0) -> Rs = Rp = 0.04`` (air/glass)
|
|
21
|
+
- ``critical_angle(1.5, 1.0) -> theta_c ≈ 41.81`` deg (glass/air)
|
|
22
|
+
- ``brewster_angle(1.0, 1.5) -> theta_b ≈ 56.31`` deg (air/glass)
|
|
23
|
+
- ``penetration_depth(5.6, 0.39, 400) -> depth ≈ 81.6`` nm (Si @ 400 nm)
|
|
24
|
+
- ``skin_depth(1.68e-8, 1e9) -> delta ≈ 2.06`` µm (Cu @ 1 GHz)
|
|
25
|
+
- ``refractive_to_dielectric(3.5, 0) -> eps1 = 12.25, eps2 = 0`` (Si, IR)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import cmath
|
|
31
|
+
import math
|
|
32
|
+
|
|
33
|
+
from quantized.calc.constants import constants
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"brewster_angle",
|
|
37
|
+
"critical_angle",
|
|
38
|
+
"dielectric_to_refractive",
|
|
39
|
+
"fresnel_coefficients",
|
|
40
|
+
"penetration_depth",
|
|
41
|
+
"refractive_to_dielectric",
|
|
42
|
+
"skin_depth",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fresnel_coefficients(n1: float, n2: float, theta: float) -> dict[str, float]:
|
|
47
|
+
"""Fresnel reflectance/transmittance at an interface (``fresnelCoefficients.m``).
|
|
48
|
+
|
|
49
|
+
Generalised Snell's law ``cos θ_t = √(1 − (n₁/n₂·sin θᵢ)²)`` (principal
|
|
50
|
+
branch) gives evanescent waves above the critical angle. Amplitude
|
|
51
|
+
coefficients are computed with complex arithmetic; only the real-valued
|
|
52
|
+
intensity coefficients are returned (complex amplitudes can't serialize).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
n1: refractive index of the incident medium, > 0.
|
|
56
|
+
n2: refractive index of the transmitted medium, > 0.
|
|
57
|
+
theta: angle of incidence (degrees from normal), >= 0.
|
|
58
|
+
|
|
59
|
+
Returns ``Rs``/``Rp`` reflectances |r|² and ``Ts``/``Tp`` transmittances
|
|
60
|
+
(energy-conserving form).
|
|
61
|
+
|
|
62
|
+
>>> r = fresnel_coefficients(1.0, 1.5, 0.0)
|
|
63
|
+
>>> round(r["Rs"], 4)
|
|
64
|
+
0.04
|
|
65
|
+
>>> round(r["Ts"], 4)
|
|
66
|
+
0.96
|
|
67
|
+
"""
|
|
68
|
+
if n1 <= 0 or n2 <= 0:
|
|
69
|
+
raise ValueError("n1 and n2 must be positive")
|
|
70
|
+
if theta < 0:
|
|
71
|
+
raise ValueError("theta must be non-negative")
|
|
72
|
+
|
|
73
|
+
th = math.radians(theta)
|
|
74
|
+
cos_i = complex(math.cos(th))
|
|
75
|
+
sin_i = complex(math.sin(th))
|
|
76
|
+
|
|
77
|
+
sin_t = (n1 / n2) * sin_i
|
|
78
|
+
cos_t = cmath.sqrt(1 - sin_t**2) # principal sqrt; evanescent if TIR
|
|
79
|
+
|
|
80
|
+
rs = (n1 * cos_i - n2 * cos_t) / (n1 * cos_i + n2 * cos_t)
|
|
81
|
+
rp = (n2 * cos_i - n1 * cos_t) / (n2 * cos_i + n1 * cos_t)
|
|
82
|
+
ts = (2 * n1 * cos_i) / (n1 * cos_i + n2 * cos_t)
|
|
83
|
+
tp = (2 * n1 * cos_i) / (n2 * cos_i + n1 * cos_t)
|
|
84
|
+
|
|
85
|
+
r_s = abs(rs) ** 2
|
|
86
|
+
r_p = abs(rp) ** 2
|
|
87
|
+
factor = (n2 * cos_t.conjugate()).real / (n1 * cos_i.conjugate()).real
|
|
88
|
+
t_s = factor * abs(ts) ** 2
|
|
89
|
+
t_p = factor * abs(tp) ** 2
|
|
90
|
+
|
|
91
|
+
return {"Rs": r_s, "Rp": r_p, "Ts": t_s, "Tp": t_p, "theta": theta}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def critical_angle(n1: float, n2: float) -> dict[str, float]:
|
|
95
|
+
"""Critical angle for total internal reflection (``criticalAngle.m``).
|
|
96
|
+
|
|
97
|
+
``θ_c = arcsin(n₂/n₁)`` (degrees). TIR requires ``n₁ > n₂``; when
|
|
98
|
+
``n₂ >= n₁`` returns ``theta_c = NaN`` (no error) so callers can use it in
|
|
99
|
+
vectorised workflows without branching.
|
|
100
|
+
|
|
101
|
+
>>> round(critical_angle(1.5, 1.0)["theta_c"], 2)
|
|
102
|
+
41.81
|
|
103
|
+
>>> import math
|
|
104
|
+
>>> math.isnan(critical_angle(1.0, 1.5)["theta_c"])
|
|
105
|
+
True
|
|
106
|
+
"""
|
|
107
|
+
if n1 <= 0 or n2 <= 0:
|
|
108
|
+
raise ValueError("n1 and n2 must be positive")
|
|
109
|
+
theta_c = float("nan") if n2 >= n1 else math.degrees(math.asin(n2 / n1))
|
|
110
|
+
return {"theta_c": theta_c, "n1": n1, "n2": n2}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def brewster_angle(n1: float, n2: float) -> dict[str, float]:
|
|
114
|
+
"""Brewster angle for p-polarised light (``brewsterAngle.m``).
|
|
115
|
+
|
|
116
|
+
``θ_B = arctan(n₂/n₁)`` (degrees). At this angle the reflected beam is
|
|
117
|
+
purely s-polarised (``R_p = 0``). Exact for real, lossless media.
|
|
118
|
+
|
|
119
|
+
>>> round(brewster_angle(1.0, 1.5)["theta_b"], 2)
|
|
120
|
+
56.31
|
|
121
|
+
"""
|
|
122
|
+
if n1 <= 0 or n2 <= 0:
|
|
123
|
+
raise ValueError("n1 and n2 must be positive")
|
|
124
|
+
return {"theta_b": math.degrees(math.atan(n2 / n1)), "n1": n1, "n2": n2}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def penetration_depth(n: float, k: float, wavelength: float) -> dict[str, float]:
|
|
128
|
+
"""Optical penetration depth for an absorbing medium (``penetrationDepth.m``).
|
|
129
|
+
|
|
130
|
+
1/e intensity depth ``δ = λ / (4π k) = 1/α``. When ``k = 0`` the medium is
|
|
131
|
+
lossless: ``depth`` and ``abs_length`` are +inf and ``abs_coeff`` is 0.
|
|
132
|
+
Output length unit matches the wavelength unit.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
n: real part of refractive index, > 0.
|
|
136
|
+
k: extinction coefficient, >= 0.
|
|
137
|
+
wavelength: wavelength (any length unit), > 0.
|
|
138
|
+
|
|
139
|
+
>>> round(penetration_depth(5.6, 0.39, 400.0)["depth"], 1)
|
|
140
|
+
81.6
|
|
141
|
+
>>> penetration_depth(1.5, 0.0, 500.0)["depth"]
|
|
142
|
+
inf
|
|
143
|
+
"""
|
|
144
|
+
if n <= 0:
|
|
145
|
+
raise ValueError("n must be positive")
|
|
146
|
+
if k < 0:
|
|
147
|
+
raise ValueError("k must be non-negative")
|
|
148
|
+
if wavelength <= 0:
|
|
149
|
+
raise ValueError("wavelength must be positive")
|
|
150
|
+
|
|
151
|
+
if k == 0:
|
|
152
|
+
abs_coeff = 0.0
|
|
153
|
+
depth = float("inf")
|
|
154
|
+
abs_length = float("inf")
|
|
155
|
+
else:
|
|
156
|
+
abs_coeff = 4 * math.pi * k / wavelength
|
|
157
|
+
depth = 1.0 / abs_coeff # = lambda / (4*pi*k)
|
|
158
|
+
abs_length = 1.0 / (2 * abs_coeff)
|
|
159
|
+
return {
|
|
160
|
+
"depth": depth,
|
|
161
|
+
"abs_coeff": abs_coeff,
|
|
162
|
+
"abs_length": abs_length,
|
|
163
|
+
"wavelength": wavelength,
|
|
164
|
+
"n": n,
|
|
165
|
+
"k": k,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def skin_depth(rho: float, f: float) -> dict[str, float]:
|
|
170
|
+
"""Electromagnetic skin depth of a conductor (``skinDepth.m``).
|
|
171
|
+
|
|
172
|
+
Good-conductor approximation ``δ = √(2ρ / (ω μ₀))`` with ``ω = 2π f`` and
|
|
173
|
+
``μ₀`` the vacuum permeability. Assumes relative permeability μ_r = 1.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
rho: electrical resistivity (Ω·m, SI), > 0.
|
|
177
|
+
f: field frequency (Hz), > 0.
|
|
178
|
+
|
|
179
|
+
Returns ``delta`` (m) plus ``delta_um`` (µm) and ``delta_nm`` (nm).
|
|
180
|
+
|
|
181
|
+
>>> round(skin_depth(1.68e-8, 1e9)["delta_um"], 2)
|
|
182
|
+
2.06
|
|
183
|
+
"""
|
|
184
|
+
if rho <= 0:
|
|
185
|
+
raise ValueError("rho must be positive")
|
|
186
|
+
if f <= 0:
|
|
187
|
+
raise ValueError("f must be positive")
|
|
188
|
+
mu0 = constants()["mu0"]
|
|
189
|
+
omega = 2 * math.pi * f
|
|
190
|
+
delta = math.sqrt(2 * rho / (omega * mu0))
|
|
191
|
+
return {
|
|
192
|
+
"delta": delta,
|
|
193
|
+
"delta_um": delta * 1e6,
|
|
194
|
+
"delta_nm": delta * 1e9,
|
|
195
|
+
"rho": rho,
|
|
196
|
+
"f": f,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def refractive_to_dielectric(n: float, k: float = 0.0) -> dict[str, float]:
|
|
201
|
+
"""Complex refractive index (n, k) → dielectric function (``refractiveToDielectric.m``).
|
|
202
|
+
|
|
203
|
+
``ε = (n + ik)²`` → ``ε₁ = n² − k²``, ``ε₂ = 2 n k``.
|
|
204
|
+
|
|
205
|
+
>>> r = refractive_to_dielectric(3.5, 0.0)
|
|
206
|
+
>>> round(r["eps1"], 4), round(r["eps2"], 4)
|
|
207
|
+
(12.25, 0.0)
|
|
208
|
+
"""
|
|
209
|
+
if k < 0:
|
|
210
|
+
raise ValueError("k must be non-negative")
|
|
211
|
+
return {"eps1": n**2 - k**2, "eps2": 2.0 * n * k, "n": n, "k": k}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def dielectric_to_refractive(eps1: float, eps2: float = 0.0) -> dict[str, float]:
|
|
215
|
+
"""Dielectric function (ε₁, ε₂) → complex refractive index (``dielectricToRefractive.m``).
|
|
216
|
+
|
|
217
|
+
Physical square root (``n >= 0``, ``k >= 0``):
|
|
218
|
+
``n = √((|ε| + ε₁)/2)``, ``k = √((|ε| − ε₁)/2)`` with ``|ε| = √(ε₁² + ε₂²)``.
|
|
219
|
+
For metals with ``ε₁ < 0`` and ``ε₂ = 0``: ``n = 0``, ``k = √(−ε₁)``.
|
|
220
|
+
|
|
221
|
+
>>> r = dielectric_to_refractive(12.25, 0.0)
|
|
222
|
+
>>> round(r["n"], 4), round(r["k"], 4)
|
|
223
|
+
(3.5, 0.0)
|
|
224
|
+
"""
|
|
225
|
+
mod_eps = math.sqrt(eps1**2 + eps2**2)
|
|
226
|
+
n = math.sqrt((mod_eps + eps1) / 2)
|
|
227
|
+
k = math.sqrt((mod_eps - eps1) / 2)
|
|
228
|
+
return {"n": n, "k": k, "eps1": eps1, "eps2": eps2}
|
quantized/calc/pawley.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
r"""Pawley whole-pattern refinement for powder XRD (``fitting.pawleyRefine``).
|
|
2
|
+
|
|
3
|
+
Pure calc layer. Pawley refinement fits **integrated peak intensities freely** —
|
|
4
|
+
it does not require a structural model (that is Rietveld). This is the same
|
|
5
|
+
scaffold as the MATLAB original: lattice parameters + an overall scale are
|
|
6
|
+
refined by an adaptive grid search around the initial cell, while at each trial
|
|
7
|
+
the peak intensities and a linear background are solved by linear least-squares.
|
|
8
|
+
|
|
9
|
+
Pipeline
|
|
10
|
+
--------
|
|
11
|
+
1. For a trial cell, enumerate allowed reflections via
|
|
12
|
+
:func:`quantized.calc.crystallography.plane_spacings` (centering rules only —
|
|
13
|
+
glide/screw absences are not applied, matching the MATLAB scaffold), keeping
|
|
14
|
+
``0 < 2θ <= max_two_theta``.
|
|
15
|
+
2. Build a fixed-width pseudo-Voigt (50–50 Gaussian/Lorentzian) basis, one column
|
|
16
|
+
per peak, plus a linear background basis ``[1, 2θ]``; solve
|
|
17
|
+
``[peaks | bg] · x = I_obs`` by least-squares, clamping peak intensities ``≥ 0``.
|
|
18
|
+
3. ``χ² = Σ (model − obs)²`` scores the trial cell. An adaptive grid search steps
|
|
19
|
+
each free axis ``± 0.02·a₀`` (halving on stalls), tying axes by crystal system
|
|
20
|
+
(cubic ``a=b=c``; tetragonal ``a=b≠c``; else all three free).
|
|
21
|
+
4. Report the refined cell, the fitted model/background/residual, per-peak
|
|
22
|
+
intensities, and the weighted-profile R-factor
|
|
23
|
+
``R_wp = sqrt( Σ w·resid² / Σ w·obs² )`` with Poisson-like weights
|
|
24
|
+
``w = 1/max(I, 1)``.
|
|
25
|
+
|
|
26
|
+
Verified by invariants (cell recovery on a synthetic pattern, output-field
|
|
27
|
+
presence, size-mismatch rejection) rather than golden values — mirroring
|
|
28
|
+
``tests/fitting/test_pawleyRefine.m``, since the adaptive grid search + LAPACK
|
|
29
|
+
least-squares can branch differently from MATLAB at the last significant digit.
|
|
30
|
+
|
|
31
|
+
References
|
|
32
|
+
----------
|
|
33
|
+
Pawley, G.S., *Unit-cell refinement from powder diffraction scans*,
|
|
34
|
+
J. Appl. Cryst. **14**, 357 (1981).
|
|
35
|
+
Cagliotti, G. et al., Nucl. Instrum. **3**, 223 (1958) — U/V/W profile.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import math
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
import numpy as np
|
|
44
|
+
from numpy.typing import NDArray
|
|
45
|
+
|
|
46
|
+
from quantized.calc.crystallography import plane_spacings
|
|
47
|
+
|
|
48
|
+
__all__ = ["pawley_refine"]
|
|
49
|
+
|
|
50
|
+
_REQUIRED_FIELDS = ("a", "b", "c", "symmetry")
|
|
51
|
+
_BACKGROUNDS = ("linear", "polynomial", "cheby")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pawley_refine(
|
|
55
|
+
two_theta: NDArray[np.float64] | list[float],
|
|
56
|
+
intensity: NDArray[np.float64] | list[float],
|
|
57
|
+
phase_info: dict[str, Any],
|
|
58
|
+
*,
|
|
59
|
+
wavelength: float = 1.5406,
|
|
60
|
+
max_two_theta: float = 120.0,
|
|
61
|
+
background: str = "linear",
|
|
62
|
+
profile_fwhm: float = 0.05,
|
|
63
|
+
refine_cell: bool = True,
|
|
64
|
+
max_iter: int = 20,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
r"""Pawley refine a powder pattern against a phase's lattice.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
two_theta, intensity
|
|
71
|
+
Observed scan (``2θ`` in degrees, counts/intensity). Same length.
|
|
72
|
+
phase_info
|
|
73
|
+
Dict with ``a``, ``b``, ``c`` (Å) and ``symmetry`` (Bravais letter /
|
|
74
|
+
centering, e.g. ``'F'``); optional ``alpha``/``beta``/``gamma`` (deg,
|
|
75
|
+
default 90) and ``hklMax`` (default 6).
|
|
76
|
+
wavelength
|
|
77
|
+
X-ray wavelength (Å); CuKα1 by default.
|
|
78
|
+
max_two_theta
|
|
79
|
+
Upper ``2θ`` cut-off (deg).
|
|
80
|
+
background
|
|
81
|
+
``'linear'`` (only linear is implemented, matching the scaffold).
|
|
82
|
+
profile_fwhm
|
|
83
|
+
Fixed pseudo-Voigt FWHM per peak (deg).
|
|
84
|
+
refine_cell
|
|
85
|
+
Refine the lattice parameters (else keep the initial cell).
|
|
86
|
+
max_iter
|
|
87
|
+
Outer grid-search iterations.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
dict
|
|
92
|
+
``cell`` / ``cell_initial`` ``[a b c α β γ]``, ``scale`` (NaN — folded
|
|
93
|
+
into per-peak intensity), ``peaks`` (list of ``hkl``/``two_theta``/``d``/
|
|
94
|
+
``multiplicity``/``intensity``), ``background``/``model``/``residual``
|
|
95
|
+
``[N]``, ``rwp``, ``n_peaks``.
|
|
96
|
+
"""
|
|
97
|
+
tt = np.asarray(two_theta, dtype=float).ravel()
|
|
98
|
+
obs = np.asarray(intensity, dtype=float).ravel()
|
|
99
|
+
if tt.size == 0 or obs.size == 0:
|
|
100
|
+
raise ValueError("two_theta and intensity must be non-empty")
|
|
101
|
+
if tt.size != obs.size:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"two_theta ({tt.size}) and intensity ({obs.size}) must have the same length."
|
|
104
|
+
)
|
|
105
|
+
if background not in _BACKGROUNDS:
|
|
106
|
+
raise ValueError(f"background must be one of {_BACKGROUNDS}, got {background!r}")
|
|
107
|
+
|
|
108
|
+
for field in _REQUIRED_FIELDS:
|
|
109
|
+
if field not in phase_info:
|
|
110
|
+
raise ValueError(f'phase_info is missing required field "{field}".')
|
|
111
|
+
alpha = float(phase_info.get("alpha", 90.0))
|
|
112
|
+
beta = float(phase_info.get("beta", 90.0))
|
|
113
|
+
gamma = float(phase_info.get("gamma", 90.0))
|
|
114
|
+
hkl_max = int(phase_info.get("hklMax", 6))
|
|
115
|
+
symmetry = str(phase_info["symmetry"])
|
|
116
|
+
cell0 = [
|
|
117
|
+
float(phase_info["a"]),
|
|
118
|
+
float(phase_info["b"]),
|
|
119
|
+
float(phase_info["c"]),
|
|
120
|
+
alpha,
|
|
121
|
+
beta,
|
|
122
|
+
gamma,
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
def compute_peaks(cell: list[float]) -> list[dict[str, Any]]:
|
|
126
|
+
ps = plane_spacings(
|
|
127
|
+
cell[0],
|
|
128
|
+
b=cell[1],
|
|
129
|
+
c=cell[2],
|
|
130
|
+
alpha=cell[3],
|
|
131
|
+
beta=cell[4],
|
|
132
|
+
gamma=cell[5],
|
|
133
|
+
centering=symmetry,
|
|
134
|
+
max_hkl=hkl_max,
|
|
135
|
+
lambda_=wavelength,
|
|
136
|
+
)
|
|
137
|
+
peaks: list[dict[str, Any]] = []
|
|
138
|
+
for hkl, d, tth, mult in zip(
|
|
139
|
+
ps["hkl"], ps["d"], ps["two_theta"], ps["multiplicity"], strict=True
|
|
140
|
+
):
|
|
141
|
+
if math.isnan(tth) or tth > max_two_theta or tth <= 0:
|
|
142
|
+
continue
|
|
143
|
+
peaks.append(
|
|
144
|
+
{"hkl": hkl, "two_theta": tth, "d": d, "multiplicity": mult, "intensity": 0.0}
|
|
145
|
+
)
|
|
146
|
+
return peaks
|
|
147
|
+
|
|
148
|
+
def trial_chi2(cell: list[float]) -> float:
|
|
149
|
+
peaks = compute_peaks(cell)
|
|
150
|
+
if not peaks:
|
|
151
|
+
return math.inf
|
|
152
|
+
model_y, _bg, _peak_i = _build_model(peaks, tt, obs, profile_fwhm)
|
|
153
|
+
resid = model_y - obs
|
|
154
|
+
return float(np.sum(resid**2))
|
|
155
|
+
|
|
156
|
+
# ── Adaptive grid search around the initial cell ────────────────────────
|
|
157
|
+
cell_refined = list(cell0)
|
|
158
|
+
if refine_cell:
|
|
159
|
+
tol_eq = 1e-4
|
|
160
|
+
is_cubic = abs(cell0[0] - cell0[1]) < tol_eq and abs(cell0[1] - cell0[2]) < tol_eq
|
|
161
|
+
is_tetrag = abs(cell0[0] - cell0[1]) < tol_eq and abs(cell0[1] - cell0[2]) >= tol_eq
|
|
162
|
+
if is_cubic:
|
|
163
|
+
axes_to_step = [0] # a only; mirror to b, c
|
|
164
|
+
elif is_tetrag:
|
|
165
|
+
axes_to_step = [0, 2] # a (mirror to b) and c
|
|
166
|
+
else:
|
|
167
|
+
axes_to_step = [0, 1, 2]
|
|
168
|
+
|
|
169
|
+
step = [0.02 * cell0[0], 0.02 * cell0[1], 0.02 * cell0[2]]
|
|
170
|
+
for _ in range(max_iter):
|
|
171
|
+
chi_base = trial_chi2(cell_refined)
|
|
172
|
+
improved = False
|
|
173
|
+
for ax in axes_to_step:
|
|
174
|
+
for sign in (1, -1):
|
|
175
|
+
trial = list(cell_refined)
|
|
176
|
+
if is_cubic:
|
|
177
|
+
new_val = cell_refined[0] + sign * step[0]
|
|
178
|
+
trial = [new_val, new_val, new_val, *cell_refined[3:6]]
|
|
179
|
+
elif is_tetrag and ax == 0:
|
|
180
|
+
new_val = cell_refined[0] + sign * step[0]
|
|
181
|
+
trial = [new_val, new_val, cell_refined[2], *cell_refined[3:6]]
|
|
182
|
+
else:
|
|
183
|
+
trial[ax] = cell_refined[ax] + sign * step[ax]
|
|
184
|
+
if trial_chi2(trial) < chi_base:
|
|
185
|
+
cell_refined = trial
|
|
186
|
+
improved = True
|
|
187
|
+
break
|
|
188
|
+
if not improved:
|
|
189
|
+
step = [s / 2 for s in step]
|
|
190
|
+
if max(abs(s) for s in step) < 1e-5:
|
|
191
|
+
break
|
|
192
|
+
|
|
193
|
+
# ── Final model on the refined cell ─────────────────────────────────────
|
|
194
|
+
peaks = compute_peaks(cell_refined)
|
|
195
|
+
model_y, bg, peak_i = _build_model(peaks, tt, obs, profile_fwhm)
|
|
196
|
+
residual = obs - model_y
|
|
197
|
+
|
|
198
|
+
weights = 1.0 / np.maximum(obs, 1.0) # Poisson-like weights
|
|
199
|
+
rwp_num = float(np.sum(weights * residual**2))
|
|
200
|
+
rwp_den = float(np.sum(weights * obs**2))
|
|
201
|
+
rwp = math.sqrt(rwp_num / rwp_den) if rwp_den > 0 else float("nan")
|
|
202
|
+
|
|
203
|
+
for k, pk in enumerate(peaks):
|
|
204
|
+
pk["intensity"] = float(peak_i[k]) if k < peak_i.size else 0.0
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
"cell": cell_refined,
|
|
208
|
+
"cell_initial": cell0,
|
|
209
|
+
"scale": float("nan"), # folded into per-peak intensity (scaffold)
|
|
210
|
+
"peaks": peaks,
|
|
211
|
+
"background": bg,
|
|
212
|
+
"model": model_y,
|
|
213
|
+
"residual": residual,
|
|
214
|
+
"rwp": rwp,
|
|
215
|
+
"n_peaks": len(peaks),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _build_model(
|
|
220
|
+
peaks: list[dict[str, Any]],
|
|
221
|
+
two_theta: NDArray[np.float64],
|
|
222
|
+
intensity: NDArray[np.float64],
|
|
223
|
+
profile_fwhm: float,
|
|
224
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
225
|
+
"""Fit peak intensities + a linear background by least-squares (``buildModel``).
|
|
226
|
+
|
|
227
|
+
Each peak is a fixed-FWHM 50–50 pseudo-Voigt column; the background basis is
|
|
228
|
+
``[1, 2θ]``. Solves ``[peaks | bg] · x = I`` and clamps peak intensities ``≥ 0``.
|
|
229
|
+
"""
|
|
230
|
+
n_pk = len(peaks)
|
|
231
|
+
if n_pk == 0:
|
|
232
|
+
zeros = np.zeros_like(two_theta)
|
|
233
|
+
return zeros, np.zeros_like(two_theta), np.zeros(0, dtype=float)
|
|
234
|
+
|
|
235
|
+
w = profile_fwhm / 2.0
|
|
236
|
+
basis = np.zeros((two_theta.size, n_pk), dtype=float)
|
|
237
|
+
for k, pk in enumerate(peaks):
|
|
238
|
+
dx = two_theta - pk["two_theta"]
|
|
239
|
+
lorentz = 1.0 / (1.0 + (dx / w) ** 2)
|
|
240
|
+
gauss = np.exp(-0.5 * (dx / (w / math.sqrt(2.0 * math.log(2.0)))) ** 2)
|
|
241
|
+
basis[:, k] = 0.5 * lorentz + 0.5 * gauss
|
|
242
|
+
|
|
243
|
+
bg_basis = np.column_stack([np.ones_like(two_theta), two_theta])
|
|
244
|
+
design = np.column_stack([basis, bg_basis])
|
|
245
|
+
coeffs, *_ = np.linalg.lstsq(design, intensity, rcond=None)
|
|
246
|
+
|
|
247
|
+
peak_intensity = np.asarray(np.maximum(coeffs[:n_pk], 0.0), dtype=float)
|
|
248
|
+
bg_coeff = coeffs[n_pk:]
|
|
249
|
+
bg = np.asarray(bg_basis @ bg_coeff, dtype=float)
|
|
250
|
+
model_y = np.asarray(basis @ peak_intensity + bg, dtype=float)
|
|
251
|
+
return model_y, bg, peak_intensity
|