quantized-lab 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,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
|