quantum-playground 0.3.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.
- qplay/__init__.py +29 -0
- qplay/__main__.py +58 -0
- qplay/animations.py +172 -0
- qplay/atomic.py +86 -0
- qplay/chaos.py +83 -0
- qplay/circuits.py +75 -0
- qplay/complexplane.py +41 -0
- qplay/demos.py +464 -0
- qplay/dynamics.py +71 -0
- qplay/em.py +80 -0
- qplay/hamiltonian.py +58 -0
- qplay/integrators.py +67 -0
- qplay/kinetic.py +163 -0
- qplay/linalg.py +107 -0
- qplay/mechanics.py +142 -0
- qplay/nuclear.py +80 -0
- qplay/numerics.py +85 -0
- qplay/plotting.py +59 -0
- qplay/quantum.py +294 -0
- qplay/relativity.py +59 -0
- qplay/statmech.py +53 -0
- qplay/symbolic.py +57 -0
- qplay/waves.py +87 -0
- quantum_playground-0.3.0.dist-info/METADATA +184 -0
- quantum_playground-0.3.0.dist-info/RECORD +29 -0
- quantum_playground-0.3.0.dist-info/WHEEL +5 -0
- quantum_playground-0.3.0.dist-info/entry_points.txt +2 -0
- quantum_playground-0.3.0.dist-info/licenses/LICENSE +21 -0
- quantum_playground-0.3.0.dist-info/top_level.txt +1 -0
qplay/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""qplay: a small physics and maths playground.
|
|
2
|
+
|
|
3
|
+
Modules
|
|
4
|
+
-------
|
|
5
|
+
integrators time-stepping schemes (Euler, RK4, velocity Verlet, adaptive wrapper)
|
|
6
|
+
mechanics oscillators, pendulums, projectiles, N-body gravity
|
|
7
|
+
waves 1D wave and heat equations, Fourier series, spectra
|
|
8
|
+
em point-charge fields, Laplace solver, charged-particle motion
|
|
9
|
+
quantum Schroedinger solvers (1D and 2D), tunnelling, double slit, hydrogen orbitals, spin-1/2,
|
|
10
|
+
oscillator eigenstates and coherent states
|
|
11
|
+
statmech 2D Ising model and Onsager's exact solution
|
|
12
|
+
relativity Lorentz transformations and special-relativity helpers
|
|
13
|
+
kinetic hard-sphere gas, Maxwell-Boltzmann distribution, ideal-gas law
|
|
14
|
+
atomic angular momentum, spin-orbit coupling, Zeeman effect, hydrogen fine structure
|
|
15
|
+
nuclear radioactive decay, Bateman decay chains, Monte Carlo decay
|
|
16
|
+
circuits RC charging and discharging, series RLC resonance
|
|
17
|
+
chaos Lorenz system, logistic map, Mandelbrot/Julia sets
|
|
18
|
+
symbolic SymPy helpers: Euler-Lagrange equations, Lagrangian -> simulation
|
|
19
|
+
hamiltonian Legendre transform, Hamilton's equations, Hamiltonian -> simulation
|
|
20
|
+
dynamics phase portraits and fixed-point classification for 2D systems
|
|
21
|
+
complexplane domain colouring and the argument principle
|
|
22
|
+
linalg Gram-Schmidt, power iteration, how a matrix maps the unit circle
|
|
23
|
+
numerics root finding, quadrature, finite differences, Monte Carlo
|
|
24
|
+
plotting figure saving and GIF helpers
|
|
25
|
+
demos ready-made figures, run with ``python -m qplay run <name>``
|
|
26
|
+
animations GIFs, run with ``python -m qplay animate <name>``
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__version__ = "0.3.0"
|
qplay/__main__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Command line.
|
|
2
|
+
|
|
3
|
+
qplay list show demos and animations
|
|
4
|
+
qplay run <name|all> [--out DIR] render demo figures (PNG)
|
|
5
|
+
qplay animate <name|all> [--frames N] [--fps F] [--out DIR] render animations (GIF)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
from . import __version__, animations, demos
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _summary(fn):
|
|
16
|
+
doc = (fn.__doc__ or "").strip().splitlines()
|
|
17
|
+
return doc[0] if doc else ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv=None):
|
|
21
|
+
parser = argparse.ArgumentParser(prog="qplay", description="Physics and maths playground")
|
|
22
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
23
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
sub.add_parser("list", help="list demos and animations")
|
|
25
|
+
run = sub.add_parser("run", help="render demos to PNG files")
|
|
26
|
+
run.add_argument("names", nargs="+", help="demo names, or 'all'")
|
|
27
|
+
run.add_argument("--out", default="figures", help="output directory (default: figures)")
|
|
28
|
+
anim = sub.add_parser("animate", help="render animations to GIF files")
|
|
29
|
+
anim.add_argument("names", nargs="+", help="animation names, or 'all'")
|
|
30
|
+
anim.add_argument("--out", default="figures", help="output directory (default: figures)")
|
|
31
|
+
anim.add_argument("--frames", type=int, default=120, help="number of frames (default: 120)")
|
|
32
|
+
anim.add_argument("--fps", type=int, default=24, help="frames per second (default: 24)")
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
if args.command == "list":
|
|
36
|
+
print("Demos (qplay run NAME):")
|
|
37
|
+
for name in sorted(demos.DEMOS):
|
|
38
|
+
print(f" {name:22s}{_summary(demos.DEMOS[name])}")
|
|
39
|
+
print("Animations (qplay animate NAME):")
|
|
40
|
+
for name in sorted(animations.ANIMATIONS):
|
|
41
|
+
print(f" {name:22s}{_summary(animations.ANIMATIONS[name])}")
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
registry = demos.DEMOS if args.command == "run" else animations.ANIMATIONS
|
|
45
|
+
names = sorted(registry) if args.names == ["all"] else args.names
|
|
46
|
+
for name in names:
|
|
47
|
+
try:
|
|
48
|
+
if args.command == "run":
|
|
49
|
+
print(demos.run(name, args.out))
|
|
50
|
+
else:
|
|
51
|
+
print(animations.render(name, args.out, n_frames=args.frames, fps=args.fps))
|
|
52
|
+
except KeyError as err:
|
|
53
|
+
parser.error(str(err.args[0]))
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|
qplay/animations.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Animated GIFs. Each animation takes ``n_frames`` and returns ``(fig, update, frames)``.
|
|
2
|
+
|
|
3
|
+
Register one with ``@animation("name")``; render with ``python -m qplay animate name``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import numpy as np
|
|
12
|
+
from matplotlib.colors import PowerNorm
|
|
13
|
+
|
|
14
|
+
from . import kinetic, mechanics, plotting, quantum, statmech
|
|
15
|
+
|
|
16
|
+
ANIMATIONS: dict = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def animation(name):
|
|
20
|
+
def register(fn):
|
|
21
|
+
ANIMATIONS[name] = fn
|
|
22
|
+
return fn
|
|
23
|
+
|
|
24
|
+
return register
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render(name, outdir="figures", n_frames=120, fps=24):
|
|
28
|
+
"""Render one animation to ``<outdir>/<name>.gif`` and return the path."""
|
|
29
|
+
if name not in ANIMATIONS:
|
|
30
|
+
raise KeyError(f"unknown animation {name!r}; available: {', '.join(sorted(ANIMATIONS))}")
|
|
31
|
+
fig, update, frames = ANIMATIONS[name](n_frames)
|
|
32
|
+
return plotting.save_gif(fig, update, frames, Path(outdir) / f"{name}.gif", fps=fps)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@animation("double-pendulum")
|
|
36
|
+
def double_pendulum_anim(n_frames):
|
|
37
|
+
"""A chaotic double pendulum with a fading trail."""
|
|
38
|
+
sub = 10 # integrate on a finer grid so the trail is smooth
|
|
39
|
+
t, y = mechanics.double_pendulum([2.2, 2.6, 0.0, 0.0], t_max=15, n=n_frames * sub)
|
|
40
|
+
x1, y1, x2, y2 = mechanics.double_pendulum_xy(y)
|
|
41
|
+
fig, ax = plt.subplots(figsize=(4, 4))
|
|
42
|
+
ax.set(xlim=(-2.2, 2.2), ylim=(-2.2, 2.2), aspect="equal", xticks=[], yticks=[])
|
|
43
|
+
ax.grid(False)
|
|
44
|
+
(trail,) = ax.plot([], [], color="C1", lw=1, alpha=0.6)
|
|
45
|
+
(rods,) = ax.plot([], [], "o-", color="k", lw=2, ms=7, mfc="C0")
|
|
46
|
+
label = ax.text(0.03, 0.95, "", transform=ax.transAxes, va="top")
|
|
47
|
+
|
|
48
|
+
def update(frame):
|
|
49
|
+
i = frame * sub
|
|
50
|
+
rods.set_data([0, x1[i], x2[i]], [0, y1[i], y2[i]])
|
|
51
|
+
start = max(0, i - 25 * sub)
|
|
52
|
+
trail.set_data(x2[start:i + 1], y2[start:i + 1])
|
|
53
|
+
label.set_text(f"t = {t[i]:.1f} s")
|
|
54
|
+
|
|
55
|
+
return fig, update, range(n_frames)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@animation("wavepacket")
|
|
59
|
+
def wavepacket_anim(n_frames):
|
|
60
|
+
"""A Gaussian wavepacket tunnelling through and reflecting off a barrier."""
|
|
61
|
+
x = np.linspace(-60, 60, 2048, endpoint=False)
|
|
62
|
+
V = np.where(np.abs(x) < 1.0, 2.0, 0.0)
|
|
63
|
+
psi0 = quantum.gaussian_wavepacket(x, x0=-20, sigma=3, k0=1.5)
|
|
64
|
+
steps = 3000
|
|
65
|
+
times, psi = quantum.split_step(psi0, x, V + quantum.absorbing_layer(x, 10.0),
|
|
66
|
+
dt=0.01, steps=steps, save_every=max(1, steps // n_frames))
|
|
67
|
+
fig, ax = plt.subplots(figsize=(6, 3.2))
|
|
68
|
+
ax.fill_between(x, V / 2.0 * 0.15, color="0.85")
|
|
69
|
+
(line,) = ax.plot([], [], color="C0")
|
|
70
|
+
(real,) = ax.plot([], [], color="C1", lw=0.6, alpha=0.6)
|
|
71
|
+
ax.set(xlim=(-45, 45), ylim=(-0.4, 0.4), xlabel="x")
|
|
72
|
+
label = ax.text(0.02, 0.92, "", transform=ax.transAxes)
|
|
73
|
+
|
|
74
|
+
def update(i):
|
|
75
|
+
line.set_data(x, np.abs(psi[i]) ** 2)
|
|
76
|
+
real.set_data(x, psi[i].real * 0.6)
|
|
77
|
+
label.set_text(rf"$|\psi|^2$ and Re $\psi$, t = {times[i]:.1f}")
|
|
78
|
+
|
|
79
|
+
return fig, update, range(min(n_frames, len(psi)))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@animation("figure-eight")
|
|
83
|
+
def figure_eight_anim(n_frames):
|
|
84
|
+
"""Three equal masses chasing each other around a figure-eight."""
|
|
85
|
+
fe = mechanics.FIGURE_EIGHT
|
|
86
|
+
_, pos, _ = mechanics.nbody(fe["masses"], fe["pos0"], fe["vel0"], fe["period"], n=n_frames)
|
|
87
|
+
fig, ax = plt.subplots(figsize=(5, 2.8))
|
|
88
|
+
ax.plot(pos[:, 0, 0], pos[:, 0, 1], color="0.85", lw=1)
|
|
89
|
+
ax.set(xlim=(-1.2, 1.2), ylim=(-0.45, 0.45), aspect="equal", xticks=[], yticks=[])
|
|
90
|
+
ax.grid(False)
|
|
91
|
+
bodies = ax.scatter(pos[0, :, 0], pos[0, :, 1], s=60, c=["C0", "C1", "C2"], zorder=3)
|
|
92
|
+
|
|
93
|
+
def update(i):
|
|
94
|
+
bodies.set_offsets(pos[i])
|
|
95
|
+
|
|
96
|
+
return fig, update, range(n_frames)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@animation("double-slit")
|
|
100
|
+
def double_slit_anim(n_frames):
|
|
101
|
+
"""A 2D wavepacket passing through two slits and interfering."""
|
|
102
|
+
steps = 1600
|
|
103
|
+
run = quantum.double_slit_experiment(save_every=max(1, steps // n_frames))
|
|
104
|
+
x, y, psi = run["x"], run["y"], run["psi"]
|
|
105
|
+
amp = np.abs(psi)
|
|
106
|
+
# square-root colour scale: the transmitted ~13% stays visible next to the bright incident packet
|
|
107
|
+
norm = PowerNorm(gamma=0.5, vmin=0, vmax=np.percentile(amp, 99.9))
|
|
108
|
+
fig, ax = plt.subplots(figsize=(4.6, 4.25))
|
|
109
|
+
fig.subplots_adjust(left=0.02, right=0.98, bottom=0.02, top=0.98)
|
|
110
|
+
fig.patch.set_facecolor("#0B0B0D") # dark margins, so the GIF sits cleanly on dark pages
|
|
111
|
+
im = ax.imshow(amp[0], origin="lower", extent=[x[0], x[-1], y[0], y[-1]], cmap="magma", norm=norm)
|
|
112
|
+
ax.contour(x, y, run["V"], [1], colors="c", linewidths=0.8)
|
|
113
|
+
ax.set(xlim=(-15, 50), ylim=(-30, 30), xticks=[], yticks=[])
|
|
114
|
+
ax.grid(False)
|
|
115
|
+
label = ax.text(0.02, 0.94, "", transform=ax.transAxes, color="w")
|
|
116
|
+
|
|
117
|
+
def update(i):
|
|
118
|
+
im.set_data(amp[i])
|
|
119
|
+
label.set_text(rf"$|\psi|$, t = {run['times'][i]:.1f}")
|
|
120
|
+
|
|
121
|
+
return fig, update, range(min(n_frames, len(psi)))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@animation("ising")
|
|
125
|
+
def ising_anim(n_frames):
|
|
126
|
+
"""Domains coarsening in the 2D Ising model at the critical temperature."""
|
|
127
|
+
snaps: list = []
|
|
128
|
+
statmech.ising(L=128, T=statmech.T_CRITICAL, sweeps=n_frames, start="hot", seed=3, snapshots=snaps)
|
|
129
|
+
fig, ax = plt.subplots(figsize=(4, 4))
|
|
130
|
+
im = ax.imshow(snaps[0], cmap="gray", interpolation="nearest", vmin=-1, vmax=1)
|
|
131
|
+
ax.set(xticks=[], yticks=[])
|
|
132
|
+
ax.grid(False)
|
|
133
|
+
title = ax.set_title("")
|
|
134
|
+
|
|
135
|
+
def update(i):
|
|
136
|
+
im.set_data(snaps[i])
|
|
137
|
+
title.set_text(rf"Ising at $T_c$, sweep {i + 1}")
|
|
138
|
+
|
|
139
|
+
return fig, update, range(n_frames)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@animation("gas")
|
|
143
|
+
def gas_anim(n_frames):
|
|
144
|
+
"""Discs in a box: every one starts at the same speed and the speeds spread into Maxwell-Boltzmann."""
|
|
145
|
+
gas = kinetic.gas_box(n=400, dim=2, radius=0.009, t_max=8, save_every=2, seed=4)
|
|
146
|
+
stride = max(1, (len(gas.t) - 1) // max(1, n_frames - 1))
|
|
147
|
+
idx = np.arange(0, len(gas.t), stride)[:n_frames]
|
|
148
|
+
speeds = np.linalg.norm(gas.velocities, axis=-1)
|
|
149
|
+
kT = gas.temperature()
|
|
150
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7.6, 3.8), gridspec_kw={"width_ratios": [1, 1]})
|
|
151
|
+
ax1.set(xlim=(0, 1), ylim=(0, 1), aspect="equal", xticks=[], yticks=[])
|
|
152
|
+
ax1.grid(False)
|
|
153
|
+
dots = ax1.scatter(*gas.positions[0].T, s=9, c=speeds[0], cmap="plasma", vmin=0, vmax=2.5)
|
|
154
|
+
bins = np.linspace(0, 3, 31)
|
|
155
|
+
vs = np.linspace(0, 3, 200)
|
|
156
|
+
ax2.plot(vs, kinetic.maxwell_boltzmann_speed(vs, kT, dim=2), "k", lw=1.2, label="Maxwell-Boltzmann (2D)")
|
|
157
|
+
bars = ax2.bar(bins[:-1], np.zeros(30), width=bins[1] - bins[0], align="edge", color="C0", alpha=0.6)
|
|
158
|
+
ax2.set(xlim=(0, 3), ylim=(0, 2.2), xlabel="speed", yticks=[])
|
|
159
|
+
ax2.legend(loc="upper right", fontsize=8)
|
|
160
|
+
label = ax2.set_title("")
|
|
161
|
+
|
|
162
|
+
def update(frame):
|
|
163
|
+
k = idx[frame]
|
|
164
|
+
dots.set_offsets(gas.positions[k])
|
|
165
|
+
dots.set_array(speeds[k])
|
|
166
|
+
window = speeds[max(0, k - 40):k + 1].ravel() # smooth the histogram over recent steps
|
|
167
|
+
hist, _ = np.histogram(window, bins=bins, density=True)
|
|
168
|
+
for bar, h in zip(bars, hist):
|
|
169
|
+
bar.set_height(h)
|
|
170
|
+
label.set_text(f"t = {gas.t[k]:.2f}")
|
|
171
|
+
|
|
172
|
+
return fig, update, range(len(idx))
|
qplay/atomic.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
r"""Atomic structure: angular momentum matrices, spin-orbit coupling, the Zeeman effect and
|
|
2
|
+
hydrogen fine structure.
|
|
3
|
+
|
|
4
|
+
For one electron with orbital angular momentum $l$ and spin $s = \tfrac12$ in a magnetic field
|
|
5
|
+
$B$ along $z$ (energies in units where $\hbar = 1$):
|
|
6
|
+
|
|
7
|
+
$$H = \xi\,\mathbf L\cdot\mathbf S + \mu_B B\,(L_z + g_s S_z).$$
|
|
8
|
+
|
|
9
|
+
At $B = 0$ the levels are labelled by $j = l\pm\tfrac12$ with
|
|
10
|
+
$E = \tfrac{\xi}{2}\,[\,j(j+1) - l(l+1) - \tfrac34\,]$. A weak field splits each into
|
|
11
|
+
$E = g_J m_j \mu_B B$ with the Landé factor $g_J$; a strong field gives the Paschen-Back limit
|
|
12
|
+
$E \to \mu_B B\,(m_l + g_s m_s)$.
|
|
13
|
+
|
|
14
|
+
Hydrogen fine structure (Dirac, to order $\alpha^2$):
|
|
15
|
+
$$E_{nj} = -\frac{R_\infty hc}{n^2}\left[1 + \frac{\alpha^2}{n^2}\left(\frac{n}{j+\tfrac12} - \frac34\right)\right].$$
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from scipy import constants
|
|
22
|
+
|
|
23
|
+
ALPHA = constants.alpha
|
|
24
|
+
RYDBERG_EV = constants.physical_constants["Rydberg constant times hc in eV"][0]
|
|
25
|
+
MU_B_EV_PER_T = constants.physical_constants["Bohr magneton in eV/T"][0]
|
|
26
|
+
ELECTRON_G = -constants.physical_constants["electron g factor"][0] # about 2.0023
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def angular_momentum(j):
|
|
30
|
+
r"""Matrices ``(Jx, Jy, Jz)`` for spin ``j`` in the basis $m = j, j-1, \dots, -j$ ($\hbar = 1$).
|
|
31
|
+
|
|
32
|
+
Built from $J_\pm|j,m\rangle = \sqrt{j(j+1) - m(m\pm1)}\,|j,m\pm1\rangle$.
|
|
33
|
+
"""
|
|
34
|
+
m = np.arange(j, -j - 1, -1)
|
|
35
|
+
jp = np.diag(np.sqrt(j * (j + 1) - m[1:] * (m[1:] + 1)), 1).astype(complex) # raising
|
|
36
|
+
jm = jp.conj().T
|
|
37
|
+
return (jp + jm) / 2, (jp - jm) / 2j, np.diag(m).astype(complex)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def basis_labels(l, s=0.5):
|
|
41
|
+
r"""``(m_l, m_s)`` for each basis vector of :func:`spin_orbit_zeeman_hamiltonian`."""
|
|
42
|
+
return [(ml, ms) for ml in np.arange(l, -l - 1, -1) for ms in np.arange(s, -s - 1, -1)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def spin_orbit_zeeman_hamiltonian(l, xi, B, mu_b=1.0, gs=2.0, s=0.5):
|
|
46
|
+
r"""$H = \xi\,\mathbf L\cdot\mathbf S + \mu_B B\,(L_z + g_s S_z)$ in the $|m_l, m_s\rangle$ basis.
|
|
47
|
+
|
|
48
|
+
``xi`` and ``mu_b * B`` share whatever energy unit you choose.
|
|
49
|
+
"""
|
|
50
|
+
Lx, Ly, Lz = angular_momentum(l)
|
|
51
|
+
Sx, Sy, Sz = angular_momentum(s)
|
|
52
|
+
il, is_ = np.eye(Lz.shape[0]), np.eye(Sz.shape[0])
|
|
53
|
+
LS = sum(np.kron(a, b) for a, b in ((Lx, Sx), (Ly, Sy), (Lz, Sz)))
|
|
54
|
+
return xi * LS + mu_b * B * (np.kron(Lz, is_) + gs * np.kron(il, Sz))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def zeeman_levels(l, xi, B_values, mu_b=1.0, gs=2.0):
|
|
58
|
+
"""Sorted energy levels for each field in ``B_values``; shape ``(len(B_values), 2(2l+1))``."""
|
|
59
|
+
return np.array([np.linalg.eigvalsh(spin_orbit_zeeman_hamiltonian(l, xi, B, mu_b, gs))
|
|
60
|
+
for B in np.atleast_1d(B_values)])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def lande_g(l, j, s=0.5, gs=2.0):
|
|
64
|
+
r"""$g_J = 1 + (g_s - 1)\,\dfrac{j(j+1) + s(s+1) - l(l+1)}{2j(j+1)}$."""
|
|
65
|
+
return 1 + (gs - 1) * (j * (j + 1) + s * (s + 1) - l * (l + 1)) / (2 * j * (j + 1))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def spin_orbit_energy(l, j, xi, s=0.5):
|
|
69
|
+
r"""$\tfrac{\xi}{2}\,[\,j(j+1) - l(l+1) - s(s+1)\,]$, the eigenvalue of $\xi\,\mathbf L\cdot\mathbf S$."""
|
|
70
|
+
return xi / 2 * (j * (j + 1) - l * (l + 1) - s * (s + 1))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hydrogen_fine_structure_eV(n, j):
|
|
74
|
+
r"""Hydrogen level $E_{nj}$ in eV including fine structure to order $\alpha^2$ (see module docstring)."""
|
|
75
|
+
return -RYDBERG_EV / n**2 * (1 + ALPHA**2 / n**2 * (n / (j + 0.5) - 0.75))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def dirac_energy_eV(n, j):
|
|
79
|
+
r"""Exact Dirac energy minus $m_ec^2$:
|
|
80
|
+
$E = m_ec^2\left[\left(1 + \dfrac{\alpha^2}{(n - \delta)^2}\right)^{-1/2} - 1\right]$,
|
|
81
|
+
$\delta = j + \tfrac12 - \sqrt{(j+\tfrac12)^2 - \alpha^2}$."""
|
|
82
|
+
mc2 = constants.physical_constants["electron mass energy equivalent in MeV"][0] * 1e6
|
|
83
|
+
k = j + 0.5
|
|
84
|
+
delta = k - np.sqrt(k**2 - ALPHA**2)
|
|
85
|
+
x = (ALPHA / (n - delta)) ** 2
|
|
86
|
+
return mc2 * (1 / np.sqrt(1 + x) - 1)
|
qplay/chaos.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
r"""Chaos and fractals: Lorenz system, logistic map, Mandelbrot and Julia sets.
|
|
2
|
+
|
|
3
|
+
Logistic map: $x_{n+1} = r x_n (1 - x_n)$.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .integrators import solve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def lorenz(state0=(1.0, 1.0, 1.0), sigma=10.0, rho=28.0, beta=8 / 3, t_max=40.0, n=4000):
|
|
14
|
+
"""Lorenz system. Returns ``(t, y)`` with columns ``x, y, z``."""
|
|
15
|
+
def rhs(_, s):
|
|
16
|
+
x, y, z = s
|
|
17
|
+
return [sigma * (y - x), x * (rho - z) - y, x * y - beta * z]
|
|
18
|
+
|
|
19
|
+
return solve(rhs, state0, (0, t_max), n, rtol=1e-9, atol=1e-11)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def logistic_map(r, x0=0.5, n=100):
|
|
23
|
+
"""First ``n`` iterates (starting with ``x0``) of the logistic map."""
|
|
24
|
+
x = np.empty(n)
|
|
25
|
+
x[0] = x0
|
|
26
|
+
for i in range(n - 1):
|
|
27
|
+
x[i + 1] = r * x[i] * (1 - x[i])
|
|
28
|
+
return x
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def bifurcation(r_values, n_transient=500, n_keep=100, x0=0.5):
|
|
32
|
+
"""Attractor of the logistic map for each ``r``. Returns flat arrays ``(r, x)`` for scatter plots."""
|
|
33
|
+
r_values = np.asarray(r_values, float)
|
|
34
|
+
x = np.full_like(r_values, x0)
|
|
35
|
+
for _ in range(n_transient):
|
|
36
|
+
x = r_values * x * (1 - x)
|
|
37
|
+
rs, xs = [], []
|
|
38
|
+
for _ in range(n_keep):
|
|
39
|
+
x = r_values * x * (1 - x)
|
|
40
|
+
rs.append(r_values.copy())
|
|
41
|
+
xs.append(x.copy())
|
|
42
|
+
return np.concatenate(rs), np.concatenate(xs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def lyapunov_logistic(r, n=20000, x0=0.4, n_transient=200):
|
|
46
|
+
r"""Lyapunov exponent $\lambda=\langle\ln|r(1-2x)|\rangle$; positive means chaos."""
|
|
47
|
+
x = x0
|
|
48
|
+
for _ in range(n_transient):
|
|
49
|
+
x = r * x * (1 - x)
|
|
50
|
+
total = 0.0
|
|
51
|
+
for _ in range(n):
|
|
52
|
+
x = r * x * (1 - x)
|
|
53
|
+
total += np.log(abs(r * (1 - 2 * x)) + 1e-300)
|
|
54
|
+
return total / n
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def escape_time(c, max_iter=100, z0=0.0):
|
|
58
|
+
"""Iterations of $z\\to z^2+c$ before $|z|>2$ (``max_iter`` if it never escapes)."""
|
|
59
|
+
c = np.asarray(c, complex)
|
|
60
|
+
z = np.broadcast_to(np.asarray(z0, complex), c.shape).copy()
|
|
61
|
+
count = np.full(c.shape, max_iter, dtype=int)
|
|
62
|
+
alive = np.ones(c.shape, dtype=bool)
|
|
63
|
+
for i in range(max_iter):
|
|
64
|
+
z[alive] = z[alive] ** 2 + c[alive]
|
|
65
|
+
escaped = alive & (np.abs(z) > 2)
|
|
66
|
+
count[escaped] = i
|
|
67
|
+
alive &= ~escaped
|
|
68
|
+
return count
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _grid(xlim, ylim, width, height):
|
|
72
|
+
X, Y = np.meshgrid(np.linspace(*xlim, width), np.linspace(*ylim, height))
|
|
73
|
+
return X + 1j * Y
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def mandelbrot(xlim=(-2.2, 0.8), ylim=(-1.2, 1.2), width=600, height=400, max_iter=100):
|
|
77
|
+
return escape_time(_grid(xlim, ylim, width, height), max_iter)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def julia(c, xlim=(-1.6, 1.6), ylim=(-1.2, 1.2), width=600, height=400, max_iter=100):
|
|
81
|
+
"""Julia set for $z\\to z^2 + c$: the grid supplies the starting points $z_0$."""
|
|
82
|
+
z0 = _grid(xlim, ylim, width, height)
|
|
83
|
+
return escape_time(np.full(z0.shape, c, dtype=complex), max_iter, z0=z0)
|
qplay/circuits.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
r"""Electric circuits: capacitor charge and discharge, and the series RLC circuit.
|
|
2
|
+
|
|
3
|
+
Series RLC with source $V(t)$ (charge $q$ on the capacitor, current $I = \dot q$):
|
|
4
|
+
|
|
5
|
+
$$L\ddot q + R\dot q + \frac{q}{C} = V(t).$$
|
|
6
|
+
|
|
7
|
+
With $L = 0$ this is the RC circuit, time constant $\tau = RC$. The undamped resonant angular
|
|
8
|
+
frequency is $\omega_0 = 1/\sqrt{LC}$, the quality factor $Q = \frac1R\sqrt{L/C}$, and for a drive
|
|
9
|
+
$V_0\cos\omega t$ the steady current amplitude is $V_0/|Z|$ with
|
|
10
|
+
$Z = R + i\left(\omega L - \frac{1}{\omega C}\right)$.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .integrators import solve
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _source(V):
|
|
21
|
+
return V if callable(V) else (lambda t, V=float(V): V)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def rc_discharge(t, V0, R, C):
|
|
25
|
+
r"""Capacitor voltage while discharging: $V = V_0 e^{-t/RC}$."""
|
|
26
|
+
return V0 * np.exp(-np.asarray(t, float) / (R * C))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def rc_charge(t, V0, R, C):
|
|
30
|
+
r"""Capacitor voltage while charging from a supply $V_0$: $V = V_0(1 - e^{-t/RC})$."""
|
|
31
|
+
return V0 * -np.expm1(-np.asarray(t, float) / (R * C))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rc_circuit(R, C, V=0.0, q0=0.0, t_max=1.0, n=500):
|
|
35
|
+
r"""Integrate $R\dot q + q/C = V(t)$. ``V`` is a number or a function of $t$.
|
|
36
|
+
|
|
37
|
+
Returns ``(t, q, I)``.
|
|
38
|
+
"""
|
|
39
|
+
src = _source(V)
|
|
40
|
+
t, y = solve(lambda t, q: [(src(t) - q[0] / C) / R], [q0], (0, t_max), n)
|
|
41
|
+
q = y[:, 0]
|
|
42
|
+
return t, q, (np.array([src(ti) for ti in t]) - q / C) / R
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def rlc_series(R, L, C, V=0.0, q0=0.0, i0=0.0, t_max=1.0, n=1000):
|
|
46
|
+
r"""Integrate $L\ddot q + R\dot q + q/C = V(t)$. Returns ``(t, q, I)``."""
|
|
47
|
+
src = _source(V)
|
|
48
|
+
|
|
49
|
+
def rhs(t, y):
|
|
50
|
+
q, i = y
|
|
51
|
+
return [i, (src(t) - R * i - q / C) / L]
|
|
52
|
+
|
|
53
|
+
t, y = solve(rhs, [q0, i0], (0, t_max), n)
|
|
54
|
+
return t, y[:, 0], y[:, 1]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resonant_frequency(L, C):
|
|
58
|
+
r"""$\omega_0 = 1/\sqrt{LC}$ in rad/s."""
|
|
59
|
+
return 1 / np.sqrt(L * C)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def quality_factor(R, L, C):
|
|
63
|
+
r"""$Q = \dfrac{1}{R}\sqrt{\dfrac{L}{C}}$; the resonance full width at half power is $\omega_0/Q = R/L$."""
|
|
64
|
+
return np.sqrt(L / C) / R
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def impedance(omega, R, L, C):
|
|
68
|
+
r"""Complex impedance $Z = R + i(\omega L - 1/\omega C)$ of the series circuit."""
|
|
69
|
+
omega = np.asarray(omega, float)
|
|
70
|
+
return R + 1j * (omega * L - 1 / (omega * C))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def current_amplitude(omega, V0, R, L, C):
|
|
74
|
+
r"""Steady-state current amplitude $I_0 = V_0/|Z|$ for a drive $V_0\cos\omega t$."""
|
|
75
|
+
return V0 / np.abs(impedance(omega, R, L, C))
|
qplay/complexplane.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
r"""Complex functions: domain colouring and the argument principle.
|
|
2
|
+
|
|
3
|
+
Argument principle: $\frac{1}{2\pi i}\oint_C \frac{f'(z)}{f(z)}\,dz = N_{\text{zeros}} - N_{\text{poles}}$ inside $C$,
|
|
4
|
+
which equals the number of times $f(C)$ winds around $0$.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from matplotlib.colors import hsv_to_rgb
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def complex_grid(xlim=(-2, 2), ylim=(-2, 2), n=500):
|
|
14
|
+
X, Y = np.meshgrid(np.linspace(*xlim, n), np.linspace(*ylim, n))
|
|
15
|
+
return X + 1j * Y
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def domain_coloring(f, xlim=(-2, 2), ylim=(-2, 2), n=500):
|
|
19
|
+
"""RGB image of ``f``: hue shows $\\arg f$ (red = positive real), brightness bands show $\\log_2|f|$.
|
|
20
|
+
|
|
21
|
+
Returns an array of shape ``(n, n, 3)`` with row 0 at ``ylim[0]`` (use ``origin="lower"``).
|
|
22
|
+
"""
|
|
23
|
+
with np.errstate(all="ignore"):
|
|
24
|
+
w = f(complex_grid(xlim, ylim, n))
|
|
25
|
+
hue = (np.angle(w) / (2 * np.pi)) % 1.0
|
|
26
|
+
bands = np.log2(np.abs(w)) % 1.0
|
|
27
|
+
bad = ~np.isfinite(w)
|
|
28
|
+
value = np.where(bad, 1.0, 0.55 + 0.45 * np.nan_to_num(bands))
|
|
29
|
+
sat = np.where(bad, 0.0, 0.85)
|
|
30
|
+
return hsv_to_rgb(np.stack([np.nan_to_num(hue), sat, value], axis=-1))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def winding_number(f, center=0.0, radius=1.0, n=4000):
|
|
34
|
+
"""How many times ``f`` maps the circle ``|z - center| = radius`` around the origin.
|
|
35
|
+
|
|
36
|
+
By the argument principle this is (zeros - poles) inside the circle, counted with multiplicity.
|
|
37
|
+
The circle must not pass through a zero or pole.
|
|
38
|
+
"""
|
|
39
|
+
theta = np.linspace(0, 2 * np.pi, n + 1)
|
|
40
|
+
w = f(center + radius * np.exp(1j * theta))
|
|
41
|
+
return int(round(np.sum(np.diff(np.unwrap(np.angle(w)))) / (2 * np.pi)))
|