quantized-lab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,425 @@
1
+ r"""Thin-film deposition / implantation / metrology calculators.
2
+
3
+ Ports DiraCulator ``buildThinFilmTab`` and every ``+calc/+thinFilm/*.m``
4
+ function verbatim. Pure calc layer — scalars (or short vectors) in, result
5
+ dicts out. No fastapi / pydantic imports. The MATLAB ``latex`` field is omitted.
6
+
7
+ .. math::
8
+
9
+ r = t / \tau \qquad L = \sqrt{D\,t} \qquad
10
+ \Phi = \frac{I\,t}{q\,A} \qquad C_{\text{peak}} = \frac{\Phi}{\sqrt{2\pi}\,\Delta R_p} \\
11
+ t_{\text{film}} = \frac{2\pi}{\Delta Q}
12
+ \;\;\Bigl(\text{or}\;\frac{2\pi}{\sqrt{\Delta Q^2 - 4 Q_c^2}}\Bigr) \\
13
+ \sigma = \frac{E_s\,t_s^2}{6(1-\nu_s)\,t_f\,R} \\
14
+ \varepsilon = (\alpha_f - \alpha_s)\,\Delta T \qquad
15
+ \dot d = \frac{Y\,(J/q)\,M}{\rho\,N_A}
16
+
17
+ Units follow the MATLAB toolbox (the behavioural reference): thickness in Å,
18
+ time in s, beam current in A, area in cm², dose in ions/cm², range in nm,
19
+ deltaQ in Å⁻¹, Stoney moduli/thicknesses in SI (Pa, m), CTE in 1/K, sputter
20
+ current density in mA/cm², density in g/cm³, molar mass in g/mol.
21
+
22
+ Reference values (closed-form / MATLAB docstrings):
23
+ - ``deposition_rate(100, 60) -> rate ≈ 1.667`` Å/s, ``10`` nm/min
24
+ - ``diffusion_length_thermal(1e-13, 3600) -> L ≈ 1.897e-5`` cm
25
+ - ``dose_from_current(1e-6, 60, 1.0) -> dose ≈ 3.745e14`` ions/cm²
26
+ - ``dose_to_concentration(1e15, 80, 25) -> Cpeak ≈ 1.596e20`` atoms/cm³
27
+ - ``kiessig_thickness(0.0628) -> thickness ≈ 100.05`` Å
28
+ - ``stoney_stress(130e9, 0.28, 500e-6, 100e-9, 10) -> stress ≈ 7.52e9`` Pa
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import math
34
+ from typing import Any
35
+
36
+ from quantized.calc import element_data
37
+ from quantized.calc.constants import constants
38
+
39
+ __all__ = [
40
+ "deposition_rate",
41
+ "diffusion_length_thermal",
42
+ "dose_from_current",
43
+ "dose_to_concentration",
44
+ "kiessig_thickness",
45
+ "multilayer_thermal_conductivity",
46
+ "projected_range",
47
+ "sputter_rate",
48
+ "stoney_stress",
49
+ "thermal_mismatch_strain",
50
+ ]
51
+
52
+
53
+ def deposition_rate(thickness: float, time: float) -> dict[str, float]:
54
+ """Deposition rate r = thickness/time (``depositionRate.m``).
55
+
56
+ Args:
57
+ thickness: deposited film thickness (Å), > 0.
58
+ time: deposition time (s), > 0.
59
+
60
+ Returns ``rate`` (Å/s) and ``rate_nm_per_min`` (nm/min, = rate·0.1·60).
61
+
62
+ >>> r = deposition_rate(100.0, 60.0)
63
+ >>> round(r["rate"], 4), round(r["rate_nm_per_min"], 4)
64
+ (1.6667, 10.0)
65
+ """
66
+ if thickness <= 0 or time <= 0:
67
+ raise ValueError("thickness and time must be positive")
68
+ rate = thickness / time
69
+ return {
70
+ "rate": rate,
71
+ "rate_nm_per_min": rate * 0.1 * 60,
72
+ "thickness": thickness,
73
+ "time": time,
74
+ }
75
+
76
+
77
+ def diffusion_length_thermal(d: float, t: float) -> dict[str, float]:
78
+ """Thermal diffusion length L = √(D·t) (``diffusionLength_thermal.m``).
79
+
80
+ Args:
81
+ d: diffusion coefficient (cm²/s), > 0.
82
+ t: anneal time (s), > 0.
83
+
84
+ Returns ``L`` (cm), ``L_nm`` (= L·1e7) and ``L_um`` (= L·1e4).
85
+
86
+ >>> round(diffusion_length_thermal(1e-13, 3600.0)["L_nm"], 4)
87
+ 189.7367
88
+ """
89
+ if d <= 0 or t <= 0:
90
+ raise ValueError("D and t must be positive")
91
+ length = math.sqrt(d * t)
92
+ return {
93
+ "L": length,
94
+ "L_nm": length * 1e7,
95
+ "L_um": length * 1e4,
96
+ "D": d,
97
+ "t": t,
98
+ }
99
+
100
+
101
+ def dose_from_current(current: float, time: float, area: float) -> dict[str, float]:
102
+ """Ion-implant dose Φ = I·t/(q·A) (``doseFromCurrent.m``).
103
+
104
+ Assumes singly charged ions. q is the elementary charge.
105
+
106
+ Args:
107
+ current: beam current (A), > 0.
108
+ time: implant time (s), > 0.
109
+ area: implanted area (cm²), > 0.
110
+
111
+ Returns ``dose`` (ions/cm²).
112
+
113
+ >>> round(dose_from_current(1e-6, 60.0, 1.0)["dose"] / 1e14, 4)
114
+ 3.7449
115
+ """
116
+ if current <= 0 or time <= 0 or area <= 0:
117
+ raise ValueError("current, time and area must be positive")
118
+ q = constants()["e"]
119
+ dose = (current * time) / (q * area)
120
+ return {"dose": dose, "current": current, "time": time, "area": area}
121
+
122
+
123
+ def dose_to_concentration(dose: float, rp: float, delta_rp: float) -> dict[str, float]:
124
+ """Peak implant concentration from dose + range straggle (``doseToConcentration.m``).
125
+
126
+ Gaussian depth profile centred at Rp with std dev ΔRp:
127
+ ``C_peak = dose / (√(2π)·ΔRp)`` with ΔRp converted nm→cm so C_peak is
128
+ in atoms/cm³.
129
+
130
+ Args:
131
+ dose: implanted dose (ions/cm²), > 0.
132
+ rp: projected range (nm), > 0 (echoed only).
133
+ delta_rp: range straggle (nm), > 0.
134
+
135
+ >>> round(dose_to_concentration(1e15, 80.0, 25.0)["Cpeak"] / 1e20, 4)
136
+ 1.5958
137
+ """
138
+ if dose <= 0 or rp <= 0 or delta_rp <= 0:
139
+ raise ValueError("dose, Rp and deltaRp must be positive")
140
+ delta_rp_cm = delta_rp * 1e-7 # nm -> cm
141
+ cpeak = dose / (math.sqrt(2 * math.pi) * delta_rp_cm)
142
+ return {"Cpeak": cpeak, "dose": dose, "Rp": rp, "deltaRp": delta_rp}
143
+
144
+
145
+ def kiessig_thickness(
146
+ delta_q: float, *, sld: float | None = None, qc: float | None = None
147
+ ) -> dict[str, Any]:
148
+ """Film thickness from Kiessig-fringe Q-spacing (``kiessigThickness.m``).
149
+
150
+ Kinematic (Born) limit ``t = 2π/ΔQ`` neglects refraction at the
151
+ vacuum-film interface (accurate well above the critical edge). Supplying a
152
+ scattering-length density (or Qc directly) uses the refraction-corrected
153
+ form (Tolan Ch. 3.3) ``t = 2π/√(ΔQ² − 4 Q_c²)`` with ``Q_c = 4√(π·SLD)``.
154
+
155
+ Args:
156
+ delta_q: Q-spacing of adjacent fringes (Å⁻¹), > 0.
157
+ sld: layer SLD (Å⁻²); if given (>0), sets Qc = 4√(π·SLD).
158
+ qc: critical-edge Q (Å⁻¹) directly; ignored when ``sld`` is given.
159
+
160
+ Returns ``thickness`` (Å), ``thickness_nm``, ``Qc`` (NaN when uncorrected),
161
+ and ``thickness_raw`` (the uncorrected 2π/ΔQ). When ΔQ ≤ 2·Qc the corrected
162
+ formula would diverge, so it falls back to the kinematic value (Qc=NaN).
163
+
164
+ >>> round(kiessig_thickness(0.0628)["thickness"], 2)
165
+ 100.05
166
+ """
167
+ if delta_q <= 0:
168
+ raise ValueError("deltaQ must be positive")
169
+
170
+ qc_eff = qc
171
+ if sld is not None and sld > 0:
172
+ qc_eff = 4 * math.sqrt(math.pi * sld) # Q_c^2 = 16*pi*SLD
173
+
174
+ thickness_raw = 2 * math.pi / delta_q
175
+ if qc_eff is None or math.isnan(qc_eff) or qc_eff <= 0:
176
+ thickness = thickness_raw
177
+ qc_used = float("nan")
178
+ else:
179
+ arg = delta_q**2 - 4 * qc_eff**2
180
+ if arg <= 0:
181
+ # deltaQ at/below 2*Qc: correction diverges -> fall back, Qc=NaN.
182
+ thickness = thickness_raw
183
+ qc_used = float("nan")
184
+ else:
185
+ thickness = 2 * math.pi / math.sqrt(arg)
186
+ qc_used = qc_eff
187
+
188
+ return {
189
+ "thickness": thickness,
190
+ "thickness_nm": thickness * 0.1,
191
+ "deltaQ": delta_q,
192
+ "Qc": qc_used,
193
+ "thickness_raw": thickness_raw,
194
+ }
195
+
196
+
197
+ def multilayer_thermal_conductivity(
198
+ thicknesses: list[float], kappas: list[float]
199
+ ) -> dict[str, Any]:
200
+ """Effective thermal conductivity of a multilayer stack (``multilayerThermalConductivity.m``).
201
+
202
+ Series (heat flow ⊥ layers): ``k = Σdᵢ / Σ(dᵢ/kᵢ)``.
203
+ Parallel (heat flow ∥ layers): ``k = Σ(kᵢ·dᵢ) / Σdᵢ``.
204
+
205
+ Args:
206
+ thicknesses: layer thicknesses (nm), all > 0.
207
+ kappas: layer thermal conductivities (W/m/K), all > 0, same length.
208
+
209
+ Returns ``k_series``, ``k_parallel`` (W/m/K), ``total_thickness`` (nm),
210
+ ``n_layers``.
211
+
212
+ >>> r = multilayer_thermal_conductivity([100.0, 50.0], [1.4, 148.0])
213
+ >>> round(r["k_series"], 4), round(r["k_parallel"], 4)
214
+ (2.0901, 50.2667)
215
+ """
216
+ if len(thicknesses) != len(kappas):
217
+ raise ValueError("thicknesses and kappas must have the same number of elements")
218
+ if not thicknesses:
219
+ raise ValueError("at least one layer is required")
220
+ if any(d <= 0 for d in thicknesses) or any(k <= 0 for k in kappas):
221
+ raise ValueError("thicknesses and kappas must be positive")
222
+
223
+ total = math.fsum(thicknesses)
224
+ k_series = total / math.fsum(d / k for d, k in zip(thicknesses, kappas, strict=True))
225
+ k_parallel = math.fsum(k * d for k, d in zip(kappas, thicknesses, strict=True)) / total
226
+ return {
227
+ "k_series": k_series,
228
+ "k_parallel": k_parallel,
229
+ "total_thickness": total,
230
+ "n_layers": len(thicknesses),
231
+ }
232
+
233
+
234
+ def projected_range(ion: str, target: str, energy: float) -> dict[str, Any]:
235
+ """Ion projected range + straggle via simplified LSS theory (``projectedRange.m``).
236
+
237
+ Combines ZBL nuclear stopping and the LSS velocity-proportional electronic
238
+ stopping; straggle uses the Lindhard form
239
+ ``ΔRp ≈ 0.4·Rp·√(M₁M₂)/(M₁+M₂)``. Target atomic density comes from
240
+ ``element_data`` (bulk density / molar mass); elements with no density fall
241
+ back to 5 g/cm³. Accuracy ±20–30 % — use SRIM/TRIM for precise work.
242
+
243
+ Args:
244
+ ion: incident-ion symbol (e.g. 'Ar').
245
+ target: target-material symbol (e.g. 'Si').
246
+ energy: ion energy (keV), > 0.
247
+
248
+ Returns ``Rp`` and ``deltaRp`` (nm), plus a ``warning`` caveat string.
249
+ """
250
+ if energy <= 0:
251
+ raise ValueError("energy must be positive")
252
+
253
+ el_ion = element_data.by_symbol(ion)
254
+ el_target = element_data.by_symbol(target)
255
+ z1 = float(el_ion["Z"])
256
+ m1 = float(el_ion["mass"])
257
+ z2 = float(el_target["Z"])
258
+ m2 = float(el_target["mass"])
259
+
260
+ na = constants()["NA"]
261
+ rho_target = el_target.get("density")
262
+ if rho_target is None or rho_target <= 0:
263
+ rho_target = 5.0 # fallback (g/cm^3)
264
+ n = rho_target * na / m2 # atoms/cm^3
265
+
266
+ z_screen = z1 ** (2 / 3) + z2 ** (2 / 3)
267
+ a = 0.4685 / math.sqrt(z_screen) # Thomas-Fermi screening length (Å)
268
+ epsilon = 32.53 * m2 * energy / (z1 * z2 * (m1 + m2) * math.sqrt(z_screen))
269
+
270
+ sqrt_eps = math.sqrt(epsilon)
271
+ sn_reduced = (
272
+ 3.441 * sqrt_eps * math.log(epsilon + 2.718)
273
+ ) / (1 + 6.355 * sqrt_eps + epsilon * (6.882 * sqrt_eps - 1.708))
274
+ sn = sn_reduced * 4 * math.pi * a * z1 * z2 * (m1 / (m1 + m2)) * 1e-8 * 14.4 / z_screen
275
+
276
+ se = (
277
+ 0.0793
278
+ * z1 ** (2 / 3)
279
+ * math.sqrt(z2)
280
+ * (m1 + m2) ** 1.5
281
+ / (m1**1.5 * math.sqrt(m2) * z_screen**0.75)
282
+ * math.sqrt(energy / m1)
283
+ * 1e-15
284
+ )
285
+
286
+ energy_ev = energy * 1e3
287
+ rp_cm = energy_ev / (n * (sn + se))
288
+ rp = rp_cm * 1e7 # cm -> nm
289
+ delta_rp = 0.4 * rp * math.sqrt(m1 * m2) / (m1 + m2)
290
+
291
+ return {
292
+ "Rp": rp,
293
+ "deltaRp": delta_rp,
294
+ "ion": ion,
295
+ "target": target,
296
+ "energy": energy,
297
+ "warning": "Approximate (±20-30%). Use SRIM for precise work.",
298
+ }
299
+
300
+
301
+ def sputter_rate(y: float, j: float, rho: float, m: float) -> dict[str, float]:
302
+ """Sputter erosion rate from yield + current density (``sputterRate.m``).
303
+
304
+ Ion flux φ = J/q (J in A/cm²); atom flux = Y·φ; volume flux =
305
+ Y·φ·M/(ρ·N_A) (cm/s); rate (nm/s) = volume flux · 1e7. J is supplied in
306
+ mA/cm² and converted to A/cm² internally.
307
+
308
+ Args:
309
+ y: sputter yield (atoms/ion), > 0.
310
+ j: ion current density (mA/cm²), > 0.
311
+ rho: target bulk density (g/cm³), > 0.
312
+ m: target molar mass (g/mol), > 0.
313
+
314
+ Returns ``rate`` (nm/s) and ``rate_nm_per_min``.
315
+
316
+ >>> round(sputter_rate(2.5, 1.0, 19.3, 196.97)["rate"], 4)
317
+ 2.6444
318
+ """
319
+ if y <= 0 or j <= 0 or rho <= 0 or m <= 0:
320
+ raise ValueError("Y, J, rho and M must be positive")
321
+ c = constants()
322
+ j_a = j * 1e-3 # mA/cm^2 -> A/cm^2
323
+ flux = j_a / c["e"] # ions/cm^2/s
324
+ rate_cmps = y * flux * m / (rho * c["NA"])
325
+ rate = rate_cmps * 1e7 # cm/s -> nm/s
326
+ return {
327
+ "rate": rate,
328
+ "rate_nm_per_min": rate * 60,
329
+ "Y": y,
330
+ "J": j,
331
+ "rho": rho,
332
+ "M": m,
333
+ }
334
+
335
+
336
+ def stoney_stress(es: float, nus: float, ts: float, tf: float, r: float) -> dict[str, float]:
337
+ """Biaxial film stress via the Stoney equation (``stoneyStress.m``).
338
+
339
+ ``σ = E_s·t_s² / (6·(1−ν_s)·t_f·R)``. Positive σ = tensile, negative =
340
+ compressive (sign carried by R, the radius of curvature).
341
+
342
+ Args:
343
+ es: substrate Young's modulus (Pa), > 0.
344
+ nus: substrate Poisson ratio (dimensionless), >= 0.
345
+ ts: substrate thickness (m), > 0.
346
+ tf: film thickness (m), > 0.
347
+ r: substrate radius of curvature (m), non-zero (positive = concave up).
348
+
349
+ Returns ``stress`` (Pa), ``stress_MPa``, ``stress_GPa``.
350
+
351
+ >>> round(stoney_stress(130e9, 0.28, 500e-6, 100e-9, 10.0)["stress"] / 1e9, 4)
352
+ 7.5231
353
+ """
354
+ if es <= 0 or ts <= 0 or tf <= 0:
355
+ raise ValueError("Es, ts and tf must be positive")
356
+ if not (0 <= nus < 1):
357
+ # nus == 1 divides by zero in the biaxial modulus (1 - nus) below.
358
+ raise ValueError("nus must satisfy 0 <= nus < 1")
359
+ if r == 0:
360
+ raise ValueError("radius of curvature R must be non-zero")
361
+ stress = (es * ts**2) / (6 * (1 - nus) * tf * r)
362
+ return {
363
+ "stress": stress,
364
+ "stress_MPa": stress * 1e-6,
365
+ "stress_GPa": stress * 1e-9,
366
+ "Es": es,
367
+ "ts": ts,
368
+ "tf": tf,
369
+ "R": r,
370
+ }
371
+
372
+
373
+ def thermal_mismatch_strain(
374
+ alpha_film: float,
375
+ alpha_sub: float,
376
+ delta_t: float,
377
+ *,
378
+ e: float | None = None,
379
+ nu: float = 0.3,
380
+ ) -> dict[str, Any]:
381
+ """Thermal-mismatch strain (and optional biaxial stress) (``thermalMismatchStrain.m``).
382
+
383
+ ``ε = (α_f − α_s)·ΔT``; if a film biaxial modulus E is given,
384
+ ``σ = E·ε/(1−ν)``. Positive = tensile, negative = compressive.
385
+
386
+ Args:
387
+ alpha_film: film linear CTE (1/K).
388
+ alpha_sub: substrate linear CTE (1/K).
389
+ delta_t: temperature change T_final − T_initial (K).
390
+ e: film biaxial modulus (Pa); enables the stress calculation.
391
+ nu: film Poisson ratio (used only when ``e`` is given), >= 0; default 0.3.
392
+
393
+ Returns ``strain`` (dimensionless), ``stress_MPa`` (NaN when E absent), and
394
+ ``description`` ('tensile' / 'compressive' / 'none').
395
+
396
+ >>> r = thermal_mismatch_strain(17e-6, 3e-6, -500.0)
397
+ >>> round(r["strain"], 9), r["description"]
398
+ (-0.007, 'compressive')
399
+ """
400
+ if not (0 <= nu < 1):
401
+ # nu == 1 divides by zero in the biaxial modulus below; nu > 0.5 is
402
+ # already unphysical for a real solid.
403
+ raise ValueError("nu must satisfy 0 <= nu < 1")
404
+ strain = (alpha_film - alpha_sub) * delta_t
405
+
406
+ if e is not None:
407
+ stress_mpa: float = (e * strain / (1 - nu)) * 1e-6
408
+ else:
409
+ stress_mpa = float("nan")
410
+
411
+ if strain > 0:
412
+ desc = "tensile"
413
+ elif strain < 0:
414
+ desc = "compressive"
415
+ else:
416
+ desc = "none"
417
+
418
+ return {
419
+ "strain": strain,
420
+ "stress_MPa": stress_mpa,
421
+ "alphaFilm": alpha_film,
422
+ "alphaSub": alpha_sub,
423
+ "deltaT": delta_t,
424
+ "description": desc,
425
+ }
@@ -0,0 +1,259 @@
1
+ """General unit-expression converter. Port of calc.unitConvert.
2
+
3
+ Pure calc layer. Parses compound unit strings (e.g. ``mA/cm^2``, ``uOhm*cm``)
4
+ into a 7-D SI dimension vector + scale, then converts by ratio of scales — with
5
+ special handling for temperature offsets (K/C/F) and equivalence bridges
6
+ (energy↔wavelength/frequency/wavenumber, H↔B field).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ from numpy.typing import ArrayLike, NDArray
16
+
17
+ from .constants import constants
18
+
19
+ __all__ = ["unit_convert"]
20
+
21
+ # Dimension vector order: [M L T I Theta N J] (kg m s A K mol cd)
22
+ _ZERO = (0, 0, 0, 0, 0, 0, 0)
23
+
24
+ # Base unit registry: name -> (dimension tuple, factor to SI).
25
+ _BASE_UNITS: dict[str, tuple[tuple[int, ...], float]] = {
26
+ "m": ((0, 1, 0, 0, 0, 0, 0), 1.0),
27
+ "Ang": ((0, 1, 0, 0, 0, 0, 0), 1e-10),
28
+ "angstrom": ((0, 1, 0, 0, 0, 0, 0), 1e-10),
29
+ "kg": ((1, 0, 0, 0, 0, 0, 0), 1.0),
30
+ "g": ((1, 0, 0, 0, 0, 0, 0), 1e-3),
31
+ "u": ((1, 0, 0, 0, 0, 0, 0), 1.66053906660e-27),
32
+ "amu": ((1, 0, 0, 0, 0, 0, 0), 1.66053906660e-27),
33
+ "s": ((0, 0, 1, 0, 0, 0, 0), 1.0),
34
+ "min": ((0, 0, 1, 0, 0, 0, 0), 60.0),
35
+ "hr": ((0, 0, 1, 0, 0, 0, 0), 3600.0),
36
+ "A": ((0, 0, 0, 1, 0, 0, 0), 1.0),
37
+ "K": ((0, 0, 0, 0, 1, 0, 0), 1.0),
38
+ "C": ((0, 0, 0, 0, 1, 0, 0), 1.0),
39
+ "F": ((0, 0, 0, 0, 1, 0, 0), 1.0),
40
+ "mol": ((0, 0, 0, 0, 0, 1, 0), 1.0),
41
+ "Hz": ((0, 0, -1, 0, 0, 0, 0), 1.0),
42
+ "THz": ((0, 0, -1, 0, 0, 0, 0), 1e12),
43
+ "N": ((1, 1, -2, 0, 0, 0, 0), 1.0),
44
+ "J": ((1, 2, -2, 0, 0, 0, 0), 1.0),
45
+ "eV": ((1, 2, -2, 0, 0, 0, 0), 1.602176634e-19),
46
+ "erg": ((1, 2, -2, 0, 0, 0, 0), 1e-7),
47
+ "cal": ((1, 2, -2, 0, 0, 0, 0), 4.184),
48
+ "W": ((1, 2, -3, 0, 0, 0, 0), 1.0),
49
+ "Pa": ((1, -1, -2, 0, 0, 0, 0), 1.0),
50
+ "bar": ((1, -1, -2, 0, 0, 0, 0), 1e5),
51
+ "atm": ((1, -1, -2, 0, 0, 0, 0), 101325.0),
52
+ "Torr": ((1, -1, -2, 0, 0, 0, 0), 133.322),
53
+ "mbar": ((1, -1, -2, 0, 0, 0, 0), 100.0),
54
+ "psi": ((1, -1, -2, 0, 0, 0, 0), 6894.76),
55
+ "GPa": ((1, -1, -2, 0, 0, 0, 0), 1e9),
56
+ "MPa": ((1, -1, -2, 0, 0, 0, 0), 1e6),
57
+ "V": ((1, 2, -3, -1, 0, 0, 0), 1.0),
58
+ "Ohm": ((1, 2, -3, -2, 0, 0, 0), 1.0),
59
+ "ohm": ((1, 2, -3, -2, 0, 0, 0), 1.0),
60
+ "S": ((-1, -2, 3, 2, 0, 0, 0), 1.0),
61
+ "F_cap": ((-1, -2, 4, 2, 0, 0, 0), 1.0),
62
+ "Coul": ((0, 0, 1, 1, 0, 0, 0), 1.0),
63
+ "T": ((1, 0, -2, -1, 0, 0, 0), 1.0),
64
+ "G": ((1, 0, -2, -1, 0, 0, 0), 1e-4),
65
+ "Oe": ((0, -1, 0, 1, 0, 0, 0), 1000.0 / (4 * np.pi)),
66
+ "emu": ((0, 2, 0, 1, 0, 0, 0), 1e-3),
67
+ "rad": (_ZERO, 1.0),
68
+ "deg": (_ZERO, float(np.pi / 180.0)),
69
+ "mrad": (_ZERO, 1e-3),
70
+ "ions": (_ZERO, 1.0),
71
+ "counts": (_ZERO, 1.0),
72
+ "sq": (_ZERO, 1.0),
73
+ }
74
+
75
+ _PREFIXES: dict[str, float] = {
76
+ "Y": 1e24, "Z": 1e21, "E": 1e18, "P": 1e15, "T": 1e12, "G": 1e9, "M": 1e6,
77
+ "k": 1e3, "h": 1e2, "da": 1e1, "d": 1e-1, "c": 1e-2, "m": 1e-3, "u": 1e-6,
78
+ "mu": 1e-6, "micro": 1e-6, "n": 1e-9, "p": 1e-12, "f": 1e-15, "a": 1e-18,
79
+ }
80
+ # Longest prefix first (ties don't matter — each first char maps uniquely).
81
+ _PREFIX_KEYS = sorted(_PREFIXES, key=len, reverse=True)
82
+
83
+ _TEMP_DIM = np.array([0, 0, 0, 0, 1, 0, 0], dtype=float)
84
+
85
+
86
+ _DIGITS = frozenset("0123456789")
87
+
88
+
89
+ def _parse_exponent(s: str) -> float | None:
90
+ r"""Parse a unit exponent of the form ``[+-]?\d+\.?\d*`` (e.g. ``2``, ``-3``,
91
+ ``2.5``, ``2.``) without a regular expression.
92
+
93
+ The previous ``re.match(r"^(.+?)\^([+-]?\d+\.?\d*)$", chunk)`` was a
94
+ polynomial-ReDoS sink: the lazy ``.+?`` and the two unbounded digit runs let
95
+ an adversarial unit string (``"a^9" + "99"*n``) force O(n²) backtracking. A
96
+ single linear scan removes the sink while keeping identical accept/reject
97
+ semantics. Returns the float value, or ``None`` if ``s`` is not a valid
98
+ exponent (so the caller treats the chunk as having an implicit exponent 1)."""
99
+ body = s[1:] if s[:1] in "+-" else s
100
+ if not body or body[0] == "." or body.count(".") > 1:
101
+ return None # need ≥1 leading digit and at most one decimal point
102
+ if any(c not in _DIGITS and c != "." for c in body):
103
+ return None
104
+ return float(s)
105
+
106
+
107
+ def _tokenize(unit_str: str) -> list[dict[str, Any]]:
108
+ tokens: list[dict[str, Any]] = []
109
+ in_denom = False
110
+ remaining = unit_str.strip()
111
+ while remaining:
112
+ match = re.search(r"[/*]", remaining)
113
+ if match is None:
114
+ chunk, op, remaining = remaining, None, ""
115
+ else:
116
+ i = match.start()
117
+ chunk, op, remaining = remaining[:i], remaining[i], remaining[i + 1 :]
118
+ chunk = chunk.strip()
119
+ if not chunk:
120
+ if op == "/":
121
+ in_denom = True
122
+ continue
123
+ # First '^' splits base from exponent (mirrors the old lazy ``.+?\^``).
124
+ caret = chunk.find("^")
125
+ exp_val = _parse_exponent(chunk[caret + 1 :]) if caret > 0 else None
126
+ if exp_val is not None:
127
+ tok_str, tok_exp = chunk[:caret], exp_val
128
+ else:
129
+ tok_str, tok_exp = chunk, 1.0
130
+ tokens.append({"str": tok_str, "exp": tok_exp, "in_denom": in_denom})
131
+ if op == "/":
132
+ in_denom = True
133
+ return tokens
134
+
135
+
136
+ def _decompose_token(tok_str: str) -> tuple[NDArray[np.float64], float]:
137
+ if tok_str in _BASE_UNITS:
138
+ dims, to_si = _BASE_UNITS[tok_str]
139
+ return np.array(dims, dtype=float), to_si
140
+ for pfx in _PREFIX_KEYS:
141
+ if len(tok_str) > len(pfx) and tok_str.startswith(pfx):
142
+ rem = tok_str[len(pfx) :]
143
+ if rem in _BASE_UNITS:
144
+ dims, to_si = _BASE_UNITS[rem]
145
+ return np.array(dims, dtype=float), to_si * _PREFIXES[pfx]
146
+ return np.zeros(7), 1.0
147
+
148
+
149
+ def _parse_units(unit_str: str) -> dict[str, Any]:
150
+ dims = np.zeros(7)
151
+ scale = 1.0
152
+ for tok in _tokenize(unit_str):
153
+ base_dims, base_scale = _decompose_token(tok["str"])
154
+ try:
155
+ total_scale = base_scale ** tok["exp"]
156
+ except OverflowError as exc:
157
+ raise ValueError(f"unit exponent too large in {unit_str!r}") from exc
158
+ if total_scale == 0.0 and base_scale != 0.0:
159
+ # A huge negative exponent underflowed the scale to exactly 0.0, which
160
+ # would divide-by-zero downstream; reject instead of crashing.
161
+ raise ValueError(f"unit exponent underflows the scale to zero in {unit_str!r}")
162
+ if tok["in_denom"]:
163
+ dims = dims - base_dims * tok["exp"]
164
+ scale = scale / total_scale
165
+ else:
166
+ dims = dims + base_dims * tok["exp"]
167
+ scale = scale * total_scale
168
+ return {"dims": dims, "scale": scale, "display": unit_str}
169
+
170
+
171
+ def _identify_temp_unit(unit_str: str) -> str:
172
+ return {"K": "K", "C": "C", "degC": "C", "F": "F", "degF": "F"}.get(unit_str.strip(), "")
173
+
174
+
175
+ def _try_temperature(
176
+ value: NDArray[np.float64], from_p: dict[str, Any], to_p: dict[str, Any],
177
+ from_str: str, to_str: str,
178
+ ) -> tuple[bool, NDArray[np.float64] | None, float]:
179
+ if not (np.array_equal(from_p["dims"], _TEMP_DIM) and np.array_equal(to_p["dims"], _TEMP_DIM)):
180
+ return False, None, float("nan")
181
+ from_u, to_u = _identify_temp_unit(from_str), _identify_temp_unit(to_str)
182
+ if not from_u or not to_u:
183
+ return False, None, float("nan")
184
+ val_k = {"K": value, "C": value + 273.15, "F": (value - 32) * 5 / 9 + 273.15}[from_u]
185
+ result = {"K": val_k, "C": val_k - 273.15, "F": (val_k - 273.15) * 9 / 5 + 32}[to_u]
186
+ return True, np.asarray(result, dtype=float), float("nan")
187
+
188
+
189
+ def _try_bridge(
190
+ value: NDArray[np.float64], from_p: dict[str, Any], to_p: dict[str, Any]
191
+ ) -> tuple[bool, NDArray[np.float64] | None, float]:
192
+ c = constants()
193
+ si = value * from_p["scale"]
194
+ hc = c["h"] * c["c"]
195
+ ts = to_p["scale"]
196
+ energy = np.array([1, 2, -2, 0, 0, 0, 0], dtype=float)
197
+ length = np.array([0, 1, 0, 0, 0, 0, 0], dtype=float)
198
+ freq = np.array([0, 0, -1, 0, 0, 0, 0], dtype=float)
199
+ inv_len = np.array([0, -1, 0, 0, 0, 0, 0], dtype=float)
200
+ h_field = np.array([0, -1, 0, 1, 0, 0, 0], dtype=float)
201
+ b_field = np.array([1, 0, -2, -1, 0, 0, 0], dtype=float)
202
+ nan = float("nan")
203
+ fd, td = from_p["dims"], to_p["dims"]
204
+
205
+ def done(result_si: NDArray[np.float64]) -> tuple[bool, NDArray[np.float64], float]:
206
+ return True, np.asarray(result_si / ts, dtype=float), nan
207
+
208
+ if np.array_equal(fd, energy) and np.array_equal(td, length):
209
+ return done(hc / si)
210
+ if np.array_equal(fd, length) and np.array_equal(td, energy):
211
+ return done(hc / si)
212
+ if np.array_equal(fd, energy) and np.array_equal(td, freq):
213
+ return done(si / c["h"])
214
+ if np.array_equal(fd, freq) and np.array_equal(td, energy):
215
+ return done(si * c["h"])
216
+ if np.array_equal(fd, energy) and np.array_equal(td, inv_len):
217
+ return done(si / hc)
218
+ if np.array_equal(fd, inv_len) and np.array_equal(td, energy):
219
+ return done(si * hc)
220
+ if np.array_equal(fd, h_field) and np.array_equal(td, b_field):
221
+ return done(si * c["mu0"])
222
+ if np.array_equal(fd, b_field) and np.array_equal(td, h_field):
223
+ return done(si / c["mu0"])
224
+ return False, None, nan
225
+
226
+
227
+ def unit_convert(
228
+ value: ArrayLike, from_str: str, to_str: str
229
+ ) -> tuple[NDArray[np.float64], dict[str, Any]]:
230
+ """Convert ``value`` between unit expressions. Port of calc.unitConvert.
231
+
232
+ Returns ``(result, info)`` where ``info`` has ``factor`` (NaN for nonlinear
233
+ temperature/bridge conversions), ``fromParsed``/``toParsed`` (dims + scale),
234
+ and a ``description``. Raises ``ValueError`` on incompatible dimensions.
235
+ """
236
+ val = np.asarray(value, dtype=float)
237
+ from_p = _parse_units(from_str)
238
+ to_p = _parse_units(to_str)
239
+
240
+ ok, result, factor = _try_temperature(val, from_p, to_p, from_str, to_str)
241
+ if not ok:
242
+ if np.array_equal(from_p["dims"], to_p["dims"]):
243
+ factor = float(from_p["scale"] / to_p["scale"])
244
+ result = np.asarray(val * factor, dtype=float)
245
+ else:
246
+ ok, result, factor = _try_bridge(val, from_p, to_p)
247
+ if not ok:
248
+ raise ValueError(
249
+ f"cannot convert from '{from_str}' to '{to_str}': incompatible dimensions"
250
+ )
251
+ assert result is not None
252
+ desc = f"{from_str} -> {to_str}" if np.isnan(factor) else f"1 {from_str} = {factor:g} {to_str}"
253
+ info = {
254
+ "factor": factor,
255
+ "fromParsed": {"dims": from_p["dims"], "scale": from_p["scale"], "display": from_str},
256
+ "toParsed": {"dims": to_p["dims"], "scale": to_p["scale"], "display": to_str},
257
+ "description": desc,
258
+ }
259
+ return result, info