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
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)))
@@ -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}
@@ -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